mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,126 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mdl_AchievementDefinitions = require('../../models/users/achievement_definitions.mdl');
|
||||||
|
const CourseAchievement = require('../../models/courses/course_achievement.mdl');
|
||||||
|
const R = require('../../utils/response.util');
|
||||||
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
|
|
||||||
|
const VALID_TYPES = ['badge', 'milestone'];
|
||||||
|
|
||||||
|
// ─── GET /admin/achievements ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getAchievements = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const achievements = await mdl_AchievementDefinitions.findAll({
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
});
|
||||||
|
return R.success(res, 'Achievements retrieved.', achievements);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET ACHIEVEMENTS]', err);
|
||||||
|
return R.error(res, 'Could not retrieve achievements.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GET /admin/achievements/:id ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getAchievement = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||||
|
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||||
|
return R.success(res, 'Achievement retrieved.', achievement);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET ACHIEVEMENT]', err);
|
||||||
|
return R.error(res, 'Could not retrieve achievement.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── POST /admin/achievements ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.createAchievement = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||||
|
if (!key || !label) return R.error(res, 'key and label are required.', 400);
|
||||||
|
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||||
|
|
||||||
|
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||||
|
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||||
|
|
||||||
|
const achievement = await mdl_AchievementDefinitions.create({
|
||||||
|
key,
|
||||||
|
type: type || 'badge',
|
||||||
|
label,
|
||||||
|
description: description ?? null,
|
||||||
|
icon: icon || null,
|
||||||
|
trigger: trigger || null,
|
||||||
|
is_active: is_active !== undefined ? !!is_active : true,
|
||||||
|
is_system: false, // only seed data may be system-protected
|
||||||
|
});
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'create_achievement', { entityType: 'achievement', details: { key, label } });
|
||||||
|
|
||||||
|
return R.success(res, 'Achievement created.', achievement, 201);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][CREATE ACHIEVEMENT]', err);
|
||||||
|
return R.error(res, 'Could not create achievement.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── PUT /admin/achievements/:id ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.updateAchievement = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||||
|
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||||
|
|
||||||
|
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||||
|
|
||||||
|
if (achievement.is_system && key !== undefined && key !== achievement.key)
|
||||||
|
return R.error(res, 'The key of a system achievement cannot be changed.', 400);
|
||||||
|
if (achievement.is_system && type !== undefined && type !== achievement.type)
|
||||||
|
return R.error(res, 'The type of a system achievement cannot be changed.', 400);
|
||||||
|
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||||
|
|
||||||
|
if (!achievement.is_system && key !== undefined && key !== achievement.key) {
|
||||||
|
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||||
|
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await achievement.update({
|
||||||
|
key: (!achievement.is_system && key !== undefined) ? key : achievement.key,
|
||||||
|
type: (!achievement.is_system && type !== undefined) ? type : achievement.type,
|
||||||
|
label: label ?? achievement.label,
|
||||||
|
description: description !== undefined ? (description || null) : achievement.description,
|
||||||
|
icon: icon !== undefined ? (icon || null) : achievement.icon,
|
||||||
|
trigger: trigger !== undefined ? (trigger || null) : achievement.trigger,
|
||||||
|
is_active: is_active !== undefined ? !!is_active : achievement.is_active,
|
||||||
|
});
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'update_achievement', { entityType: 'achievement', details: { id: achievement.achievement_definition_id, key: achievement.key } });
|
||||||
|
|
||||||
|
return R.success(res, 'Achievement updated.', achievement);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][UPDATE ACHIEVEMENT]', err);
|
||||||
|
return R.error(res, 'Could not update achievement.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── DELETE /admin/achievements/:id ───────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.deleteAchievement = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||||
|
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||||
|
if (achievement.is_system) return R.error(res, 'Built-in system achievements cannot be deleted.', 400);
|
||||||
|
|
||||||
|
const assignedCourses = await CourseAchievement.count({ where: { achievement_key: achievement.key } });
|
||||||
|
if (assignedCourses > 0)
|
||||||
|
return R.error(res, `Cannot delete — ${assignedCourses} course(s) still reference this achievement. Unassign it first.`, 409);
|
||||||
|
|
||||||
|
await achievement.destroy();
|
||||||
|
logActivity(req.user?.user_id, 'delete_achievement', { entityType: 'achievement', details: { key: achievement.key } });
|
||||||
|
return R.success(res, 'Achievement deleted.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][DELETE ACHIEVEMENT]', err);
|
||||||
|
return R.error(res, 'Could not delete achievement.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,9 +4,11 @@ const sequelize = require("../../config/db.config");
|
|||||||
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
||||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const mediaToken = require("../../services/mediaToken.service");
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { paginate } = require("../../utils/paginate.util");
|
const { paginate } = require("../../utils/paginate.util");
|
||||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
|
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
|
||||||
|
const { PLACEMENT_MAP, PLACEMENT_KEYS } = require("../../models/advertisements/advertisements.placements");
|
||||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
|
|
||||||
@@ -16,9 +18,32 @@ const { Op } = require('sequelize');
|
|||||||
|
|
||||||
const notDeleted = { deletedAt: null };
|
const notDeleted = { deletedAt: null };
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
|
|
||||||
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
|
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
|
||||||
|
|
||||||
|
// Fields needed off the associated Asset to render a preview AND (for S3 assets)
|
||||||
|
// mint a stream token — storage_key is stripped again in attachImageStreamToken
|
||||||
|
// before the row is ever sent out.
|
||||||
|
const AD_IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
|
||||||
|
|
||||||
|
// ─── Media proxying ─────────────────────────────────────────────────────────
|
||||||
|
// Mirrors controllers/admin/assets.controller.js's redactS3Url/attachStreamTokens.
|
||||||
|
// Private (S3-backed) advertisement images must never expose a raw file_url to
|
||||||
|
// the browser — mint a short-lived stream token instead so the frontend resolves
|
||||||
|
// it through GET /api/client/media/stream/:token. Public/chibisafe images keep
|
||||||
|
// their direct file_url (no proxy needed).
|
||||||
|
async function attachImageStreamToken(image, req) {
|
||||||
|
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
const ip = mediaToken.resolveIp(req);
|
||||||
|
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||||
|
image.stream_token = token;
|
||||||
|
image.file_url = null;
|
||||||
|
image.thumbnail_url = null;
|
||||||
|
delete image.storage_key;
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Status derivation ─────────────────────────────────────────────────────
|
// ─── Status derivation ─────────────────────────────────────────────────────
|
||||||
// status is never trusted as manually-set truth — it's derived from is_active
|
// status is never trusted as manually-set truth — it's derived from is_active
|
||||||
// + start_date/end_date every time an advertisement is read or written.
|
// + start_date/end_date every time an advertisement is read or written.
|
||||||
@@ -53,13 +78,17 @@ function normalizeCtas(ctas) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyAdvertisementFields(advertisement, body) {
|
async function applyAdvertisementFields(advertisement, body) {
|
||||||
if (body.type !== undefined) {
|
// placement is the only settable "where" — type/format is always derived
|
||||||
if (!ALLOWED_TYPES.includes(body.type)) {
|
// from the placement's registry entry, never accepted directly from the body.
|
||||||
const err = new Error(`Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`);
|
if (body.placement !== undefined) {
|
||||||
|
const entry = PLACEMENT_MAP[body.placement];
|
||||||
|
if (!entry) {
|
||||||
|
const err = new Error(`Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`);
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
advertisement.type = body.type;
|
advertisement.placement = body.placement;
|
||||||
|
advertisement.type = entry.format;
|
||||||
}
|
}
|
||||||
|
|
||||||
// status is intentionally NOT settable here — it's derived via deriveStatus()
|
// status is intentionally NOT settable here — it's derived via deriveStatus()
|
||||||
@@ -93,8 +122,8 @@ async function applyAdvertisementFields(advertisement, body) {
|
|||||||
if (body.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true";
|
if (body.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true";
|
||||||
|
|
||||||
if (body.size !== undefined) {
|
if (body.size !== undefined) {
|
||||||
if (body.size !== null && !["sm", "md", "lg"].includes(body.size)) {
|
if (body.size !== null && !["sm", "md", "lg", "xl"].includes(body.size)) {
|
||||||
const err = new Error(`Invalid size. Must be one of: sm, md, lg`);
|
const err = new Error(`Invalid size. Must be one of: sm, md, lg, xl`);
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -120,7 +149,7 @@ exports.getAdvertisements = async (req, res) => {
|
|||||||
include: [{
|
include: [{
|
||||||
model: mdl_Assets,
|
model: mdl_Assets,
|
||||||
as: "image",
|
as: "image",
|
||||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
|
attributes: AD_IMAGE_ATTRIBUTES,
|
||||||
required: false,
|
required: false,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -129,7 +158,10 @@ exports.getAdvertisements = async (req, res) => {
|
|||||||
// Resync status on the way out — never trust what's stored, since
|
// Resync status on the way out — never trust what's stored, since
|
||||||
// start_date/end_date may have lapsed since the row was last saved.
|
// start_date/end_date may have lapsed since the row was last saved.
|
||||||
if (Array.isArray(result?.data)) {
|
if (Array.isArray(result?.data)) {
|
||||||
result.data = result.data.map((row) => ({ ...row, status: deriveStatus(row) }));
|
result.data = await Promise.all(result.data.map(async (row) => {
|
||||||
|
if (row.image) await attachImageStreamToken(row.image, req);
|
||||||
|
return { ...row, status: deriveStatus(row) };
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return R.success(res, "Advertisements retrieved.", result);
|
return R.success(res, "Advertisements retrieved.", result);
|
||||||
@@ -149,7 +181,7 @@ exports.getAdvertisement = async (req, res) => {
|
|||||||
const advertisement = await Advertisement.findOne({
|
const advertisement = await Advertisement.findOne({
|
||||||
where: { advertisement_id: advertisementId, ...notDeleted },
|
where: { advertisement_id: advertisementId, ...notDeleted },
|
||||||
include: [
|
include: [
|
||||||
{ model: mdl_Assets, as: "image", attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], required: false },
|
{ model: mdl_Assets, as: "image", attributes: AD_IMAGE_ATTRIBUTES, required: false },
|
||||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||||
],
|
],
|
||||||
@@ -160,6 +192,8 @@ exports.getAdvertisement = async (req, res) => {
|
|||||||
const json = advertisement.toJSON();
|
const json = advertisement.toJSON();
|
||||||
json.status = deriveStatus(json);
|
json.status = deriveStatus(json);
|
||||||
|
|
||||||
|
if (json.image) await attachImageStreamToken(json.image, req);
|
||||||
|
|
||||||
if (json.creator) {
|
if (json.creator) {
|
||||||
json.creator = {
|
json.creator = {
|
||||||
user_id: json.creator.user_id,
|
user_id: json.creator.user_id,
|
||||||
@@ -184,20 +218,21 @@ exports.getAdvertisement = async (req, res) => {
|
|||||||
|
|
||||||
exports.createAdvertisement = async (req, res) => {
|
exports.createAdvertisement = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { type, createdBy } = req.body;
|
const { placement, createdBy } = req.body;
|
||||||
|
|
||||||
if (!type) return R.error(res, "type is required.", 400);
|
if (!placement) return R.error(res, "placement is required.", 400);
|
||||||
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
|
const entry = PLACEMENT_MAP[placement];
|
||||||
|
if (!entry) return R.error(res, `Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`, 400);
|
||||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||||
|
|
||||||
const t = await sequelize.transaction();
|
const t = await sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
const advertisement = await Advertisement.build({ type, createdBy });
|
const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
|
||||||
await applyAdvertisementFields(advertisement, req.body);
|
await applyAdvertisementFields(advertisement, req.body);
|
||||||
await advertisement.save({ transaction: t });
|
await advertisement.save({ transaction: t });
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
|
||||||
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { type: advertisement.type } });
|
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { placement: advertisement.placement, type: advertisement.type } });
|
||||||
return R.success(res, "Advertisement created.", { data: advertisement }, 201);
|
return R.success(res, "Advertisement created.", { data: advertisement }, 201);
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
try { await t.rollback(); } catch { /* connection gone */ }
|
try { await t.rollback(); } catch { /* connection gone */ }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const sequelize = require("../../config/db.config");
|
|||||||
const Asset = require("../../models/assets/assets.mdl");
|
const Asset = require("../../models/assets/assets.mdl");
|
||||||
const chibi = require("../../services/chibisafe.service");
|
const chibi = require("../../services/chibisafe.service");
|
||||||
const s3 = require("../../services/s3.service");
|
const s3 = require("../../services/s3.service");
|
||||||
|
const mediaToken = require("../../services/mediaToken.service");
|
||||||
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const { paginate } = require("../../utils/paginate.util");
|
const { paginate } = require("../../utils/paginate.util");
|
||||||
@@ -20,6 +21,29 @@ const { Op } = require('sequelize');
|
|||||||
|
|
||||||
const notDeleted = { deletedAt: null };
|
const notDeleted = { deletedAt: null };
|
||||||
|
|
||||||
|
// List queries keep storage_key selected (unlike adminExclude) so
|
||||||
|
// attachStreamTokens can sign a stream token server-side without a second
|
||||||
|
// query — it's deleted from every row before the response is sent.
|
||||||
|
const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key");
|
||||||
|
|
||||||
|
// ─── In-memory list cache (no Redis yet) ───────────────────────────────────────
|
||||||
|
// Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g.
|
||||||
|
// AssetPickerSheet being opened/closed repeatedly with the same filters — so
|
||||||
|
// Postgres isn't re-queried on every toggle. Cleared on any mutation below.
|
||||||
|
// Single-process only; fine for one instance, won't stay consistent across
|
||||||
|
// multiple app instances without a shared store like Redis.
|
||||||
|
const LIST_CACHE_TTL_MS = 20_000;
|
||||||
|
const listCache = new Map(); // queryKey -> { result, expiresAt }
|
||||||
|
|
||||||
|
function listCacheKey(req) {
|
||||||
|
return JSON.stringify({
|
||||||
|
page: req.query.page, limit: req.query.limit,
|
||||||
|
filters: req.query.filters, sort: req.query.sort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateListCache() { listCache.clear(); }
|
||||||
|
|
||||||
function resolveFileType(mimeType = "") {
|
function resolveFileType(mimeType = "") {
|
||||||
if (mimeType.startsWith("image/")) return "image";
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
if (mimeType.startsWith("video/")) return "video";
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
@@ -166,12 +190,51 @@ function redactS3Url(asset) {
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── attachStreamTokens ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row
|
||||||
|
// so pickers/tables reading the list can render thumbnails immediately instead
|
||||||
|
// of firing a second POST /admin/media/tokens round-trip and waiting on it.
|
||||||
|
// storage_key is kept out of the DB attribute exclude list (unlike the rest of
|
||||||
|
// adminExclude) purely so it's available here to sign the token — it's still
|
||||||
|
// stripped from every row before the response goes out.
|
||||||
|
//
|
||||||
|
// Operates on shallow copies: `result.data` is shared with listCache, and
|
||||||
|
// mutating those rows in place would delete storage_key from the cached
|
||||||
|
// objects, breaking token issuance for the next request that hits the cache.
|
||||||
|
//
|
||||||
|
async function attachStreamTokens(rows, req) {
|
||||||
|
const ip = mediaToken.resolveIp(req);
|
||||||
|
const userId = req.user?.user_id;
|
||||||
|
|
||||||
|
return Promise.all(rows.map(async (original) => {
|
||||||
|
const row = { ...original };
|
||||||
|
const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type);
|
||||||
|
|
||||||
|
if (eligible) {
|
||||||
|
const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip);
|
||||||
|
row.stream_token = token;
|
||||||
|
if (thumbnail_url) row.thumbnail_url = thumbnail_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete row.storage_key;
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getAssets = async (req, res) => {
|
exports.getAssets = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await paginate(Asset, req, {
|
const key = listCacheKey(req);
|
||||||
excludeAttributes: adminExclude,
|
const cached = listCache.get(key);
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (cached && Date.now() < cached.expiresAt) {
|
||||||
|
result = cached.result;
|
||||||
|
} else {
|
||||||
|
result = await paginate(Asset, req, {
|
||||||
|
excludeAttributes: LIST_QUERY_EXCLUDE,
|
||||||
jsonbSchemas,
|
jsonbSchemas,
|
||||||
computedAttributes,
|
computedAttributes,
|
||||||
context: "list",
|
context: "list",
|
||||||
@@ -179,7 +242,11 @@ exports.getAssets = async (req, res) => {
|
|||||||
findOptions: { where: { deletedAt: null } },
|
findOptions: { where: { deletedAt: null } },
|
||||||
});
|
});
|
||||||
result.data = result.data.map(redactS3Url);
|
result.data = result.data.map(redactS3Url);
|
||||||
return R.success(res, "Assets retrieved.", result);
|
listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await attachStreamTokens(result.data, req);
|
||||||
|
return R.success(res, "Assets retrieved.", { ...result, data });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ASSET][GET ALL]", err);
|
console.error("[ASSET][GET ALL]", err);
|
||||||
return R.error(res, "Could not retrieve assets.", 500);
|
return R.error(res, "Could not retrieve assets.", 500);
|
||||||
@@ -195,7 +262,9 @@ exports.getAsset = async (req, res) => {
|
|||||||
|
|
||||||
const asset = await Asset.findOne({
|
const asset = await Asset.findOne({
|
||||||
where: { asset_id: assetId, ...notDeleted },
|
where: { asset_id: assetId, ...notDeleted },
|
||||||
attributes: { exclude: ["storage_key", "storage_bucket"] },
|
// storage_key stays selected here (unlike the list query) so it's
|
||||||
|
// available below to sign a stream token — stripped before the response.
|
||||||
|
attributes: { exclude: ["storage_bucket"] },
|
||||||
include: [
|
include: [
|
||||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||||
@@ -219,6 +288,14 @@ exports.getAsset = async (req, res) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) {
|
||||||
|
const ip = mediaToken.resolveIp(req);
|
||||||
|
const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip);
|
||||||
|
json.stream_token = token;
|
||||||
|
if (thumbnail_url) json.thumbnail_url = thumbnail_url;
|
||||||
|
}
|
||||||
|
delete json.storage_key;
|
||||||
|
|
||||||
redactS3Url(json);
|
redactS3Url(json);
|
||||||
return R.success(res, "Asset retrieved.", { data: json });
|
return R.success(res, "Asset retrieved.", { data: json });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -404,6 +481,7 @@ exports.uploadAsset = async (req, res) => {
|
|||||||
}, { transaction: t });
|
}, { transaction: t });
|
||||||
|
|
||||||
await t.commit();
|
await t.commit();
|
||||||
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||||
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
return R.success(res, "Asset uploaded.", { data: asset }, 201);
|
||||||
|
|
||||||
@@ -471,6 +549,7 @@ exports.updateAsset = async (req, res) => {
|
|||||||
|
|
||||||
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
|
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
|
||||||
|
|
||||||
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||||
return R.success(res, "Asset updated.", { data: asset });
|
return R.success(res, "Asset updated.", { data: asset });
|
||||||
|
|
||||||
@@ -494,6 +573,7 @@ exports.archiveAsset = async (req, res) => {
|
|||||||
|
|
||||||
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
||||||
await asset.destroy();
|
await asset.destroy();
|
||||||
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||||
return R.success(res, "Asset archived.");
|
return R.success(res, "Asset archived.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -517,6 +597,7 @@ exports.archiveAssets = async (req, res) => {
|
|||||||
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
|
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
|
||||||
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
||||||
|
|
||||||
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
||||||
return R.success(res, `${activeIds.length} asset(s) archived.`, {
|
return R.success(res, `${activeIds.length} asset(s) archived.`, {
|
||||||
archived_ids: activeIds,
|
archived_ids: activeIds,
|
||||||
@@ -540,6 +621,7 @@ exports.restoreAsset = async (req, res) => {
|
|||||||
|
|
||||||
await asset.restore();
|
await asset.restore();
|
||||||
await asset.update({ deletedBy: null });
|
await asset.update({ deletedBy: null });
|
||||||
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||||
return R.success(res, "Asset restored.", { data: asset });
|
return R.success(res, "Asset restored.", { data: asset });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -566,6 +648,7 @@ exports.restoreAssets = async (req, res) => {
|
|||||||
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
|
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
|
||||||
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
|
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
|
||||||
|
|
||||||
|
invalidateListCache();
|
||||||
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||||
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
|
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
|
||||||
restored_ids: archivedIds,
|
restored_ids: archivedIds,
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ const {
|
|||||||
UnitQuiz, QuizQuestion, QuizOption,
|
UnitQuiz, QuizQuestion, QuizOption,
|
||||||
QuizAttempt, AssessmentSession,
|
QuizAttempt, AssessmentSession,
|
||||||
CourseInstructor, CourseAchievement,
|
CourseInstructor, CourseAchievement,
|
||||||
|
UnitReadingProgress, LessonReadingProgress,
|
||||||
} = require("../../models/courses/courses.associations");
|
} = require("../../models/courses/courses.associations");
|
||||||
|
|
||||||
const mdl_Users = require("../../models/users/users.mdl");
|
const mdl_Users = require("../../models/users/users.mdl");
|
||||||
|
|
||||||
|
const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
excludeAttributes: courseExclude,
|
excludeAttributes: courseExclude,
|
||||||
computedAttributes: courseComputed,
|
computedAttributes: courseComputed,
|
||||||
@@ -46,7 +49,15 @@ exports.getCourses = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const result = await paginate(Course, req, {
|
const result = await paginate(Course, req, {
|
||||||
excludeAttributes: courseExclude,
|
excludeAttributes: courseExclude,
|
||||||
computedAttributes: courseComputed,
|
computedAttributes: [
|
||||||
|
...courseComputed,
|
||||||
|
{
|
||||||
|
key: "assessment_id",
|
||||||
|
label: "Assessment ID",
|
||||||
|
type: "text",
|
||||||
|
literal: `(SELECT assessment_id FROM course_assessments WHERE course_id = "Course"."course_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
auditOptions: { mdl_Users, parentAlias: "Course" },
|
auditOptions: { mdl_Users, parentAlias: "Course" },
|
||||||
context: "list",
|
context: "list",
|
||||||
findOptions: { where: { ...notDeleted } },
|
findOptions: { where: { ...notDeleted } },
|
||||||
@@ -126,7 +137,7 @@ exports.createCourse = async (req, res) => {
|
|||||||
|
|
||||||
if (achievement_keys.length) {
|
if (achievement_keys.length) {
|
||||||
await CourseAchievement.bulkCreate(
|
await CourseAchievement.bulkCreate(
|
||||||
achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
|
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
|
||||||
{ transaction: t },
|
{ transaction: t },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,6 +289,30 @@ exports.bulkRestoreCourses = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.getCourseArchiveImpact = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
|
||||||
|
const [activeCount, totalCount] = await Promise.all([
|
||||||
|
UnitReadingProgress.count({
|
||||||
|
where: { course_id: courseId, status: "in_progress" },
|
||||||
|
distinct: true,
|
||||||
|
col: "user_id",
|
||||||
|
}),
|
||||||
|
UnitReadingProgress.count({
|
||||||
|
where: { course_id: courseId },
|
||||||
|
distinct: true,
|
||||||
|
col: "user_id",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return R.success(res, "Impact retrieved.", { activeCount, totalCount });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[COURSE][ARCHIVE IMPACT]", err);
|
||||||
|
return R.error(res, "Could not retrieve impact.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
// COURSE PREREQUISITES
|
// COURSE PREREQUISITES
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -364,6 +399,22 @@ exports.getUnits = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.getUnitArchiveImpact = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { unitId } = req.params;
|
||||||
|
|
||||||
|
const [completionCount, progressCount] = await Promise.all([
|
||||||
|
UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }),
|
||||||
|
LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return R.success(res, "Impact retrieved.", { completionCount, progressCount });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[UNIT][ARCHIVE IMPACT]", err);
|
||||||
|
return R.error(res, "Could not retrieve impact.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
exports.getUnit = async (req, res) => {
|
exports.getUnit = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { courseId, unitId } = req.params;
|
const { courseId, unitId } = req.params;
|
||||||
@@ -1234,6 +1285,87 @@ exports.bulkRestoreQuestions = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.bulkSyncQuestions = async (req, res) => {
|
||||||
|
const t = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const parent = await resolveQuestionParent(req.params);
|
||||||
|
if (!parent?.parentRecord) return R.error(res, "Parent not found.", 404);
|
||||||
|
|
||||||
|
const { questions = [], updatedBy } = req.body;
|
||||||
|
|
||||||
|
const existing = await QuizQuestion.findAll({
|
||||||
|
where: { [parent.parentField]: parent.parentId, ...notDeleted },
|
||||||
|
});
|
||||||
|
const existingIds = existing.map((q) => q.question_id);
|
||||||
|
const incomingIds = questions.filter((q) => q.question_id).map((q) => q.question_id);
|
||||||
|
const toArchive = existingIds.filter((id) => !incomingIds.includes(id));
|
||||||
|
|
||||||
|
if (toArchive.length) {
|
||||||
|
await QuizQuestion.update(
|
||||||
|
{ deletedAt: new Date(), deletedBy: updatedBy ?? null },
|
||||||
|
{ where: { question_id: toArchive }, transaction: t }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const { question_id, type, question, explanation, points, options = [] } = questions[i];
|
||||||
|
|
||||||
|
if (question_id && existingIds.includes(question_id)) {
|
||||||
|
const q = existing.find((e) => e.question_id === question_id);
|
||||||
|
q.type = type ?? q.type;
|
||||||
|
q.question = question ?? q.question;
|
||||||
|
q.explanation = explanation ?? null;
|
||||||
|
q.order_index = i;
|
||||||
|
q.points = points ?? q.points;
|
||||||
|
q.updatedBy = updatedBy ?? null;
|
||||||
|
await q.save({ transaction: t });
|
||||||
|
|
||||||
|
await QuizOption.destroy({ where: { question_id }, transaction: t });
|
||||||
|
if (options.length) {
|
||||||
|
await QuizOption.bulkCreate(
|
||||||
|
options.map((o, oi) => ({ question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.push(question_id);
|
||||||
|
} else {
|
||||||
|
const q = await QuizQuestion.create({
|
||||||
|
[parent.parentField]: parent.parentId,
|
||||||
|
type, question,
|
||||||
|
explanation: explanation ?? null,
|
||||||
|
order_index: i,
|
||||||
|
points: points ?? 1,
|
||||||
|
createdBy: updatedBy ?? null,
|
||||||
|
}, { transaction: t });
|
||||||
|
|
||||||
|
if (options.length) {
|
||||||
|
await QuizOption.bulkCreate(
|
||||||
|
options.map((o, oi) => ({ question_id: q.question_id, text: o.text, is_correct: o.is_correct ?? false, order_index: oi })),
|
||||||
|
{ transaction: t }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result.push(q.question_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.commit();
|
||||||
|
|
||||||
|
const synced = await QuizQuestion.findAll({
|
||||||
|
where: { question_id: result },
|
||||||
|
order: [["order_index", "ASC"]],
|
||||||
|
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'bulk_sync_questions', { entityType: 'question', details: { parentField: parent.parentField, parentId: parent.parentId, count: synced.length } });
|
||||||
|
return R.success(res, "Questions synced.", { data: synced });
|
||||||
|
} catch (err) {
|
||||||
|
await t.rollback();
|
||||||
|
console.error("[QUESTION][BULK SYNC]", err);
|
||||||
|
return R.error(res, "Could not sync questions.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
// COURSE ASSESSMENT
|
// COURSE ASSESSMENT
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -1341,11 +1473,12 @@ exports.updateAssessment = async (req, res) => {
|
|||||||
|
|
||||||
const course = await Course.findOne({
|
const course = await Course.findOne({
|
||||||
where: { course_id: courseId },
|
where: { course_id: courseId },
|
||||||
attributes: ['title'],
|
attributes: ['title', 'uuid'],
|
||||||
});
|
});
|
||||||
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
|
const notify = NOTIFICATION_REGISTRY.assessment_updated.build({
|
||||||
assessmentTitle: assessment.title,
|
assessmentTitle: assessment.title,
|
||||||
courseTitle: course?.title ?? null,
|
courseTitle: course?.title ?? null,
|
||||||
|
courseUuid: course?.uuid ?? null,
|
||||||
});
|
});
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await UserNotification.bulkCreate(
|
await UserNotification.bulkCreate(
|
||||||
@@ -1433,7 +1566,7 @@ exports.getCoursesFlat = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const data = await Course.findAll({
|
const data = await Course.findAll({
|
||||||
where: notDeleted,
|
where: notDeleted,
|
||||||
attributes: ["uuid", "title"],
|
attributes: ["uuid", "title", "subscription", "duration_seconds"],
|
||||||
order: [["title", "ASC"]],
|
order: [["title", "ASC"]],
|
||||||
});
|
});
|
||||||
return R.success(res, "Courses retrieved.", data);
|
return R.success(res, "Courses retrieved.", data);
|
||||||
@@ -1448,11 +1581,32 @@ exports.getCoursesBySubscription = async (req, res) => {
|
|||||||
const { slug } = req.query;
|
const { slug } = req.query;
|
||||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||||
|
|
||||||
const data = await Course.findAll({
|
const rows = await Course.findAll({
|
||||||
where: { ...notDeleted, subscription: slug },
|
where: { ...notDeleted, subscription: slug },
|
||||||
attributes: ['course_id', 'title', 'description', 'subscription'],
|
attributes: ['course_id', 'title', 'description', 'subscription'],
|
||||||
|
include: [{
|
||||||
|
model: mdl_PlanCourses,
|
||||||
|
as: 'planCourse',
|
||||||
|
required: false,
|
||||||
|
attributes: ['plan_id'],
|
||||||
|
include: [{
|
||||||
|
model: mdl_TierPlans,
|
||||||
|
as: 'plan',
|
||||||
|
attributes: ['plan_id', 'label'],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
order: [['title', 'ASC']],
|
order: [['title', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flatten so the frontend can just check `assigned_plan` — a course belongs
|
||||||
|
// to at most one plan (UNIQUE constraint on plan_courses.course_id).
|
||||||
|
const data = rows.map((c) => {
|
||||||
|
const plain = c.toJSON();
|
||||||
|
const assigned_plan = plain.planCourse?.plan ?? null;
|
||||||
|
delete plain.planCourse;
|
||||||
|
return { ...plain, assigned_plan };
|
||||||
|
});
|
||||||
|
|
||||||
return R.success(res, 'Courses retrieved.', data);
|
return R.success(res, 'Courses retrieved.', data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[COURSE][BY SUBSCRIPTION]', err);
|
console.error('[COURSE][BY SUBSCRIPTION]', err);
|
||||||
@@ -1464,11 +1618,12 @@ exports.getUnitsFlat = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const rows = await Unit.findAll({
|
const rows = await Unit.findAll({
|
||||||
where: notDeleted,
|
where: notDeleted,
|
||||||
attributes: ["uuid", "title", "order_index"],
|
attributes: ["uuid", "title", "order_index", "duration_seconds"],
|
||||||
include: [{
|
include: [{
|
||||||
model: Course,
|
model: Course,
|
||||||
as: "course",
|
as: "course",
|
||||||
attributes: ["title"],
|
attributes: ["title", "subscription"],
|
||||||
|
paranoid: false,
|
||||||
}],
|
}],
|
||||||
order: [
|
order: [
|
||||||
[{ model: Course, as: "course" }, "title", "ASC"],
|
[{ model: Course, as: "course" }, "title", "ASC"],
|
||||||
@@ -1479,7 +1634,9 @@ exports.getUnitsFlat = async (req, res) => {
|
|||||||
uuid: u.uuid,
|
uuid: u.uuid,
|
||||||
title: u.title,
|
title: u.title,
|
||||||
order_index: u.order_index ?? 0,
|
order_index: u.order_index ?? 0,
|
||||||
|
duration_seconds: u.duration_seconds ?? 0,
|
||||||
course_title: u.course?.title ?? "",
|
course_title: u.course?.title ?? "",
|
||||||
|
subscription: u.course?.subscription ?? "free",
|
||||||
}));
|
}));
|
||||||
return R.success(res, "Units retrieved.", data);
|
return R.success(res, "Units retrieved.", data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1492,15 +1649,17 @@ exports.getLessonsFlat = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const rows = await Lesson.findAll({
|
const rows = await Lesson.findAll({
|
||||||
where: notDeleted,
|
where: notDeleted,
|
||||||
attributes: ["uuid", "title", "order_index"],
|
attributes: ["uuid", "title", "order_index", "duration_seconds"],
|
||||||
include: [{
|
include: [{
|
||||||
model: Unit,
|
model: Unit,
|
||||||
as: "unit",
|
as: "unit",
|
||||||
attributes: ["title", "order_index"],
|
attributes: ["title", "order_index"],
|
||||||
|
paranoid: false,
|
||||||
include: [{
|
include: [{
|
||||||
model: Course,
|
model: Course,
|
||||||
as: "course",
|
as: "course",
|
||||||
attributes: ["title"],
|
attributes: ["title", "subscription"],
|
||||||
|
paranoid: false,
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
order: [
|
order: [
|
||||||
@@ -1513,9 +1672,11 @@ exports.getLessonsFlat = async (req, res) => {
|
|||||||
uuid: l.uuid,
|
uuid: l.uuid,
|
||||||
title: l.title,
|
title: l.title,
|
||||||
order_index: l.order_index ?? 0,
|
order_index: l.order_index ?? 0,
|
||||||
|
duration_seconds: l.duration_seconds ?? 0,
|
||||||
unit_title: l.unit?.title ?? "",
|
unit_title: l.unit?.title ?? "",
|
||||||
unit_order: l.unit?.order_index ?? 0,
|
unit_order: l.unit?.order_index ?? 0,
|
||||||
course_title: l.unit?.course?.title ?? "",
|
course_title: l.unit?.course?.title ?? "",
|
||||||
|
subscription: l.unit?.course?.subscription ?? "free",
|
||||||
}));
|
}));
|
||||||
return R.success(res, "Lessons retrieved.", data);
|
return R.success(res, "Lessons retrieved.", data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1801,8 +1962,8 @@ exports.syncCourseAchievements = async (req, res) => {
|
|||||||
const { courseId } = req.params;
|
const { courseId } = req.params;
|
||||||
const { achievement_keys = [] } = req.body;
|
const { achievement_keys = [] } = req.body;
|
||||||
|
|
||||||
if (achievement_keys.length > 3)
|
if (achievement_keys.length > 1)
|
||||||
return R.error(res, "Maximum 3 achievements allowed per course.", 400);
|
return R.error(res, "Maximum 1 achievement allowed per course.", 400);
|
||||||
|
|
||||||
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });
|
await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t });
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,27 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Placement Registry
|
||||||
|
|
||||||
|
Every advertisement belongs to a `placement` — a page + position slug drawn from a fixed
|
||||||
|
registry (`models/advertisements/advertisements.placements.js`). The placement determines
|
||||||
|
the advertisement's `type` (visual format) automatically; `type` is **never** accepted from
|
||||||
|
the client and is denormalized from the placement on every write.
|
||||||
|
|
||||||
|
| Placement key | Page | Position | Format |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `dashboard.hero` | Dashboard | Hero (top of page) | `hero` |
|
||||||
|
| `dashboard.popup` | Dashboard | Popup (on load) | `popup` |
|
||||||
|
| `course_list.banner` | Courses | Banner (above course grid) | `banner` |
|
||||||
|
| `course_details.banner` | Course Details | Banner (below hero) | `banner` |
|
||||||
|
| `course_details.sidebar` | Course Details | Sidebar (beside course content) | `sidebar` |
|
||||||
|
| `plans.banner` | Plans | Banner (above plan cards) | `banner` |
|
||||||
|
|
||||||
|
Adding a new placement is a one-line addition to that registry file plus wiring the
|
||||||
|
corresponding client page to fetch/render it — nothing else needs to change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Status Derivation
|
## Status Derivation
|
||||||
|
|
||||||
Status is **never** trusted as stored — it is recomputed on every read and write:
|
Status is **never** trusted as stored — it is recomputed on every read and write:
|
||||||
@@ -93,6 +114,7 @@ Returns one advertisement with its `image` asset and audit user info.
|
|||||||
"data": {
|
"data": {
|
||||||
"advertisement_id": 1,
|
"advertisement_id": 1,
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
|
"placement": "dashboard.hero",
|
||||||
"type": "hero",
|
"type": "hero",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"badge_label": "New",
|
"badge_label": "New",
|
||||||
@@ -131,7 +153,7 @@ Returns one advertisement with its `image` asset and audit user info.
|
|||||||
### Request Body
|
### Request Body
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `type` | string | **Yes** | `hero`, `banner`, `popup`, `sidebar` |
|
| `placement` | string | **Yes** | A placement registry key, e.g. `dashboard.hero` — see [Placement Registry](#placement-registry). Determines `type` automatically. |
|
||||||
| `createdBy` | number | **Yes** | User ID of creator |
|
| `createdBy` | number | **Yes** | User ID of creator |
|
||||||
| `badge_label` | string | No | Small label shown on the ad |
|
| `badge_label` | string | No | Small label shown on the ad |
|
||||||
| `headline` | string | No | Main heading |
|
| `headline` | string | No | Main heading |
|
||||||
@@ -157,8 +179,8 @@ Returns one advertisement with its `image` asset and audit user info.
|
|||||||
### Error Responses
|
### Error Responses
|
||||||
| Status | Message |
|
| Status | Message |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `400` | `type is required.` |
|
| `400` | `placement is required.` |
|
||||||
| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` |
|
| `400` | `Invalid placement. Must be one of: dashboard.hero, dashboard.popup, ...` |
|
||||||
| `400` | `createdBy is required.` |
|
| `400` | `createdBy is required.` |
|
||||||
| `400` | `Invalid size. Must be one of: sm, md, lg` |
|
| `400` | `Invalid size. Must be one of: sm, md, lg` |
|
||||||
|
|
||||||
@@ -171,7 +193,7 @@ Returns one advertisement with its `image` asset and audit user info.
|
|||||||
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
|
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
|
||||||
|
|
||||||
### Request Body
|
### Request Body
|
||||||
Same optional fields as Create. Does not accept `type` once set. Accepts `updatedBy`.
|
Same optional fields as Create. `type` is never accepted — it's always derived from `placement`. Accepts `updatedBy`.
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
```json
|
```json
|
||||||
@@ -279,6 +301,10 @@ Returns distinct values for filterable advertisement fields. Used by DataTable f
|
|||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Field values retrieved.",
|
"message": "Field values retrieved.",
|
||||||
"data": { "type": ["hero", "banner"], "status": ["active", "draft"] }
|
"data": {
|
||||||
|
"type": ["hero", "banner", "popup", "sidebar"],
|
||||||
|
"placement": ["dashboard.hero", "dashboard.popup", "course_list.banner"],
|
||||||
|
"status": ["active", "draft"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
|
||||||
|
const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl');
|
||||||
|
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
|
||||||
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const R = require('../../utils/response.util');
|
||||||
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
|
const {
|
||||||
|
ALLOWED_TARGET_TYPES,
|
||||||
|
SCOPED_TARGET_TYPES,
|
||||||
|
validateTargetId,
|
||||||
|
resolveTargetUserIds,
|
||||||
|
resolveAllUserIds,
|
||||||
|
} = require('../../utils/audienceResolver.util');
|
||||||
|
|
||||||
|
// Only these categories are ever appropriate to blast to real recipients —
|
||||||
|
// system/transactional templates (OTP, welcome, banned, etc.) are triggered
|
||||||
|
// per-user by app events and are deliberately NOT broadcastable here.
|
||||||
|
const BROADCASTABLE_CATEGORIES = ['announcement', 'advertisement'];
|
||||||
|
|
||||||
|
// 'admin' means "email every admin/staff user" for this feature — distinct
|
||||||
|
// from notification broadcasts' 'admin' (which posts to the shared bell feed
|
||||||
|
// instead of emailing anyone). Kept local to this controller for that reason.
|
||||||
|
async function resolveAdminStaffUserIds() {
|
||||||
|
const users = await mdl_Users.findAll({
|
||||||
|
attributes: ['user_id'],
|
||||||
|
where: { acc_type: { [Op.in]: ['admin', 'staff'] }, deletedAt: null },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
return users.map((u) => String(u.user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAudienceUserIds(target_type, target_id) {
|
||||||
|
if (target_type === 'admin') return resolveAdminStaffUserIds();
|
||||||
|
if (target_type === 'user') return resolveAllUserIds();
|
||||||
|
if (target_type === 'both') {
|
||||||
|
const [admins, users] = await Promise.all([resolveAdminStaffUserIds(), resolveAllUserIds()]);
|
||||||
|
return [...new Set([...admins, ...users])];
|
||||||
|
}
|
||||||
|
return resolveTargetUserIds(target_type, target_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /admin/email-broadcasts ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getEmailBroadcasts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const broadcasts = await mdl_EmailBroadcast.findAll({
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
|
||||||
|
});
|
||||||
|
return R.success(res, 'Email broadcasts retrieved.', broadcasts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET EMAIL BROADCASTS]', err);
|
||||||
|
return R.error(res, 'Could not retrieve email broadcasts.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GET /admin/email-broadcasts/:id ──────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getEmailBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id, {
|
||||||
|
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
|
||||||
|
});
|
||||||
|
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
|
||||||
|
|
||||||
|
const failedRecipients = await mdl_EmailBroadcastRecipient.findAll({
|
||||||
|
where: { email_broadcast_id: broadcast.email_broadcast_id, status: 'failed' },
|
||||||
|
attributes: ['email', 'error'],
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
return R.success(res, 'Email broadcast retrieved.', { ...broadcast.toJSON(), failed_recipients: failedRecipients });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET EMAIL BROADCAST]', err);
|
||||||
|
return R.error(res, 'Could not retrieve email broadcast.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── POST /admin/email-broadcasts ─────────────────────────────────────────────
|
||||||
|
// Enqueues recipients and returns immediately — actual sending happens later,
|
||||||
|
// paced, in cron/jobs/dispatch_email_broadcasts.cron.js. Never loops over
|
||||||
|
// recipients or calls sendEmail() here.
|
||||||
|
|
||||||
|
exports.createEmailBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email_template_id, target_type, target_id, createdBy } = req.body;
|
||||||
|
|
||||||
|
if (!email_template_id) return R.error(res, 'email_template_id is required.', 400);
|
||||||
|
if (!target_type) return R.error(res, 'target_type is required.', 400);
|
||||||
|
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(', ')}`, 400);
|
||||||
|
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, 'target_id is required for this target_type.', 400);
|
||||||
|
|
||||||
|
const template = await mdl_EmailTemplate.findByPk(email_template_id);
|
||||||
|
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||||
|
if (!BROADCASTABLE_CATEGORIES.includes(template.category)) {
|
||||||
|
return R.error(res, `Only ${BROADCASTABLE_CATEGORIES.join('/')} templates can be sent to recipients.`, 400);
|
||||||
|
}
|
||||||
|
if (template.status !== 'sent' || !template.subject || !template.html_body) {
|
||||||
|
return R.error(res, 'This template has no published (sent) version yet — publish it before sending to recipients.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||||
|
|
||||||
|
const userIds = await resolveAudienceUserIds(target_type, target_id);
|
||||||
|
if (!userIds.length) return R.error(res, 'No recipients match this target.', 400);
|
||||||
|
|
||||||
|
const users = await mdl_Users.findAll({
|
||||||
|
attributes: ['user_id', 'email', 'personal_info'],
|
||||||
|
where: { user_id: { [Op.in]: userIds }, email: { [Op.ne]: null }, deletedAt: null },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
if (!users.length) return R.error(res, 'None of the matched recipients have a usable email address.', 400);
|
||||||
|
|
||||||
|
const broadcast = await mdl_EmailBroadcast.create({
|
||||||
|
email_template_id,
|
||||||
|
target_type,
|
||||||
|
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
||||||
|
status: 'queued',
|
||||||
|
total_recipients: users.length,
|
||||||
|
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await mdl_EmailBroadcastRecipient.bulkCreate(
|
||||||
|
users.map((u) => ({
|
||||||
|
email_broadcast_id: broadcast.email_broadcast_id,
|
||||||
|
user_id: u.user_id,
|
||||||
|
email: u.email,
|
||||||
|
name: u.personal_info?.name?.full_name ?? null,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})),
|
||||||
|
{ validate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'create_email_broadcast', {
|
||||||
|
entityType: 'email_broadcast',
|
||||||
|
entityId: broadcast.email_broadcast_id,
|
||||||
|
details: { email_template_id, target_type, target_id, recipient_count: users.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
return R.success(res, `Broadcast queued for ${users.length} recipient(s).`, broadcast, 201);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][CREATE EMAIL BROADCAST]', err);
|
||||||
|
if (err.status) return R.error(res, err.message, err.status);
|
||||||
|
return R.error(res, 'Internal server error.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── PATCH /admin/email-broadcasts/:id/cancel ─────────────────────────────────
|
||||||
|
// Soft stop — the cron simply won't pick up any more pending recipients for a
|
||||||
|
// canceled broadcast. Whatever's already sent stays sent.
|
||||||
|
|
||||||
|
exports.cancelEmailBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id);
|
||||||
|
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
|
||||||
|
if (broadcast.status === 'completed' || broadcast.status === 'canceled') {
|
||||||
|
return R.error(res, `Broadcast is already ${broadcast.status}.`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await broadcast.update({ status: 'canceled' });
|
||||||
|
logActivity(req.user?.user_id, 'cancel_email_broadcast', { entityType: 'email_broadcast', entityId: broadcast.email_broadcast_id });
|
||||||
|
return R.success(res, 'Email broadcast canceled.', broadcast);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][CANCEL EMAIL BROADCAST]', err);
|
||||||
|
return R.error(res, 'Internal server error.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
|
||||||
|
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
|
||||||
|
const R = require('../../utils/response.util');
|
||||||
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
|
|
||||||
|
const TYPE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
||||||
|
const VALID_CATEGORIES = ['announcement', 'advertisement', 'system', 'other'];
|
||||||
|
|
||||||
|
// ─── GET /admin/email-templates ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getEmailTemplates = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const templates = await mdl_EmailTemplate.findAll({
|
||||||
|
order: [['category', 'ASC'], ['is_system', 'DESC'], ['type', 'ASC']],
|
||||||
|
});
|
||||||
|
return R.success(res, 'Email templates retrieved.', templates);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET EMAIL TEMPLATES]', err);
|
||||||
|
return R.error(res, 'Could not retrieve email templates.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GET /admin/email-templates/:id ───────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getEmailTemplate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||||
|
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||||
|
return R.success(res, 'Email template retrieved.', template);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][GET EMAIL TEMPLATE]', err);
|
||||||
|
return R.error(res, 'Could not retrieve email template.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── POST /admin/email-templates ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.createEmailTemplate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
|
||||||
|
if (!type || !label || !subject || !html_body) {
|
||||||
|
return R.error(res, 'type, label, subject and html_body are required.', 400);
|
||||||
|
}
|
||||||
|
if (!TYPE_PATTERN.test(type)) {
|
||||||
|
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
|
||||||
|
}
|
||||||
|
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
|
||||||
|
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||||
|
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
|
||||||
|
|
||||||
|
// "Send Now" writes straight to the live columns sendEmail() reads.
|
||||||
|
// "Save as Draft" keeps the content out of the live columns entirely, so
|
||||||
|
// there's nothing for sendEmail() to pick up until it's published.
|
||||||
|
const isPublishing = publish === true || publish === 'true';
|
||||||
|
|
||||||
|
const template = await mdl_EmailTemplate.create({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
category: category || 'other',
|
||||||
|
status: isPublishing ? 'sent' : 'draft',
|
||||||
|
subject: isPublishing ? subject : null,
|
||||||
|
html_body: isPublishing ? html_body : null,
|
||||||
|
body_markdown: isPublishing ? (body_markdown ?? null) : null,
|
||||||
|
draft_subject: isPublishing ? null : subject,
|
||||||
|
draft_html_body: isPublishing ? null : html_body,
|
||||||
|
draft_body_markdown: isPublishing ? null : (body_markdown ?? null),
|
||||||
|
last_sent_at: isPublishing ? new Date() : null,
|
||||||
|
is_system: false, // only seed data may be system-protected
|
||||||
|
});
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'create_email_template', { entityType: 'email_template', details: { type, label, category: template.category, status: template.status } });
|
||||||
|
|
||||||
|
return R.success(res, 'Email template created.', template, 201);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][CREATE EMAIL TEMPLATE]', err);
|
||||||
|
return R.error(res, 'Could not create email template.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── PUT /admin/email-templates/:id ───────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.updateEmailTemplate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||||
|
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||||
|
|
||||||
|
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
|
||||||
|
|
||||||
|
if (template.is_system && type !== undefined && type !== template.type) {
|
||||||
|
return R.error(res, 'The type of a system email template cannot be changed.', 400);
|
||||||
|
}
|
||||||
|
if (type !== undefined && !TYPE_PATTERN.test(type)) {
|
||||||
|
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
|
||||||
|
}
|
||||||
|
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
|
||||||
|
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template.is_system && type !== undefined && type !== template.type) {
|
||||||
|
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||||
|
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subject !== undefined && !subject.trim()) return R.error(res, 'subject cannot be empty.', 400);
|
||||||
|
if (html_body !== undefined && !html_body.trim()) return R.error(res, 'html_body cannot be empty.', 400);
|
||||||
|
|
||||||
|
// "Send" publishes subject/html_body straight to the live columns that
|
||||||
|
// sendEmail() reads and clears any pending draft. A plain save (no
|
||||||
|
// publish flag) writes into draft_subject/draft_html_body instead, so
|
||||||
|
// real outgoing mail keeps using the last-published content until an
|
||||||
|
// admin comes back and explicitly sends again.
|
||||||
|
const isPublishing = publish === true || publish === 'true';
|
||||||
|
const nextSubject = subject ?? template.draft_subject ?? template.subject;
|
||||||
|
const nextHtmlBody = html_body ?? template.draft_html_body ?? template.html_body;
|
||||||
|
const nextMarkdown = body_markdown ?? template.draft_body_markdown ?? template.body_markdown;
|
||||||
|
|
||||||
|
await template.update({
|
||||||
|
type: (!template.is_system && type !== undefined) ? type : template.type,
|
||||||
|
label: label ?? template.label,
|
||||||
|
category: category ?? template.category,
|
||||||
|
...(isPublishing
|
||||||
|
? {
|
||||||
|
status: 'sent',
|
||||||
|
subject: nextSubject,
|
||||||
|
html_body: nextHtmlBody,
|
||||||
|
body_markdown: nextMarkdown,
|
||||||
|
draft_subject: null,
|
||||||
|
draft_html_body: null,
|
||||||
|
draft_body_markdown: null,
|
||||||
|
last_sent_at: new Date(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
draft_subject: nextSubject,
|
||||||
|
draft_html_body: nextHtmlBody,
|
||||||
|
draft_body_markdown: nextMarkdown,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'update_email_template', { entityType: 'email_template', entityId: template.email_template_id, details: { type: template.type, published: isPublishing } });
|
||||||
|
|
||||||
|
return R.success(res, 'Email template updated.', template);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][UPDATE EMAIL TEMPLATE]', err);
|
||||||
|
return R.error(res, 'Could not update email template.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── DELETE /admin/email-templates/:id ────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.deleteEmailTemplate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||||
|
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||||
|
if (template.is_system) return R.error(res, 'Built-in system email templates cannot be deleted.', 400);
|
||||||
|
|
||||||
|
const broadcastCount = await mdl_EmailBroadcast.count({ where: { email_template_id: template.email_template_id } });
|
||||||
|
if (broadcastCount > 0) {
|
||||||
|
return R.error(res, `Cannot delete — ${broadcastCount} broadcast(s) reference this template. Its send history would be lost.`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await template.destroy();
|
||||||
|
logActivity(req.user?.user_id, 'delete_email_template', { entityType: 'email_template', details: { type: template.type } });
|
||||||
|
return R.success(res, 'Email template deleted.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN][DELETE EMAIL TEMPLATE]', err);
|
||||||
|
return R.error(res, 'Could not delete email template.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,37 +13,10 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const { Op } = require("sequelize");
|
const { Op } = require("sequelize");
|
||||||
const jwt = require("jsonwebtoken");
|
|
||||||
|
|
||||||
const R = require("../../utils/response.util");
|
const R = require("../../utils/response.util");
|
||||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||||
const s3 = require("../../services/s3.service");
|
const mediaToken = require("../../services/mediaToken.service");
|
||||||
|
|
||||||
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
|
|
||||||
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
|
|
||||||
|
|
||||||
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
|
|
||||||
|
|
||||||
function resolveIp(req) {
|
|
||||||
const forwarded = req.headers["x-forwarded-for"];
|
|
||||||
if (forwarded) return forwarded.split(",")[0].trim();
|
|
||||||
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function signToken(asset, userId, ip) {
|
|
||||||
return jwt.sign(
|
|
||||||
{
|
|
||||||
asset_id: asset.asset_id,
|
|
||||||
user_id: userId,
|
|
||||||
storage_key: asset.storage_key,
|
|
||||||
file_type: asset.file_type,
|
|
||||||
mime_type: asset.mime_type,
|
|
||||||
ip,
|
|
||||||
},
|
|
||||||
MEDIA_SECRET,
|
|
||||||
{ expiresIn: TOKEN_TTL_SEC }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /admin/media/token ──────────────────────────────────────────────────
|
// ─── POST /admin/media/token ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -59,7 +32,7 @@ exports.issueToken = async (req, res) => {
|
|||||||
|
|
||||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||||
|
|
||||||
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
if (!mediaToken.SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||||
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
|
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,18 +40,8 @@ exports.issueToken = async (req, res) => {
|
|||||||
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = resolveIp(req);
|
const ip = mediaToken.resolveIp(req);
|
||||||
const token = signToken(asset, req.user.user_id, ip);
|
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||||
|
|
||||||
// ── Presign thumbnail URL so the browser can load it directly ─────────────
|
|
||||||
let thumbnail_url = null;
|
|
||||||
if (asset.thumbnail_storage_key) {
|
|
||||||
try {
|
|
||||||
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
|
|
||||||
} catch {
|
|
||||||
// Non-fatal — thumbnail is cosmetic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return R.success(res, "Token issued.", {
|
return R.success(res, "Token issued.", {
|
||||||
token,
|
token,
|
||||||
@@ -116,26 +79,15 @@ exports.issueTokensBatch = async (req, res) => {
|
|||||||
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
|
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = resolveIp(req);
|
const ip = mediaToken.resolveIp(req);
|
||||||
const tokens = {};
|
const tokens = {};
|
||||||
const thumbnails = {};
|
const thumbnails = {};
|
||||||
|
|
||||||
for (const asset of assets) {
|
await Promise.all(assets.map(async (asset) => {
|
||||||
tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip);
|
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||||
|
tokens[String(asset.asset_id)] = token;
|
||||||
// For image/video assets with a thumbnail — presign it so the browser can
|
if (thumbnail_url) thumbnails[String(asset.asset_id)] = thumbnail_url;
|
||||||
// load it directly from Garage without going through the stream proxy.
|
}));
|
||||||
if (asset.thumbnail_storage_key) {
|
|
||||||
try {
|
|
||||||
thumbnails[String(asset.asset_id)] = await s3.getSignedDownloadUrl(
|
|
||||||
asset.thumbnail_storage_key,
|
|
||||||
TOKEN_TTL_SEC,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Non-fatal — stream token is the fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return R.success(res, "Tokens issued.", { tokens, thumbnails });
|
return R.success(res, "Tokens issued.", { tokens, thumbnails });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
// controllers/admin/notificationBroadcasts.controller.js
|
||||||
|
|
||||||
|
const sequelize = require("../../config/db.config");
|
||||||
|
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
|
||||||
|
const AdminNotification = require("../../models/notifications/admin_notification.mdl");
|
||||||
|
const UserNotification = require("../../models/notifications/user_notification.mdl");
|
||||||
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const { TaskList } = require('../../models/task/task.mdl');
|
||||||
|
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
|
const { Course } = require('../../models/courses/courses.mdl');
|
||||||
|
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||||
|
const R = require('../../utils/response.util');
|
||||||
|
const { paginate } = require("../../utils/paginate.util");
|
||||||
|
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
|
||||||
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||||
|
const {
|
||||||
|
ALLOWED_TARGET_TYPES,
|
||||||
|
SCOPED_TARGET_TYPES,
|
||||||
|
validateTargetId,
|
||||||
|
resolveTaskListUserGroups,
|
||||||
|
resolveTargetUserIds,
|
||||||
|
} = require('../../utils/audienceResolver.util');
|
||||||
|
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const notDeleted = { deletedAt: null };
|
||||||
|
|
||||||
|
async function applyBroadcastFields(broadcast, body) {
|
||||||
|
if (body.title !== undefined) broadcast.title = body.title;
|
||||||
|
if (body.message !== undefined) broadcast.message = body.message;
|
||||||
|
|
||||||
|
if (body.target_type !== undefined) {
|
||||||
|
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
|
||||||
|
const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SCOPED_TARGET_TYPES.includes(body.target_type)) {
|
||||||
|
if (!body.target_id) {
|
||||||
|
const err = new Error("target_id is required for this target_type.");
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await validateTargetId(body.target_type, body.target_id);
|
||||||
|
broadcast.target_id = String(body.target_id);
|
||||||
|
} else {
|
||||||
|
broadcast.target_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast.target_type = body.target_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Target resolution ────────────────────────────────────────────────────────
|
||||||
|
// task_list/course/tier_plan resolution now lives in utils/audienceResolver.util.js
|
||||||
|
// (resolveTargetUserIds, resolveTaskListUserGroups — imported above) so email
|
||||||
|
// broadcasts resolve the same targets identically.
|
||||||
|
|
||||||
|
// Enrich one or many broadcast rows with a human-readable target_label.
|
||||||
|
async function attachTargetLabels(rows) {
|
||||||
|
const list = Array.isArray(rows) ? rows : [rows];
|
||||||
|
const idsByType = { task_list: [], course: [], tier_plan: [] };
|
||||||
|
list.forEach((r) => { if (SCOPED_TARGET_TYPES.includes(r.target_type) && r.target_id) idsByType[r.target_type].push(r.target_id); });
|
||||||
|
|
||||||
|
const [taskLists, courses, plans] = await Promise.all([
|
||||||
|
idsByType.task_list.length ? TaskList.findAll({ where: { task_list_id: { [Op.in]: idsByType.task_list } }, attributes: ['task_list_id', 'name'], paranoid: false }) : [],
|
||||||
|
idsByType.course.length ? Course.findAll({ where: { uuid: { [Op.in]: idsByType.course } }, attributes: ['uuid', 'title'], paranoid: false }) : [],
|
||||||
|
idsByType.tier_plan.length ? mdl_TierPlans.findAll({ where: { plan_id: { [Op.in]: idsByType.tier_plan } }, attributes: ['plan_id', 'label'], paranoid: false }) : [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const taskListMap = Object.fromEntries(taskLists.map((t) => [t.task_list_id, t.name]));
|
||||||
|
const courseMap = Object.fromEntries(courses.map((c) => [c.uuid, c.title]));
|
||||||
|
const planMap = Object.fromEntries(plans.map((p) => [String(p.plan_id), p.label]));
|
||||||
|
|
||||||
|
list.forEach((r) => {
|
||||||
|
if (r.target_type === 'task_list') r.target_label = taskListMap[r.target_id] ?? null;
|
||||||
|
else if (r.target_type === 'course') r.target_label = courseMap[r.target_id] ?? null;
|
||||||
|
else if (r.target_type === 'tier_plan') r.target_label = planMap[r.target_id] ?? null;
|
||||||
|
else r.target_label = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getBroadcasts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await paginate(NotificationBroadcast, req, {
|
||||||
|
excludeAttributes: adminExclude,
|
||||||
|
jsonbSchemas,
|
||||||
|
computedAttributes,
|
||||||
|
context: "list",
|
||||||
|
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||||
|
findOptions: { where: { ...notDeleted } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
|
||||||
|
|
||||||
|
return R.success(res, "Notification broadcasts retrieved.", result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
|
||||||
|
return R.error(res, "Could not retrieve notification broadcasts.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { broadcastId } = req.params;
|
||||||
|
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||||
|
|
||||||
|
const broadcast = await NotificationBroadcast.findOne({
|
||||||
|
where: { broadcast_id: broadcastId, ...notDeleted },
|
||||||
|
include: [
|
||||||
|
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
|
||||||
|
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||||
|
|
||||||
|
const json = broadcast.toJSON();
|
||||||
|
|
||||||
|
if (json.creator) {
|
||||||
|
json.creator = {
|
||||||
|
user_id: json.creator.user_id,
|
||||||
|
full_name: json.creator.personal_info?.name?.full_name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (json.updater) {
|
||||||
|
json.updater = {
|
||||||
|
user_id: json.updater.user_id,
|
||||||
|
full_name: json.updater.personal_info?.name?.full_name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await attachTargetLabels(json);
|
||||||
|
|
||||||
|
return R.success(res, "Notification broadcast retrieved.", { data: json });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.createBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, message, target_type, target_id, createdBy } = req.body;
|
||||||
|
|
||||||
|
if (!title) return R.error(res, "title is required.", 400);
|
||||||
|
if (!message) return R.error(res, "message is required.", 400);
|
||||||
|
if (!target_type) return R.error(res, "target_type is required.", 400);
|
||||||
|
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400);
|
||||||
|
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400);
|
||||||
|
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||||
|
|
||||||
|
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||||
|
|
||||||
|
const t = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const broadcast = await NotificationBroadcast.build({
|
||||||
|
title, message, createdBy, status: 'draft',
|
||||||
|
target_type,
|
||||||
|
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
||||||
|
});
|
||||||
|
await broadcast.save({ transaction: t });
|
||||||
|
await t.commit();
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } });
|
||||||
|
return R.success(res, "Notification broadcast created.", { data: broadcast }, 201);
|
||||||
|
} catch (dbErr) {
|
||||||
|
try { await t.rollback(); } catch { /* connection gone */ }
|
||||||
|
throw dbErr;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][CREATE]", err);
|
||||||
|
if (err.status) return R.error(res, err.message, err.status);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.updateBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { broadcastId } = req.params;
|
||||||
|
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||||
|
|
||||||
|
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||||
|
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||||
|
|
||||||
|
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be edited.", 400);
|
||||||
|
|
||||||
|
const t = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
await applyBroadcastFields(broadcast, req.body);
|
||||||
|
broadcast.updatedBy = req.body.updatedBy ?? null;
|
||||||
|
await broadcast.save({ transaction: t });
|
||||||
|
await t.commit();
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||||
|
return R.success(res, "Notification broadcast updated.", { data: broadcast });
|
||||||
|
} catch (dbErr) {
|
||||||
|
try { await t.rollback(); } catch { /* gone */ }
|
||||||
|
throw dbErr;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][UPDATE]", err);
|
||||||
|
if (err.status) return R.error(res, err.message, err.status);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SEND ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.sendBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { broadcastId } = req.params;
|
||||||
|
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||||
|
|
||||||
|
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||||
|
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||||
|
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
|
||||||
|
|
||||||
|
const t = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
let recipientCount = 0;
|
||||||
|
|
||||||
|
const targetType = broadcast.target_type;
|
||||||
|
const targetId = broadcast.target_id;
|
||||||
|
|
||||||
|
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
||||||
|
title: broadcast.title,
|
||||||
|
message: broadcast.message,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetType === 'admin' || targetType === 'both') {
|
||||||
|
await AdminNotification.create({ ...baseNotify, seen: false }, { transaction: t });
|
||||||
|
recipientCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userIds = [];
|
||||||
|
let groupByUser = {}; // only populated for task_list — one group_id per user, for deep-linking
|
||||||
|
|
||||||
|
if (targetType === 'user' || targetType === 'both') {
|
||||||
|
const users = await mdl_Users.findAll({
|
||||||
|
attributes: ['user_id'],
|
||||||
|
where: { acc_type: 'user', deletedAt: null },
|
||||||
|
raw: true,
|
||||||
|
transaction: t,
|
||||||
|
});
|
||||||
|
userIds = users.map((u) => String(u.user_id));
|
||||||
|
} else if (targetType === 'task_list') {
|
||||||
|
groupByUser = await resolveTaskListUserGroups(targetId);
|
||||||
|
userIds = Object.keys(groupByUser);
|
||||||
|
} else if (SCOPED_TARGET_TYPES.includes(targetType)) {
|
||||||
|
userIds = await resolveTargetUserIds(targetType, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIds.length) {
|
||||||
|
await UserNotification.bulkCreate(
|
||||||
|
userIds.map((user_id) => ({
|
||||||
|
user_id,
|
||||||
|
...(targetType === 'task_list'
|
||||||
|
? NOTIFICATION_REGISTRY.broadcast.build({
|
||||||
|
title: broadcast.title, message: broadcast.message, targetType, targetId,
|
||||||
|
groupId: groupByUser[user_id] ?? null,
|
||||||
|
})
|
||||||
|
: baseNotify),
|
||||||
|
seen: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})),
|
||||||
|
{ validate: false, transaction: t }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
recipientCount += userIds.length;
|
||||||
|
|
||||||
|
broadcast.status = 'sent';
|
||||||
|
broadcast.sent_at = now;
|
||||||
|
broadcast.recipient_count = recipientCount;
|
||||||
|
await broadcast.save({ transaction: t });
|
||||||
|
await t.commit();
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } });
|
||||||
|
return R.success(res, "Notification broadcast sent.", { data: broadcast });
|
||||||
|
} catch (dbErr) {
|
||||||
|
try { await t.rollback(); } catch { /* gone */ }
|
||||||
|
throw dbErr;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][SEND]", err);
|
||||||
|
if (err.status) return R.error(res, err.message, err.status);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.archiveBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { broadcastId } = req.params;
|
||||||
|
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||||
|
|
||||||
|
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||||
|
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||||
|
|
||||||
|
await broadcast.update({ deletedBy: req.body.deletedBy ?? null });
|
||||||
|
await broadcast.destroy();
|
||||||
|
logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||||
|
return R.success(res, "Notification broadcast archived.");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.archiveBroadcasts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ids, deletedBy } = req.body;
|
||||||
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||||
|
|
||||||
|
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids }, ...notDeleted } });
|
||||||
|
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||||
|
|
||||||
|
const activeIds = broadcasts.map((b) => b.broadcast_id);
|
||||||
|
|
||||||
|
await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } } });
|
||||||
|
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } } });
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'bulk_archive_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: activeIds, count: activeIds.length } });
|
||||||
|
return R.success(res, `${activeIds.length} notification broadcast(s) archived.`, {
|
||||||
|
archived_ids: activeIds,
|
||||||
|
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][BULK ARCHIVE]", err);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.restoreBroadcast = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { broadcastId } = req.params;
|
||||||
|
|
||||||
|
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||||
|
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||||
|
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast is not archived.", 400);
|
||||||
|
|
||||||
|
await broadcast.restore();
|
||||||
|
await broadcast.update({ deletedBy: null });
|
||||||
|
logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||||
|
return R.success(res, "Notification broadcast restored.", { data: broadcast });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.restoreBroadcasts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||||
|
|
||||||
|
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
|
||||||
|
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||||
|
|
||||||
|
const archived = broadcasts.filter((b) => b.deletedAt);
|
||||||
|
if (!archived.length) return R.error(res, "All selected notification broadcasts are already active.", 400);
|
||||||
|
|
||||||
|
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||||
|
|
||||||
|
await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } } });
|
||||||
|
await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false });
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'bulk_restore_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
|
||||||
|
return R.success(res, `${archivedIds.length} notification broadcast(s) restored.`, {
|
||||||
|
restored_ids: archivedIds,
|
||||||
|
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][BULK RESTORE]", err);
|
||||||
|
return R.error(res, "Internal server error.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getArchivedBroadcasts = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await paginate(NotificationBroadcast, req, {
|
||||||
|
excludeAttributes: adminExclude,
|
||||||
|
jsonbSchemas,
|
||||||
|
computedAttributes,
|
||||||
|
context: "list",
|
||||||
|
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||||
|
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||||
|
});
|
||||||
|
return R.success(res, "Archived notification broadcasts retrieved.", result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err);
|
||||||
|
return R.error(res, "Could not retrieve archived notification broadcasts.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// controllers/admin/notificationSettings.controller.js
|
||||||
|
|
||||||
|
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||||
|
const R = require('../../utils/response.util');
|
||||||
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
|
const { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION } = require('../../data/cronPresets.data');
|
||||||
|
const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.util');
|
||||||
|
|
||||||
|
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
|
||||||
|
const JOBS = {
|
||||||
|
taskOverdue: { schedule: '0 * * * *', label: 'Task Overdue Alerts (Admin)', description: 'Notifies admins when tasks flip to overdue.' },
|
||||||
|
userNotifications: { schedule: '5 * * * *', label: 'Task Overdue Alerts (Users)', description: 'Notifies affected users when their tasks are marked overdue.' },
|
||||||
|
issueCertificates: { schedule: '0 * * * *', label: 'Certificate Issued', description: 'Notifies users when a course certificate is ready.' },
|
||||||
|
expireUserTiers: { schedule: '* * * * *', label: 'Tier Expired', description: 'Notifies users when their subscription tier expires.' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getSettings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = [];
|
||||||
|
for (const [job_name, meta] of Object.entries(JOBS)) {
|
||||||
|
const row = await getOrCreateSetting(job_name, meta.schedule);
|
||||||
|
rows.push({
|
||||||
|
job_name,
|
||||||
|
enabled: row.enabled,
|
||||||
|
schedule: row.schedule,
|
||||||
|
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
|
||||||
|
label: meta.label,
|
||||||
|
description: meta.description,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return R.success(res, 'Notification settings retrieved.', rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NOTIFICATION SETTINGS][GET]', err);
|
||||||
|
return R.error(res, 'Could not retrieve notification settings.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.updateSetting = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { jobName } = req.params;
|
||||||
|
const { enabled, preset, updatedBy } = req.body;
|
||||||
|
|
||||||
|
if (!JOBS[jobName]) return R.error(res, `Unknown job "${jobName}".`, 404);
|
||||||
|
|
||||||
|
const row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||||
|
if (!row) return R.error(res, 'Setting not found.', 404);
|
||||||
|
|
||||||
|
if (enabled !== undefined) row.enabled = enabled === true || enabled === 'true';
|
||||||
|
|
||||||
|
if (preset !== undefined) {
|
||||||
|
const schedule = CRON_PRESETS[preset];
|
||||||
|
if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400);
|
||||||
|
row.schedule = schedule;
|
||||||
|
|
||||||
|
try {
|
||||||
|
rescheduleJob(jobName, schedule);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NOTIFICATION SETTINGS][RESCHEDULE]', err);
|
||||||
|
return R.error(res, `Saved, but failed to reschedule the live job: ${err.message}`, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row.updatedBy = updatedBy ?? null;
|
||||||
|
await row.save();
|
||||||
|
|
||||||
|
logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule } });
|
||||||
|
return R.success(res, 'Notification setting updated.', { data: row });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
|
||||||
|
return R.error(res, 'Internal server error.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
|
||||||
* File Name: plan_prices.controller.js (admin)
|
|
||||||
* Type of Program: Controller
|
|
||||||
* Description: Admin CRUD for localized price overrides per tier plan.
|
|
||||||
* Routes: GET/POST/PUT/DELETE /admin/tiers/:id/prices[/:currency]
|
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
|
||||||
* Date Created: Jun. 29, 2026
|
|
||||||
***********************************************************************************************************************************************************************/
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
|
||||||
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
|
|
||||||
const R = require('../../utils/response.util');
|
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
|
||||||
const { isSupported, SUPPORTED_CURRENCIES, validateLocalizedPrice } = require('../../utils/currency.util');
|
|
||||||
|
|
||||||
// ─── GET /admin/tiers/:id/prices ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
exports.getPrices = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
|
||||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
|
||||||
|
|
||||||
const prices = await mdl_PlanPrices.findAll({
|
|
||||||
where: { plan_id: plan.plan_id },
|
|
||||||
order: [['currency', 'ASC']],
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Localized prices retrieved.', prices);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][GET PLAN PRICES]', err);
|
|
||||||
return R.error(res, 'Could not retrieve localized prices.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── POST /admin/tiers/:id/prices ────────────────────────────────────────────
|
|
||||||
|
|
||||||
exports.addPrice = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
|
||||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
|
||||||
|
|
||||||
const { currency, price } = req.body;
|
|
||||||
if (!currency || price === undefined) return R.error(res, 'currency and price are required.', 400);
|
|
||||||
if (!isSupported(currency)) return R.error(res, `Unsupported currency: ${currency}.`, 400);
|
|
||||||
if (currency === plan.currency) return R.error(res, `${currency} is already the plan's base currency.`, 400);
|
|
||||||
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
|
|
||||||
|
|
||||||
const exists = await mdl_PlanPrices.findOne({ where: { plan_id: plan.plan_id, currency } });
|
|
||||||
if (exists) return R.error(res, `A localized price for ${currency} already exists. Use PUT to update it.`, 409);
|
|
||||||
|
|
||||||
// ── Rate validation ────────────────────────────────────────────────────────
|
|
||||||
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
|
|
||||||
if (validation.zone === 'block') return R.error(res, validation.message, 422);
|
|
||||||
|
|
||||||
const entry = await mdl_PlanPrices.create({
|
|
||||||
plan_id: plan.plan_id,
|
|
||||||
currency: currency.toUpperCase(),
|
|
||||||
price: Number(price),
|
|
||||||
});
|
|
||||||
|
|
||||||
logActivity(req.user?.user_id, 'add_plan_price', {
|
|
||||||
entityType: 'plan_price',
|
|
||||||
details: { plan_id: plan.plan_id, currency, price },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validation.zone === 'warn')
|
|
||||||
return res.status(201).json({ success: true, warning: true, message: validation.message, data: entry });
|
|
||||||
|
|
||||||
return R.success(res, 'Localized price added.', entry, 201);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][ADD PLAN PRICE]', err);
|
|
||||||
return R.error(res, 'Could not add localized price.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── PUT /admin/tiers/:id/prices/:currency ───────────────────────────────────
|
|
||||||
|
|
||||||
exports.updatePrice = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id, currency } = req.params;
|
|
||||||
const { price } = req.body;
|
|
||||||
|
|
||||||
if (price === undefined) return R.error(res, 'price is required.', 400);
|
|
||||||
if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400);
|
|
||||||
|
|
||||||
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
|
|
||||||
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
|
|
||||||
|
|
||||||
// ── Rate validation ────────────────────────────────────────────────────────
|
|
||||||
const plan = await mdl_TierPlans.findByPk(id);
|
|
||||||
const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency);
|
|
||||||
if (validation.zone === 'block') return R.error(res, validation.message, 422);
|
|
||||||
|
|
||||||
await entry.update({ price: Number(price) });
|
|
||||||
|
|
||||||
logActivity(req.user?.user_id, 'update_plan_price', {
|
|
||||||
entityType: 'plan_price',
|
|
||||||
details: { plan_id: id, currency, price },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validation.zone === 'warn')
|
|
||||||
return res.status(200).json({ success: true, warning: true, message: validation.message, data: entry });
|
|
||||||
|
|
||||||
return R.success(res, 'Localized price updated.', entry);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][UPDATE PLAN PRICE]', err);
|
|
||||||
return R.error(res, 'Could not update localized price.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── DELETE /admin/tiers/:id/prices/:currency ────────────────────────────────
|
|
||||||
|
|
||||||
exports.removePrice = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id, currency } = req.params;
|
|
||||||
|
|
||||||
const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } });
|
|
||||||
if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404);
|
|
||||||
|
|
||||||
await entry.destroy();
|
|
||||||
|
|
||||||
logActivity(req.user?.user_id, 'remove_plan_price', {
|
|
||||||
entityType: 'plan_price',
|
|
||||||
details: { plan_id: id, currency },
|
|
||||||
});
|
|
||||||
|
|
||||||
return R.success(res, 'Localized price removed.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[ADMIN][REMOVE PLAN PRICE]', err);
|
|
||||||
return R.error(res, 'Could not remove localized price.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── GET /admin/currencies ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
exports.getCurrencies = async (_req, res) => {
|
|
||||||
return R.success(res, 'Supported currencies retrieved.', SUPPORTED_CURRENCIES);
|
|
||||||
};
|
|
||||||
@@ -11,7 +11,10 @@ const { Op, Sequelize } = require('sequelize');
|
|||||||
const sequelize = require('../../config/db.config');
|
const sequelize = require('../../config/db.config');
|
||||||
|
|
||||||
const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = require('../../models/task/task.mdl');
|
const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = require('../../models/task/task.mdl');
|
||||||
|
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
|
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||||
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||||
const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
|
const { adminExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.attributes');
|
||||||
|
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
@@ -623,6 +626,53 @@ exports.updateTask = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
|
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
|
||||||
|
|
||||||
|
// ── Notify assigned users when requirements changed ────────────────────
|
||||||
|
if (Array.isArray(requirements)) {
|
||||||
|
try {
|
||||||
|
const groupRows = await TaskListGroup.findAll({
|
||||||
|
where: { task_list_id: task.task_list_id },
|
||||||
|
attributes: ['group_id'],
|
||||||
|
});
|
||||||
|
const groupIds = groupRows.map((r) => r.group_id);
|
||||||
|
|
||||||
|
if (groupIds.length) {
|
||||||
|
const memberRows = await mdl_UserGroupMembers.findAll({
|
||||||
|
where: { group_id: groupIds },
|
||||||
|
attributes: ['user_id', 'group_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// One notification per user — first group membership wins if they're in more than one.
|
||||||
|
const seenUsers = new Set();
|
||||||
|
const members = memberRows.filter(({ user_id }) => {
|
||||||
|
if (seenUsers.has(user_id)) return false;
|
||||||
|
seenUsers.add(user_id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (members.length) {
|
||||||
|
const now = new Date();
|
||||||
|
await UserNotification.bulkCreate(
|
||||||
|
members.map(({ user_id, group_id }) => ({
|
||||||
|
user_id,
|
||||||
|
...NOTIFICATION_REGISTRY.task_requirements_updated.build({
|
||||||
|
taskName: full.name,
|
||||||
|
taskListId: task.task_list_id,
|
||||||
|
groupId: group_id,
|
||||||
|
}),
|
||||||
|
seen: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})),
|
||||||
|
{ validate: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (notifyErr) {
|
||||||
|
console.error('[ADMIN][UPDATE TASK][NOTIFY]', notifyErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return R.success(res, 'Task updated successfully.', full);
|
return R.success(res, 'Task updated successfully.', full);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
|
|||||||
@@ -37,8 +37,20 @@ const {
|
|||||||
computedAttributes: paymentsComputed,
|
computedAttributes: paymentsComputed,
|
||||||
} = require('../../models/tiers/payments.attributes');
|
} = require('../../models/tiers/payments.attributes');
|
||||||
|
|
||||||
|
const cc = require('currency-codes');
|
||||||
|
|
||||||
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
|
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
|
||||||
|
|
||||||
|
// ─── CURRENCIES ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getCurrencies = (req, res) => {
|
||||||
|
const list = cc.codes().map((code) => {
|
||||||
|
const entry = cc.code(code);
|
||||||
|
return { code: entry.code, name: entry.currency };
|
||||||
|
}).sort((a, b) => a.code.localeCompare(b.code));
|
||||||
|
return R.success(res, 'OK', list);
|
||||||
|
};
|
||||||
|
|
||||||
const expireStalePendingPayments = async () => {
|
const expireStalePendingPayments = async () => {
|
||||||
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
|
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
|
||||||
await mdl_Payments.update(
|
await mdl_Payments.update(
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ exports.getUnits = async (req, res) => {
|
|||||||
where: { course_id: courseId, ...notDeleted },
|
where: { course_id: courseId, ...notDeleted },
|
||||||
order: [["order_index", "ASC"]],
|
order: [["order_index", "ASC"]],
|
||||||
},
|
},
|
||||||
|
computedAttributes: [
|
||||||
|
{
|
||||||
|
key: "quiz_id",
|
||||||
|
label: "Quiz ID",
|
||||||
|
type: "text",
|
||||||
|
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return R.success(res, "Units retrieved.", result);
|
return R.success(res, "Units retrieved.", result);
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ exports.verifyOTP = async (req, res) => {
|
|||||||
groupName: grp?.name ?? null,
|
groupName: grp?.name ?? null,
|
||||||
groupCode: grp?.group_code ?? null,
|
groupCode: grp?.group_code ?? null,
|
||||||
accType: user.acc_type,
|
accType: user.acc_type,
|
||||||
|
groupId: membership?.group_id ?? null,
|
||||||
}),
|
}),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -407,7 +408,7 @@ exports.googleCallback = async (req, res) => {
|
|||||||
UserNotification.bulkCreate([
|
UserNotification.bulkCreate([
|
||||||
{
|
{
|
||||||
user_id: user.user_id,
|
user_id: user.user_id,
|
||||||
...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user' }),
|
...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user', groupId: noGrp?.group_id ?? null }),
|
||||||
createdAt: _now,
|
createdAt: _now,
|
||||||
updatedAt: _now,
|
updatedAt: _now,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,11 +2,39 @@
|
|||||||
|
|
||||||
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
||||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||||
|
const mediaToken = require("../../services/mediaToken.service");
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
|
const { PLACEMENT_MAP } = require("../../models/advertisements/advertisements.placements");
|
||||||
|
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
|
const AD_IMAGE_INCLUDE = {
|
||||||
|
model: mdl_Assets,
|
||||||
|
as: "image",
|
||||||
|
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
|
||||||
|
required: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AD_CLIENT_EXCLUDE = ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"];
|
||||||
|
|
||||||
|
// ─── Media proxying ─────────────────────────────────────────────────────────
|
||||||
|
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
|
||||||
|
// Kept duplicated rather than shared to avoid a cross-boundary import between
|
||||||
|
// admin and client controllers (same rationale as deriveStatus above). Private
|
||||||
|
// (S3-backed) images never expose a raw file_url — the frontend resolves the
|
||||||
|
// stream_token through GET /api/client/media/stream/:token instead.
|
||||||
|
async function attachImageStreamToken(image, req) {
|
||||||
|
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
const ip = mediaToken.resolveIp(req);
|
||||||
|
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||||
|
image.stream_token = token;
|
||||||
|
image.file_url = null;
|
||||||
|
image.thumbnail_url = null;
|
||||||
|
delete image.storage_key;
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Status derivation ─────────────────────────────────────────────────────
|
// ─── Status derivation ─────────────────────────────────────────────────────
|
||||||
// Mirrors admin controller's deriveStatus — single source of truth for what
|
// Mirrors admin controller's deriveStatus — single source of truth for what
|
||||||
@@ -25,42 +53,41 @@ function deriveStatus(advertisement) {
|
|||||||
return "active";
|
return "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
|
// ─── Live window helper ─────────────────────────────────────────────────────
|
||||||
//
|
// "Live" means is_active = true AND within start_date/end_date window —
|
||||||
// Resolves the single highest-priority live advertisement for a given placement
|
|
||||||
// type. "Live" means is_active = true AND within start_date/end_date window —
|
|
||||||
// computed the same way as deriveStatus, but expressed as a SQL WHERE clause
|
// computed the same way as deriveStatus, but expressed as a SQL WHERE clause
|
||||||
// here since we want the DB to do the filtering/ordering, not JS.
|
// here since we want the DB to do the filtering/ordering, not JS.
|
||||||
//
|
function liveWhere(extra) {
|
||||||
// GET /api/client/advertisements/active?type=hero
|
|
||||||
//
|
|
||||||
exports.getActiveAdvertisement = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { type } = req.query;
|
|
||||||
|
|
||||||
if (!type) return R.error(res, "type is required.", 400);
|
|
||||||
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
return {
|
||||||
const advertisement = await Advertisement.findOne({
|
...extra,
|
||||||
where: {
|
|
||||||
type,
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
[Op.and]: [
|
[Op.and]: [
|
||||||
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
|
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
|
||||||
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
|
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
|
||||||
],
|
],
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Resolves the single highest-priority live advertisement for a given placement.
|
||||||
|
//
|
||||||
|
// GET /api/client/advertisements/active?placement=dashboard.hero
|
||||||
|
//
|
||||||
|
exports.getActiveAdvertisement = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { placement } = req.query;
|
||||||
|
|
||||||
|
if (!placement) return R.error(res, "placement is required.", 400);
|
||||||
|
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
|
||||||
|
|
||||||
|
const advertisement = await Advertisement.findOne({
|
||||||
|
where: liveWhere({ placement }),
|
||||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||||
include: [{
|
include: [AD_IMAGE_INCLUDE],
|
||||||
model: mdl_Assets,
|
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||||
as: "image",
|
|
||||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
|
|
||||||
required: false,
|
|
||||||
}],
|
|
||||||
attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null });
|
if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null });
|
||||||
@@ -68,6 +95,8 @@ exports.getActiveAdvertisement = async (req, res) => {
|
|||||||
const json = advertisement.toJSON();
|
const json = advertisement.toJSON();
|
||||||
json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency
|
json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency
|
||||||
|
|
||||||
|
if (json.image) await attachImageStreamToken(json.image, req);
|
||||||
|
|
||||||
return R.success(res, "Active advertisement retrieved.", { data: json });
|
return R.success(res, "Active advertisement retrieved.", { data: json });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err);
|
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err);
|
||||||
@@ -75,6 +104,51 @@ exports.getActiveAdvertisement = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Resolves the highest-priority live advertisement for each of several
|
||||||
|
// placements in a single round-trip — pages that need multiple simultaneous
|
||||||
|
// slots (e.g. dashboard.hero + dashboard.popup) use this instead of N calls
|
||||||
|
// to /active.
|
||||||
|
//
|
||||||
|
// GET /api/client/advertisements/active-batch?placements=dashboard.hero,dashboard.popup
|
||||||
|
//
|
||||||
|
exports.getActiveAdvertisements = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const raw = req.query.placements;
|
||||||
|
const placements = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!placements.length) return R.error(res, "placements is required.", 400);
|
||||||
|
|
||||||
|
const invalid = placements.filter((p) => !PLACEMENT_MAP[p]);
|
||||||
|
if (invalid.length) return R.error(res, `Invalid placement(s): ${invalid.join(", ")}`, 400);
|
||||||
|
|
||||||
|
const advertisements = await Advertisement.findAll({
|
||||||
|
where: liveWhere({ placement: { [Op.in]: placements } }),
|
||||||
|
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||||
|
include: [AD_IMAGE_INCLUDE],
|
||||||
|
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only the highest-priority row per placement (order ASC, createdAt DESC already applied).
|
||||||
|
const data = Object.fromEntries(placements.map((p) => [p, null]));
|
||||||
|
for (const ad of advertisements) {
|
||||||
|
const json = ad.toJSON();
|
||||||
|
if (data[json.placement] !== null) continue; // already have the winner for this placement
|
||||||
|
json.status = deriveStatus(json);
|
||||||
|
if (json.image) await attachImageStreamToken(json.image, req);
|
||||||
|
data[json.placement] = json;
|
||||||
|
}
|
||||||
|
|
||||||
|
return R.success(res, "Active advertisements retrieved.", { data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE BATCH]", err);
|
||||||
|
return R.error(res, "Could not retrieve advertisements.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
|
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// POST /api/client/advertisements/:advertisementId/click
|
// POST /api/client/advertisements/:advertisementId/click
|
||||||
|
|||||||
@@ -14,49 +14,17 @@
|
|||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
const { generateCertificate } = require('../../services/certificate.service');
|
const { generateCertificate } = require('../../services/certificate.service');
|
||||||
const { formatDuration } = require('../../utils/duration.util');
|
const { ensureCertificateRecord, formatInstructors } = require('../../services/certificate-record.service');
|
||||||
const { fmtDate } = require('../../utils/datetime.util');
|
const { fmtDate } = require('../../utils/datetime.util');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Course,
|
Course,
|
||||||
CourseAssessment,
|
CourseAssessment,
|
||||||
QuizAttempt,
|
|
||||||
Certificate,
|
|
||||||
CourseInstructor,
|
CourseInstructor,
|
||||||
} = require('../../models/courses/courses.associations');
|
} = require('../../models/courses/courses.associations');
|
||||||
|
|
||||||
const notDeleted = { deletedAt: null };
|
const notDeleted = { deletedAt: null };
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
|
|
||||||
// userCertSeq = how many certs this user will have after this insert
|
|
||||||
async function buildCertNo(userId) {
|
|
||||||
const count = await Certificate.count({ where: { user_id: userId } });
|
|
||||||
const seq = String(count + 1).padStart(5, '0');
|
|
||||||
const uid = String(userId).padStart(6, '0');
|
|
||||||
const now = new Date();
|
|
||||||
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
return `${YYYYMM}-${uid}-${seq}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
|
|
||||||
async function buildRefNo() {
|
|
||||||
const count = await Certificate.count();
|
|
||||||
const seq = String(count + 1).padStart(5, '0');
|
|
||||||
const now = new Date();
|
|
||||||
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
||||||
return `PP-${YYYYMM}-${seq}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInstructors(rows) {
|
|
||||||
const names = rows.map(r => r.display_name);
|
|
||||||
if (names.length === 0) return '';
|
|
||||||
if (names.length === 1) return names[0];
|
|
||||||
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
|
||||||
return `${names[0]}, ${names[1]} and et. al`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
|
// ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
|
||||||
|
|
||||||
exports.getCertificate = async (req, res) => {
|
exports.getCertificate = async (req, res) => {
|
||||||
@@ -91,53 +59,27 @@ exports.getCertificate = async (req, res) => {
|
|||||||
return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
|
return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Verify the user passed ──────────────────────────────────────────────
|
// ── 2. Get user's name ──────────────────────────────────────────────────────
|
||||||
const passedAttempt = await QuizAttempt.findOne({
|
|
||||||
where: {
|
|
||||||
user_id,
|
|
||||||
assessment_id: course.assessment.assessment_id,
|
|
||||||
passed: true,
|
|
||||||
},
|
|
||||||
order: [['createdAt', 'DESC']],
|
|
||||||
attributes: ['score', 'createdAt'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!passedAttempt) {
|
|
||||||
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. Get user's name ─────────────────────────────────────────────────────
|
|
||||||
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
|
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
|
||||||
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
|
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
|
||||||
|
|
||||||
// ── 4. Resolve or create the certificate record ────────────────────────────
|
// ── 3. Resolve or create the certificate record ─────────────────────────────
|
||||||
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
|
// Shared with the hourly issuance cron (cron/jobs/issue_certificates.cron.js)
|
||||||
let cert = await Certificate.findOne({ where: { user_id, course_id: course.course_id } });
|
// so both write through the same cert_no/ref_no sequence.
|
||||||
|
const cert = await ensureCertificateRecord({ userId: user_id, courseId: course.course_id });
|
||||||
if (!cert) {
|
if (!cert) {
|
||||||
cert = await Certificate.create({
|
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
|
||||||
user_id,
|
|
||||||
course_id: course.course_id,
|
|
||||||
cert_no: await buildCertNo(user_id),
|
|
||||||
ref_no: await buildRefNo(),
|
|
||||||
instructors: formatInstructors(course.instructors ?? []),
|
|
||||||
score: passedAttempt.score ?? null,
|
|
||||||
length_str: formatDuration(course.duration_seconds),
|
|
||||||
issued_at: passedAttempt.createdAt,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use live instructors from course_instructors table for the PDF.
|
// Always use live instructors from course_instructors table for the PDF,
|
||||||
// Keep the snapshot in sync so it reflects the current state.
|
// in case they changed since the certificate row was created.
|
||||||
const liveInstructors = formatInstructors(course.instructors ?? []);
|
const liveInstructors = formatInstructors(course.instructors ?? []);
|
||||||
if (liveInstructors !== (cert.instructors ?? '')) {
|
|
||||||
await cert.update({ instructors: liveInstructors });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
|
// ── 4. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
|
||||||
const issuedDate = new Date(cert.issued_at);
|
const issuedDate = new Date(cert.issued_at);
|
||||||
const dateStr = fmtDate(issuedDate);
|
const dateStr = fmtDate(issuedDate);
|
||||||
|
|
||||||
// ── 6. Generate PDF ────────────────────────────────────────────────────────
|
// ── 5. Generate PDF ────────────────────────────────────────────────────────
|
||||||
const pdf = await generateCertificate({
|
const pdf = await generateCertificate({
|
||||||
name: fullName,
|
name: fullName,
|
||||||
course: course.title,
|
course: course.title,
|
||||||
@@ -148,7 +90,7 @@ exports.getCertificate = async (req, res) => {
|
|||||||
length: cert.length_str ?? '',
|
length: cert.length_str ?? '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 7. Stream response ─────────────────────────────────────────────────────
|
// ── 6. Stream response ─────────────────────────────────────────────────────
|
||||||
const nameParts = fullName.trim().split(/\s+/);
|
const nameParts = fullName.trim().split(/\s+/);
|
||||||
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
|
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
|
||||||
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
|
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
|
||||||
|
|||||||
@@ -958,7 +958,7 @@ exports.submitCourseAssessment = async (req, res) => {
|
|||||||
// Immediate notification: course completed, certificate incoming
|
// Immediate notification: course completed, certificate incoming
|
||||||
UserNotification.create({
|
UserNotification.create({
|
||||||
user_id,
|
user_id,
|
||||||
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '' }),
|
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }),
|
||||||
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
|
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,23 @@ function trackToken(token, ip) {
|
|||||||
|
|
||||||
// ─── Helper: resolve client IP ───────────────────────────────────────────────
|
// ─── Helper: resolve client IP ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
|
||||||
|
// down to a single canonical form. Without this, a token minted off one
|
||||||
|
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
|
||||||
|
// request that happened to land on the other stack (IPv6) — browsers race
|
||||||
|
// both when resolving "localhost", so mint and stream requests can land on
|
||||||
|
// different stacks even from the same client.
|
||||||
|
function normalizeIp(ip) {
|
||||||
|
if (ip === "::1") return "127.0.0.1";
|
||||||
|
if (ip.startsWith("::ffff:")) return ip.slice(7);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveIp(req) {
|
function resolveIp(req) {
|
||||||
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
|
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
|
||||||
const forwarded = req.headers["x-forwarded-for"];
|
const forwarded = req.headers["x-forwarded-for"];
|
||||||
if (forwarded) return forwarded.split(",")[0].trim();
|
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
|
||||||
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
|
return normalizeIp(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
|
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* GET /client/notifications/unseen — unseen count
|
* GET /client/notifications/unseen — unseen count
|
||||||
* PATCH /client/notifications/:id/seen — mark one as seen
|
* PATCH /client/notifications/:id/seen — mark one as seen
|
||||||
* PATCH /client/notifications/seen-all — mark all as seen
|
* PATCH /client/notifications/seen-all — mark all as seen
|
||||||
|
* DELETE /client/notifications/clear-all — delete all notifications
|
||||||
*
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 19, 2026
|
* Date Created: Jun. 19, 2026
|
||||||
@@ -83,4 +84,17 @@ async function markAllSeen(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { list, unseenCount, markSeen, markAllSeen };
|
// ─── DELETE /client/notifications/clear-all ──────────────────────────────────
|
||||||
|
async function clearAll(req, res) {
|
||||||
|
try {
|
||||||
|
const count = await UserNotification.destroy({
|
||||||
|
where: { user_id: req.user.user_id },
|
||||||
|
});
|
||||||
|
return R.success(res, `${count} notification(s) cleared.`, { count });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CLIENT NOTIFICATION] clearAll error:', err);
|
||||||
|
return R.error(res, 'Failed to clear notifications.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { list, unseenCount, markSeen, markAllSeen, clearAll };
|
||||||
|
|||||||
@@ -66,23 +66,6 @@ exports.updateProfile = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── PATCH preferred currency ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
exports.updateCurrency = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { currency } = req.body;
|
|
||||||
if (!currency || typeof currency !== 'string' || currency.length !== 3)
|
|
||||||
return R.error(res, 'A valid 3-letter ISO 4217 currency code is required.', 400);
|
|
||||||
|
|
||||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
|
||||||
await user.update({ preferred_currency: currency.toUpperCase() });
|
|
||||||
return R.success(res, 'Currency preference updated.', { preferred_currency: user.preferred_currency });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[CLIENT] updateCurrency error:', err);
|
|
||||||
return R.error(res, 'Could not update currency preference.', 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── GET own sessions ──────────────────────────────────────────────────────────
|
// ─── GET own sessions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getSessions = async (req, res) => {
|
exports.getSessions = async (req, res) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const { userExclude } = require('../../models/task/task.attributes');
|
|||||||
const { clientExclude } = require('../../models/task/task_completion.attributes');
|
const { clientExclude } = require('../../models/task/task_completion.attributes');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
|
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
const isUUID = (v) => UUID_RE.test(v);
|
const isUUID = (v) => UUID_RE.test(v);
|
||||||
@@ -45,13 +46,32 @@ exports.getMyGroups = async (req, res) => {
|
|||||||
attributes: [],
|
attributes: [],
|
||||||
through: {
|
through: {
|
||||||
model: mdl_UserGroupMembers,
|
model: mdl_UserGroupMembers,
|
||||||
attributes: ['joined_at'],
|
attributes: [],
|
||||||
where: { deletedAt: null },
|
where: { deletedAt: null },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: TaskList,
|
||||||
|
as: 'taskLists',
|
||||||
|
attributes: [],
|
||||||
|
through: { attributes: [] },
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
where: { is_active: true },
|
where: { is_active: true },
|
||||||
attributes: ['group_id', 'name', 'group_code', 'description'],
|
attributes: [
|
||||||
|
'group_id',
|
||||||
|
'name',
|
||||||
|
'group_code',
|
||||||
|
'description',
|
||||||
|
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('taskLists.task_list_id'))), 'task_list_count'],
|
||||||
|
],
|
||||||
|
group: [
|
||||||
|
'UserGroup.group_id',
|
||||||
|
'UserGroup.name',
|
||||||
|
'UserGroup.group_code',
|
||||||
|
'UserGroup.description',
|
||||||
|
],
|
||||||
order: [['name', 'ASC']],
|
order: [['name', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,6 +195,13 @@ exports.getGroupTaskList = async (req, res) => {
|
|||||||
const json = taskList.toJSON();
|
const json = taskList.toJSON();
|
||||||
const tasks = json.tasks ?? [];
|
const tasks = json.tasks ?? [];
|
||||||
const taskIds = tasks.map((t) => t.task_id);
|
const taskIds = tasks.map((t) => t.task_id);
|
||||||
|
const readRequirements = tasks.flatMap((task) =>
|
||||||
|
(task.requirements ?? [])
|
||||||
|
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
|
||||||
|
.map((req) => ({ ...req, task_id: task.task_id }))
|
||||||
|
);
|
||||||
|
|
||||||
|
await hydrateReadTaskProgress(userId, readRequirements);
|
||||||
|
|
||||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
const [completions, linkVisits, progressRows] = await Promise.all([
|
||||||
@@ -316,6 +343,18 @@ exports.getGroupTaskLists = async (req, res) => {
|
|||||||
// ── Gather all task_ids across the group's task lists ──────────────────
|
// ── Gather all task_ids across the group's task lists ──────────────────
|
||||||
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
|
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
|
||||||
const taskIds = allTasks.map((t) => t.task_id);
|
const taskIds = allTasks.map((t) => t.task_id);
|
||||||
|
const readRequirements = allTasks.flatMap((task) =>
|
||||||
|
(task.requirements ?? [])
|
||||||
|
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
|
||||||
|
.map((req) => ({
|
||||||
|
task_id: task.task_id,
|
||||||
|
requirement_id: req.requirement_id,
|
||||||
|
reference_id: req.reference_id,
|
||||||
|
type: req.type,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await hydrateReadTaskProgress(userId, readRequirements);
|
||||||
|
|
||||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
const [completions, linkVisits, progressRows] = await Promise.all([
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 13, 2026
|
* Date Created: Jun. 13, 2026
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
|
const { Op } = require('sequelize');
|
||||||
const sequelize = require('../../config/db.config');
|
const sequelize = require('../../config/db.config');
|
||||||
|
|
||||||
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
||||||
@@ -28,6 +29,7 @@ const { TaskLinkVisit, TaskProgress } = require('../../models/task/task
|
|||||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const R = require('../../utils/response.util');
|
const R = require('../../utils/response.util');
|
||||||
|
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
const isUUID = (v) => UUID_RE.test(v);
|
const isUUID = (v) => UUID_RE.test(v);
|
||||||
@@ -101,6 +103,16 @@ exports.getTaskProgress = async (req, res) => {
|
|||||||
});
|
});
|
||||||
if (!task) return R.error(res, 'Task not found.', 404);
|
if (!task) return R.error(res, 'Task not found.', 404);
|
||||||
|
|
||||||
|
const readRequirements = await TaskRequirement.findAll({
|
||||||
|
where: {
|
||||||
|
task_id: taskId,
|
||||||
|
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
|
||||||
|
},
|
||||||
|
attributes: ['task_id', 'requirement_id', 'reference_id', 'type'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await hydrateReadTaskProgress(req.user.user_id, readRequirements);
|
||||||
|
|
||||||
const [linkVisits, progress] = await Promise.all([
|
const [linkVisits, progress] = await Promise.all([
|
||||||
TaskLinkVisit.findAll({
|
TaskLinkVisit.findAll({
|
||||||
where: { task_id: taskId, user_id: req.user.user_id },
|
where: { task_id: taskId, user_id: req.user.user_id },
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||||
const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl');
|
|
||||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||||
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||||
@@ -55,6 +54,7 @@ exports.getMyTier = async (req, res) => {
|
|||||||
...NOTIFICATION_REGISTRY.tier_expired.build({
|
...NOTIFICATION_REGISTRY.tier_expired.build({
|
||||||
tier: tier.tier,
|
tier: tier.tier,
|
||||||
label: tier.plan?.label ?? null,
|
label: tier.plan?.label ?? null,
|
||||||
|
planId: tier.plan?.plan_id ?? null,
|
||||||
}),
|
}),
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return R.success(res, 'Active tier retrieved.', {
|
return R.success(res, 'Active tier retrieved.', {
|
||||||
@@ -114,11 +114,6 @@ exports.getPlans = async (req, res) => {
|
|||||||
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
|
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
|
||||||
through: { attributes: [] },
|
through: { attributes: [] },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
model: mdl_PlanPrices,
|
|
||||||
as: 'prices',
|
|
||||||
attributes: ['currency', 'price'],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,21 +134,14 @@ exports.getPlans = async (req, res) => {
|
|||||||
|
|
||||||
exports.validatePromo = async (req, res) => {
|
exports.validatePromo = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { plan_id, code, currency } = req.body;
|
const { plan_id, code } = req.body;
|
||||||
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
|
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
|
||||||
|
|
||||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
||||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||||
|
|
||||||
// Resolve localized price if a preferred currency was sent
|
|
||||||
let effectivePrice = null;
|
|
||||||
if (currency && currency !== plan.currency) {
|
|
||||||
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency } });
|
|
||||||
if (priceEntry) effectivePrice = priceEntry.price;
|
|
||||||
}
|
|
||||||
|
|
||||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||||
const result = await paymentSvc.evaluatePromo(policy, plan, code, effectivePrice);
|
const result = await paymentSvc.evaluatePromo(policy, plan, code, null);
|
||||||
|
|
||||||
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
|
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -166,22 +154,14 @@ exports.validatePromo = async (req, res) => {
|
|||||||
|
|
||||||
exports.createOrder = async (req, res) => {
|
exports.createOrder = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { plan_id, promo_code, currency: requestedCurrency } = req.body;
|
const { plan_id, promo_code } = req.body;
|
||||||
if (!plan_id) return R.error(res, 'plan_id is required.', 400);
|
if (!plan_id) return R.error(res, 'plan_id is required.', 400);
|
||||||
|
|
||||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
|
||||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||||
|
|
||||||
// Resolve localized price — falls back to plan base price when no override exists
|
const effectivePrice = Number(plan.price);
|
||||||
let effectivePrice = Number(plan.price);
|
const effectiveCurrency = plan.currency;
|
||||||
let effectiveCurrency = plan.currency;
|
|
||||||
if (requestedCurrency && requestedCurrency !== plan.currency) {
|
|
||||||
const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency: requestedCurrency } });
|
|
||||||
if (priceEntry) {
|
|
||||||
effectivePrice = Number(priceEntry.price);
|
|
||||||
effectiveCurrency = priceEntry.currency;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||||
|
|
||||||
|
|||||||
+28
-11
@@ -14,8 +14,16 @@
|
|||||||
* That's the only wiring required — server.js never needs to
|
* That's the only wiring required — server.js never needs to
|
||||||
* change when admin-side jobs are added/removed.
|
* change when admin-side jobs are added/removed.
|
||||||
*
|
*
|
||||||
|
* taskOverdue is settings-backed (see cronRegistry.util.js) —
|
||||||
|
* its schedule/enabled state lives in cron_notification_settings
|
||||||
|
* and is configurable from /admin/notifications/settings without
|
||||||
|
* a restart. liftExpiredBans is not notification-related, so it
|
||||||
|
* stays on a plain hardcoded schedule.
|
||||||
|
*
|
||||||
* Currently registered:
|
* Currently registered:
|
||||||
* - taskOverdue (cron/jobs/taskOverdue.cron.js)
|
* - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed
|
||||||
|
* - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js)
|
||||||
|
* - dispatchEmailBroadcasts (cron/jobs/dispatch_email_broadcasts.cron.js)
|
||||||
*
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 17, 2026
|
* Date Created: Jun. 17, 2026
|
||||||
@@ -23,24 +31,33 @@
|
|||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const taskOverdue = require('./jobs/task_overdue.cron');
|
const taskOverdue = require('./jobs/task_overdue.cron');
|
||||||
const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
|
const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
|
||||||
|
const dispatchEmailBroadcasts = require('./jobs/dispatch_email_broadcasts.cron');
|
||||||
|
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||||
|
|
||||||
// ─── Registry — add future admin-side cron jobs here ─────────────────────────
|
// ─── Registry — add future admin-side cron jobs here ─────────────────────────
|
||||||
const jobs = [
|
const settingsBackedJobs = [
|
||||||
taskOverdue,
|
taskOverdue,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
||||||
|
const plainJobs = [
|
||||||
liftExpiredBans,
|
liftExpiredBans,
|
||||||
|
dispatchEmailBroadcasts,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
|
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
|
||||||
function startAdminCronJobs() {
|
async function startAdminCronJobs() {
|
||||||
const registered = [];
|
const registered = await startSettingsBackedJobs(settingsBackedJobs, 'ADMIN');
|
||||||
jobs.forEach(({ name, schedule, run }) => {
|
|
||||||
if (!cron.validate(schedule)) {
|
for (const job of plainJobs) {
|
||||||
console.error(`[CRON][ADMIN] Invalid schedule for "${name}": "${schedule}" — skipped.`);
|
if (!cron.validate(job.schedule)) {
|
||||||
return;
|
console.error(`[CRON][ADMIN] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
cron.schedule(schedule, run);
|
cron.schedule(job.schedule, job.run);
|
||||||
registered.push({ name, scope: 'ADMIN', schedule });
|
registered.push({ name: job.name, scope: 'ADMIN', schedule: job.schedule });
|
||||||
});
|
}
|
||||||
|
|
||||||
return registered;
|
return registered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-12
@@ -5,6 +5,11 @@
|
|||||||
* Same shape as admin.cron.js — each job module exports
|
* Same shape as admin.cron.js — each job module exports
|
||||||
* { name, schedule, run }, listed in the `jobs` array below.
|
* { name, schedule, run }, listed in the `jobs` array below.
|
||||||
*
|
*
|
||||||
|
* All three are settings-backed (see cronRegistry.util.js) —
|
||||||
|
* schedule/enabled state lives in cron_notification_settings
|
||||||
|
* and is configurable from /admin/notifications/settings
|
||||||
|
* without a restart.
|
||||||
|
*
|
||||||
* Currently registered:
|
* Currently registered:
|
||||||
* - userNotifications (cron/jobs/user_notifications.cron.js)
|
* - userNotifications (cron/jobs/user_notifications.cron.js)
|
||||||
* - issueCertificates (cron/jobs/issue_certificates.cron.js)
|
* - issueCertificates (cron/jobs/issue_certificates.cron.js)
|
||||||
@@ -13,10 +18,10 @@
|
|||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 17, 2026
|
* Date Created: Jun. 17, 2026
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const cron = require('node-cron');
|
|
||||||
const userNotifications = require('./jobs/user_notifications.cron');
|
const userNotifications = require('./jobs/user_notifications.cron');
|
||||||
const issueCertificates = require('./jobs/issue_certificates.cron');
|
const issueCertificates = require('./jobs/issue_certificates.cron');
|
||||||
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
||||||
|
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||||
|
|
||||||
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
||||||
const jobs = [
|
const jobs = [
|
||||||
@@ -26,17 +31,8 @@ const jobs = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
||||||
function startClientCronJobs() {
|
async function startClientCronJobs() {
|
||||||
const registered = [];
|
return startSettingsBackedJobs(jobs, 'CLIENT');
|
||||||
jobs.forEach(({ name, schedule, run }) => {
|
|
||||||
if (!cron.validate(schedule)) {
|
|
||||||
console.error(`[CRON][CLIENT] Invalid schedule for "${name}": "${schedule}" — skipped.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cron.schedule(schedule, run);
|
|
||||||
registered.push({ name, scope: 'CLIENT', schedule });
|
|
||||||
});
|
|
||||||
return registered;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startClientCronJobs };
|
module.exports = { startClientCronJobs };
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name : cronRegistry.util.js
|
||||||
|
* Type : Utility
|
||||||
|
* Description : Shared machinery for settings-backed cron jobs (the 4 jobs
|
||||||
|
* that emit notifications and are configurable from
|
||||||
|
* /admin/notifications/settings). Not every cron job in the
|
||||||
|
* app goes through this — jobs with no notification tied to
|
||||||
|
* them (e.g. lift_expired_bans) keep using node-cron directly.
|
||||||
|
*
|
||||||
|
* startSettingsBackedJobs() reads each job's schedule from
|
||||||
|
* cron_notification_settings (falling back to — and seeding —
|
||||||
|
* the job's own hardcoded default on first boot), then keeps
|
||||||
|
* a live reference to the scheduled task so it can be swapped
|
||||||
|
* out later via rescheduleJob() without a server restart.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 2, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const cron = require('node-cron');
|
||||||
|
const CronNotificationSetting = require('../models/notifications/cron_notification_setting.mdl');
|
||||||
|
|
||||||
|
// job_name -> { task: ScheduledTask, run: fn }
|
||||||
|
const runningTasks = new Map();
|
||||||
|
|
||||||
|
// CockroachDB can't run Sequelize's findOrCreate() — it wraps the insert in a
|
||||||
|
// pg_temp PL/pgSQL function to atomically catch unique_violation, which
|
||||||
|
// CockroachDB rejects ("cannot create user-defined functions under a temporary
|
||||||
|
// schema"). Plain findOne-then-create sidesteps it; the race window (two boots
|
||||||
|
// racing to seed the same job_name) is a non-issue here — jobs are seeded once.
|
||||||
|
async function getOrCreateSetting(jobName, defaultSchedule) {
|
||||||
|
let row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||||
|
if (row) return row;
|
||||||
|
|
||||||
|
try {
|
||||||
|
row = await CronNotificationSetting.create({ job_name: jobName, enabled: true, schedule: defaultSchedule });
|
||||||
|
} catch (err) {
|
||||||
|
row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||||
|
if (!row) throw err;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSettingsBackedJobs(jobs, scopeLabel) {
|
||||||
|
const registered = [];
|
||||||
|
|
||||||
|
for (const { name, schedule: defaultSchedule, run } of jobs) {
|
||||||
|
let schedule = defaultSchedule;
|
||||||
|
try {
|
||||||
|
const settings = await getOrCreateSetting(name, defaultSchedule);
|
||||||
|
schedule = settings.schedule || defaultSchedule;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[CRON][${scopeLabel}] Failed to load settings for "${name}", using hardcoded default:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cron.validate(schedule)) {
|
||||||
|
console.error(`[CRON][${scopeLabel}] Invalid schedule for "${name}": "${schedule}" — skipped.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = cron.schedule(schedule, run);
|
||||||
|
runningTasks.set(name, { task, run });
|
||||||
|
registered.push({ name, scope: scopeLabel, schedule });
|
||||||
|
}
|
||||||
|
|
||||||
|
return registered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-swap a running job's schedule — used by notificationSettings.controller.js
|
||||||
|
// after an admin picks a new preset. No server restart required.
|
||||||
|
function rescheduleJob(jobName, newSchedule) {
|
||||||
|
const entry = runningTasks.get(jobName);
|
||||||
|
if (!entry) throw new Error(`No running cron task found for "${jobName}".`);
|
||||||
|
if (!cron.validate(newSchedule)) throw new Error(`Invalid cron schedule: "${newSchedule}".`);
|
||||||
|
|
||||||
|
entry.task.stop();
|
||||||
|
const task = cron.schedule(newSchedule, entry.run);
|
||||||
|
runningTasks.set(jobName, { task, run: entry.run });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startSettingsBackedJobs, rescheduleJob, getOrCreateSetting };
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name : dispatch_email_broadcasts.cron.js
|
||||||
|
* Type : Cron Job
|
||||||
|
* Description : Sends real, paced SMTP email for queued email_broadcasts —
|
||||||
|
* the actual delivery half of controllers/admin/email_broadcasts
|
||||||
|
* .controller.js's createEmailBroadcast(), which only ever
|
||||||
|
* enqueues rows and returns immediately.
|
||||||
|
*
|
||||||
|
* Every tick, picks up to BATCH_SIZE 'pending' recipient rows
|
||||||
|
* (oldest broadcast first, FIFO within it) and sends them one
|
||||||
|
* at a time with a short delay between each — this is the "one
|
||||||
|
* by one, not a blocking for-loop in the API request" behavior:
|
||||||
|
* it's fine to block *here* because nothing is waiting on an
|
||||||
|
* HTTP response, and the delay keeps us well under Gmail SMTP's
|
||||||
|
* practical sustained-send pacing.
|
||||||
|
*
|
||||||
|
* Resumable by construction — if the process restarts mid-
|
||||||
|
* broadcast, the next tick just keeps consuming whatever rows
|
||||||
|
* are still 'pending'. Single-instance only: this does not use
|
||||||
|
* row-level locking, so running more than one app instance
|
||||||
|
* would let two ticks grab the same batch. Fine for the
|
||||||
|
* current single-process deployment; would need SELECT ... FOR
|
||||||
|
* UPDATE SKIP LOCKED before scaling horizontally.
|
||||||
|
*
|
||||||
|
* Schedule : Every minute ("* * * * *"). Registered by cron/admin.cron.js.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
|
||||||
|
const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl');
|
||||||
|
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
|
||||||
|
const { sendEmail } = require('../../services/email.service');
|
||||||
|
|
||||||
|
const BATCH_SIZE = 25;
|
||||||
|
const DELAY_MS = 600; // pacing between individual sends — keeps us well under Gmail's throttling threshold
|
||||||
|
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const recipients = await mdl_EmailBroadcastRecipient.findAll({
|
||||||
|
where: { status: 'pending' },
|
||||||
|
include: [{
|
||||||
|
model: mdl_EmailBroadcast,
|
||||||
|
as: 'broadcast',
|
||||||
|
where: { status: ['queued', 'sending'] },
|
||||||
|
include: [{ model: mdl_EmailTemplate, as: 'template' }],
|
||||||
|
}],
|
||||||
|
order: [['email_broadcast_id', 'ASC'], ['email_broadcast_recipient_id', 'ASC']],
|
||||||
|
limit: BATCH_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipients.length) return;
|
||||||
|
|
||||||
|
const touchedBroadcastIds = new Set();
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const broadcast = recipient.broadcast;
|
||||||
|
const template = broadcast?.template;
|
||||||
|
|
||||||
|
if (!broadcast || !template) {
|
||||||
|
await recipient.update({ status: 'failed', error: 'Broadcast or template no longer exists.', sent_at: new Date() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (broadcast.status === 'queued') {
|
||||||
|
await broadcast.update({ status: 'sending', started_at: broadcast.started_at ?? new Date() });
|
||||||
|
}
|
||||||
|
touchedBroadcastIds.add(broadcast.email_broadcast_id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: recipient.email,
|
||||||
|
type: template.type,
|
||||||
|
data: { name: recipient.name || 'there', email: recipient.email },
|
||||||
|
});
|
||||||
|
await recipient.update({ status: 'sent', sent_at: new Date() });
|
||||||
|
await broadcast.increment('sent_count');
|
||||||
|
} catch (err) {
|
||||||
|
await recipient.update({ status: 'failed', error: err.message, sent_at: new Date() });
|
||||||
|
await broadcast.increment('failed_count');
|
||||||
|
console.error('[CRON][EMAIL BROADCAST] Send failed:', recipient.email, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await delay(DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close out any broadcast that has no pending recipients left.
|
||||||
|
for (const broadcastId of touchedBroadcastIds) {
|
||||||
|
const remaining = await mdl_EmailBroadcastRecipient.count({ where: { email_broadcast_id: broadcastId, status: 'pending' } });
|
||||||
|
if (remaining === 0) {
|
||||||
|
await mdl_EmailBroadcast.update(
|
||||||
|
{ status: 'completed', completed_at: new Date() },
|
||||||
|
{ where: { email_broadcast_id: broadcastId, status: ['queued', 'sending'] } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[CRON][EMAIL BROADCAST] Processed ${recipients.length} recipient(s) across ${touchedBroadcastIds.size} broadcast(s).`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CRON][EMAIL BROADCAST] Failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'dispatchEmailBroadcasts',
|
||||||
|
schedule: '* * * * *',
|
||||||
|
run,
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ const { Op } = require('sequelize');
|
|||||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||||
|
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||||
|
|
||||||
require('../../models/tiers/tier.associations');
|
require('../../models/tiers/tier.associations');
|
||||||
@@ -43,7 +44,7 @@ async function run() {
|
|||||||
include: [{
|
include: [{
|
||||||
model: mdl_TierPlans,
|
model: mdl_TierPlans,
|
||||||
as: 'plan',
|
as: 'plan',
|
||||||
attributes: ['label', 'tier'],
|
attributes: ['plan_id', 'label', 'tier'],
|
||||||
required: false,
|
required: false,
|
||||||
}],
|
}],
|
||||||
attributes: ['tier_id', 'user_id', 'tier'],
|
attributes: ['tier_id', 'user_id', 'tier'],
|
||||||
@@ -69,10 +70,14 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Send in-app notifications (one per affected user) ──────────────────
|
// ── 3. Send in-app notifications (one per affected user) ──────────────────
|
||||||
|
// Status flip above always happens — only this step is skippable via settings.
|
||||||
|
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } });
|
||||||
|
if (!settings || settings.enabled) {
|
||||||
const notifications = expired.map((t) =>
|
const notifications = expired.map((t) =>
|
||||||
NOTIFICATION_REGISTRY.tier_expired.build({
|
NOTIFICATION_REGISTRY.tier_expired.build({
|
||||||
tier: t.tier,
|
tier: t.tier,
|
||||||
label: t.plan?.label ?? null,
|
label: t.plan?.label ?? null,
|
||||||
|
planId: t.plan?.plan_id ?? null,
|
||||||
})
|
})
|
||||||
).map((payload, i) => ({
|
).map((payload, i) => ({
|
||||||
user_id: expired[i].user_id,
|
user_id: expired[i].user_id,
|
||||||
@@ -84,6 +89,7 @@ async function run() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
|
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`);
|
console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
* For each ready row it:
|
* For each ready row it:
|
||||||
* 1. Grants the course_completed_<uuid> achievement (the key
|
* 1. Grants the course_completed_<uuid> achievement (the key
|
||||||
* MyCertificates / Profile use to display certificate cards).
|
* MyCertificates / Profile use to display certificate cards).
|
||||||
* 2. Sends a 'certificate_issued' UserNotification.
|
* 2. Persists the certificate record (cert_no/ref_no) via
|
||||||
* 3. Marks the row processed_at = NOW() so it never fires again.
|
* services/certificate-record.service.js, so course.certificate
|
||||||
|
* is populated immediately instead of only on first PDF download.
|
||||||
|
* 3. Sends a 'certificate_issued' UserNotification.
|
||||||
|
* 4. Marks the row processed_at = NOW() so it never fires again.
|
||||||
*
|
*
|
||||||
* Safety pattern: processed_at is set only after both step 1 and
|
* Safety pattern: processed_at is set only after both step 1 and
|
||||||
* step 2 succeed. If the process restarts mid-run the row will be
|
* step 2 succeed. If the process restarts mid-run the row will be
|
||||||
@@ -28,9 +31,15 @@ const { Op } = require('sequelize');
|
|||||||
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
|
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
|
||||||
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||||
|
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||||
|
const { ensureCertificateRecord } = require('../../services/certificate-record.service');
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
// Certificate/achievement issuance always happens — only the notification step is skippable.
|
||||||
|
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'issueCertificates' } });
|
||||||
|
const notificationsEnabled = !settings || settings.enabled;
|
||||||
|
|
||||||
// ── 1. Fetch all rows ready to process ────────────────────────────────────
|
// ── 1. Fetch all rows ready to process ────────────────────────────────────
|
||||||
let rows;
|
let rows;
|
||||||
try {
|
try {
|
||||||
@@ -69,7 +78,13 @@ async function run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Send certificate_issued notification ────────────────────────
|
// ── 3. Persist the actual certificate record (cert_no/ref_no) so
|
||||||
|
// course.certificate is populated immediately, instead of only
|
||||||
|
// lazily on first PDF download ────────────────────────────────
|
||||||
|
await ensureCertificateRecord({ userId: user_id, courseId: row.course_id });
|
||||||
|
|
||||||
|
// ── 4. Send certificate_issued notification ─────────────────────────
|
||||||
|
if (notificationsEnabled) {
|
||||||
await UserNotification.create({
|
await UserNotification.create({
|
||||||
user_id,
|
user_id,
|
||||||
...NOTIFICATION_REGISTRY.certificate_issued.build({
|
...NOTIFICATION_REGISTRY.certificate_issued.build({
|
||||||
@@ -77,8 +92,9 @@ async function run() {
|
|||||||
courseUuid: course_uuid,
|
courseUuid: course_uuid,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── 4. Mark row processed ─────────────────────────────────────────
|
// ── 5. Mark row processed ─────────────────────────────────────────
|
||||||
await PendingCertificate.update(
|
await PendingCertificate.update(
|
||||||
{ processed_at: new Date() },
|
{ processed_at: new Date() },
|
||||||
{ where: { pending_id } }
|
{ where: { pending_id } }
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const { Task } = require('../../models/task/task.mdl');
|
const { Task } = require('../../models/task/task.mdl');
|
||||||
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
|
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
|
||||||
|
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||||
|
|
||||||
// ─── The actual sweep ────────────────────────────────────────────────────────
|
// ─── The actual sweep ────────────────────────────────────────────────────────
|
||||||
@@ -52,7 +53,11 @@ async function run() {
|
|||||||
console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`);
|
console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`);
|
||||||
|
|
||||||
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
|
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
|
||||||
|
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
|
||||||
try {
|
try {
|
||||||
|
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } });
|
||||||
|
if (settings && !settings.enabled) return;
|
||||||
|
|
||||||
await AdminNotification.create(
|
await AdminNotification.create(
|
||||||
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount })
|
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ const { Op, QueryTypes } = require('sequelize');
|
|||||||
const sequelize = require('../../config/db.config');
|
const sequelize = require('../../config/db.config');
|
||||||
const { Task } = require('../../models/task/task.mdl');
|
const { Task } = require('../../models/task/task.mdl');
|
||||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||||
|
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||||
|
|
||||||
const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
|
const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
// Entire job exists to emit this notification — skippable via /admin/notifications/settings.
|
||||||
|
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } });
|
||||||
|
if (settings && !settings.enabled) return;
|
||||||
|
|
||||||
// ── 1. Find tasks that flipped to overdue in the last 65 minutes ──────────
|
// ── 1. Find tasks that flipped to overdue in the last 65 minutes ──────────
|
||||||
let recentlyOverdue;
|
let recentlyOverdue;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+5
-105
@@ -1,119 +1,19 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
/***********************************************************************************************************************************************************************
|
||||||
* File Name: achievements.data.js
|
* File Name: achievements.data.js
|
||||||
* Type of Program: Data
|
* Type of Program: Data
|
||||||
* Description: Static registry of all available achievements (badges and milestones).
|
* Description: Static config for the achievement system that isn't part of the
|
||||||
* Add a new achievement here — no other code changes needed.
|
* admin-managed catalog. The achievement catalog itself (keys, type,
|
||||||
*
|
* label, description, icon) now lives in the achievement_definitions
|
||||||
* Available keys:
|
* table — see models/users/achievement_definitions.mdl.js and
|
||||||
* Badge:
|
* controllers/admin/achievements.controller.js for CRUD.
|
||||||
* early_access — registered before Dec 31, 2026
|
|
||||||
* premium_first_time — first premium tier purchase
|
|
||||||
* exclusive_first_time — first exclusive tier purchase
|
|
||||||
* Milestone:
|
|
||||||
* first_course_completed — completed first course
|
|
||||||
* courses_completed_5 — completed 5 courses
|
|
||||||
* courses_completed_10 — completed 10 courses
|
|
||||||
* perfect_quiz_score — perfect score on a quiz
|
|
||||||
* profile_completed — filled out full profile
|
|
||||||
* first_referral — referred a user
|
|
||||||
*
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 19, 2026
|
* Date Created: Jun. 19, 2026
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ─── Early Access cutoff ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const EARLY_ACCESS_CUTOFF = new Date('2026-12-31T23:59:59Z');
|
const EARLY_ACCESS_CUTOFF = new Date('2026-12-31T23:59:59Z');
|
||||||
|
|
||||||
// ─── Achievement Registry ─────────────────────────────────────────────────────
|
|
||||||
// To add a new achievement — add an entry here. No other code changes needed.
|
|
||||||
//
|
|
||||||
// trigger categories (for documentation only, not enforced):
|
|
||||||
// auth — registration / login events
|
|
||||||
// tier — subscription purchase events
|
|
||||||
// course — course / lesson / quiz events
|
|
||||||
// profile — profile completion events
|
|
||||||
// social — referral / community events
|
|
||||||
|
|
||||||
const ACHIEVEMENT_REGISTRY = {
|
|
||||||
|
|
||||||
// ── Auth ───────────────────────────────────────────────────────────────────
|
|
||||||
early_access: {
|
|
||||||
key: 'early_access',
|
|
||||||
type: 'badge',
|
|
||||||
label: 'Early Access',
|
|
||||||
description: 'Registered during the Philproperties beta period (before Dec 31, 2026).',
|
|
||||||
trigger: 'auth',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Tier ───────────────────────────────────────────────────────────────────
|
|
||||||
premium_first_time: {
|
|
||||||
key: 'premium_first_time',
|
|
||||||
type: 'badge',
|
|
||||||
label: 'Premium Member',
|
|
||||||
description: 'Purchased a Premium tier plan for the first time.',
|
|
||||||
trigger: 'tier',
|
|
||||||
},
|
|
||||||
exclusive_first_time: {
|
|
||||||
key: 'exclusive_first_time',
|
|
||||||
type: 'badge',
|
|
||||||
label: 'Exclusive Member',
|
|
||||||
description: 'Purchased an Exclusive tier plan for the first time.',
|
|
||||||
trigger: 'tier',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Course ─────────────────────────────────────────────────────────────────
|
|
||||||
first_course_completed: {
|
|
||||||
key: 'first_course_completed',
|
|
||||||
type: 'milestone',
|
|
||||||
label: 'First Course Completed',
|
|
||||||
description: 'Completed your very first course on Philproperties.',
|
|
||||||
trigger: 'course',
|
|
||||||
},
|
|
||||||
courses_completed_5: {
|
|
||||||
key: 'courses_completed_5',
|
|
||||||
type: 'milestone',
|
|
||||||
label: 'Learning Streak',
|
|
||||||
description: 'Completed 5 courses.',
|
|
||||||
trigger: 'course',
|
|
||||||
},
|
|
||||||
courses_completed_10: {
|
|
||||||
key: 'courses_completed_10',
|
|
||||||
type: 'milestone',
|
|
||||||
label: 'Knowledge Builder',
|
|
||||||
description: 'Completed 10 courses.',
|
|
||||||
trigger: 'course',
|
|
||||||
},
|
|
||||||
perfect_quiz_score: {
|
|
||||||
key: 'perfect_quiz_score',
|
|
||||||
type: 'milestone',
|
|
||||||
label: 'Perfect Score',
|
|
||||||
description: 'Achieved a perfect score on a quiz.',
|
|
||||||
trigger: 'course',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Profile ────────────────────────────────────────────────────────────────
|
|
||||||
profile_completed: {
|
|
||||||
key: 'profile_completed',
|
|
||||||
type: 'milestone',
|
|
||||||
label: 'Profile Complete',
|
|
||||||
description: 'Filled out all personal profile information.',
|
|
||||||
trigger: 'profile',
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Social ─────────────────────────────────────────────────────────────────
|
|
||||||
first_referral: {
|
|
||||||
key: 'first_referral',
|
|
||||||
type: 'milestone',
|
|
||||||
label: 'Referral Champion',
|
|
||||||
description: 'Successfully referred a user to Philproperties.',
|
|
||||||
trigger: 'social',
|
|
||||||
},
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
EARLY_ACCESS_CUTOFF,
|
EARLY_ACCESS_CUTOFF,
|
||||||
ACHIEVEMENT_REGISTRY,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: cronPresets.data.js
|
||||||
|
* Type of Program: Data
|
||||||
|
* Description: Friendly schedule presets for admin-configurable notification
|
||||||
|
* crons. The UI only ever offers these six options — no raw cron
|
||||||
|
* expressions are accepted from the client, so `updateSetting`
|
||||||
|
* in notificationSettings.controller.js validates against this map.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 2, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CRON_PRESETS = {
|
||||||
|
every_minute: '* * * * *',
|
||||||
|
every_5_min: '*/5 * * * *',
|
||||||
|
every_15_min: '*/15 * * * *',
|
||||||
|
hourly: '0 * * * *',
|
||||||
|
every_6_hours: '0 */6 * * *',
|
||||||
|
daily: '0 0 * * *',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reverse lookup — cron string -> preset key (used to label a job's current schedule)
|
||||||
|
const CRON_PRESET_BY_EXPRESSION = Object.fromEntries(
|
||||||
|
Object.entries(CRON_PRESETS).map(([key, expr]) => [expr, key])
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION };
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
|
|
||||||
|
|
||||||
const wrap = (body) => `
|
|
||||||
<html>
|
|
||||||
<body style="${FONT} line-height: 1.6;">
|
|
||||||
${body.trim()}
|
|
||||||
<br><br>
|
|
||||||
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
|
|
||||||
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
|
|
||||||
</body>
|
|
||||||
</html>`.trim();
|
|
||||||
|
|
||||||
export const emailTemplates = {
|
|
||||||
OTP: ({ otp, expiryMinutes = 10 }) => ({
|
|
||||||
subject: "Email OTP Verification - STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear User,</p>
|
|
||||||
<p>Please use the One-Time Password (OTP) below to verify your email address. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
|
|
||||||
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
|
|
||||||
<p>For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
|
|
||||||
WELCOME: ({ name }) => ({
|
|
||||||
subject: "Welcome to STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear ${name},</p>
|
|
||||||
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
|
|
||||||
<p>You may now access your dashboard and begin using the available services.</p>
|
|
||||||
<p>We look forward to supporting you.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
|
|
||||||
PASSWORD_CHANGED: () => ({
|
|
||||||
subject: "Password Update Confirmation - STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear User,</p>
|
|
||||||
<p>This is to confirm that your account password has been successfully changed.</p>
|
|
||||||
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
|
|
||||||
<p>For your security, we recommend using a strong and unique password.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
|
|
||||||
ADDED_TO_GROUP: ({ groupName }) => ({
|
|
||||||
subject: "Group Assignment Notification - STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear User,</p>
|
|
||||||
<p>You have been assigned to the group <strong>${groupName}</strong> in the STARR System.</p>
|
|
||||||
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
|
|
||||||
<p>Please log in to your account to view group details.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
|
|
||||||
TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({
|
|
||||||
subject: "New Task Assignment - STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear User,</p>
|
|
||||||
<p>You have been assigned a new task in the STARR System.</p>
|
|
||||||
<table style="${FONT}">
|
|
||||||
<tr><td><strong>Task</strong></td><td>${taskTitle}</td></tr>
|
|
||||||
<tr><td><strong>Due Date</strong></td><td>${dueDate}</td></tr>
|
|
||||||
</table>
|
|
||||||
<p>Kindly ensure completion within the specified timeframe.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
|
|
||||||
BAN_LIFTED: ({ name, email, date }) => ({
|
|
||||||
subject: "Account Suspension Lifted - STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear ${name},</p>
|
|
||||||
<p>We are writing to inform you that the suspension on your account (<strong>${email}</strong>) has been lifted effective <strong>${date}</strong>.</p>
|
|
||||||
<p>You may now log in and resume access to all services within the STARR System.</p>
|
|
||||||
<p>If you have any concerns, please do not hesitate to contact your administrator.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
|
|
||||||
BANNED: ({ name, email, date, reason, ban_type }) => ({
|
|
||||||
subject: "Account Suspension Notice - STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear ${name},</p>
|
|
||||||
<p>Your account (<strong>${email}</strong>) has been ${ban_type === 'permanent' ? 'permanently' : 'temporarily'} suspended from the STARR System effective <strong>${date}</strong>.</p>
|
|
||||||
<table style="${FONT}">
|
|
||||||
<tr><td><strong>Reason</strong></td><td>${reason}</td></tr>
|
|
||||||
<tr><td><strong>Duration</strong></td><td>${ban_type === 'permanent' ? 'Permanent' : 'Temporary'}</td></tr>
|
|
||||||
</table>
|
|
||||||
<p>During this period, access to all system services has been revoked.${ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.'}</p>
|
|
||||||
<p>If you believe this was made in error, please contact your administrator.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
|
|
||||||
ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({
|
|
||||||
subject: "Your Staff Account Has Been Created - STARR System",
|
|
||||||
html: wrap(`
|
|
||||||
<p>Dear ${name},</p>
|
|
||||||
<p>Your staff account has been successfully created in the STARR System. Below are your login credentials:</p>
|
|
||||||
<table style="${FONT}">
|
|
||||||
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
|
|
||||||
<tr><td><strong>Password</strong></td><td>${password}</td></tr>
|
|
||||||
</table>
|
|
||||||
<p>This temporary password is valid for <strong>${expiryHours} hours</strong>. You will be required to change it upon first login.</p>
|
|
||||||
<p>If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.</p>
|
|
||||||
<p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p>
|
|
||||||
`),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: email_template_enrichers.data.js
|
||||||
|
* Type of Program: Data / Registry
|
||||||
|
* Description: Admin-edited templates are plain HTML — no conditionals or
|
||||||
|
* expressions allowed. Any type that used to branch on data in
|
||||||
|
* JS (e.g. BANNED's permanent/temporary wording) gets that branch
|
||||||
|
* precomputed here into flat placeholder keys BEFORE substitution,
|
||||||
|
* so the stored HTML only ever needs straight {{key}} swaps.
|
||||||
|
* Adding a new derived placeholder for a type should only require
|
||||||
|
* adding/editing one entry here.
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
|
||||||
|
const ENRICHERS = {
|
||||||
|
OTP: (data) => ({ expiryMinutes: 10, ...data }),
|
||||||
|
|
||||||
|
BANNED: (data) => ({
|
||||||
|
...data,
|
||||||
|
duration_word: data.ban_type === 'permanent' ? 'permanently' : 'temporarily',
|
||||||
|
duration_label: data.ban_type === 'permanent' ? 'Permanent' : 'Temporary',
|
||||||
|
suspension_note: data.ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichEmailData = (type, data = {}) => (ENRICHERS[type] ? ENRICHERS[type](data) : data);
|
||||||
|
|
||||||
|
module.exports = { enrichEmailData };
|
||||||
+43
-13
@@ -20,9 +20,10 @@
|
|||||||
*
|
*
|
||||||
* Current types:
|
* Current types:
|
||||||
* Admin : task_overdue, user_registration, nogrp_user_registered
|
* Admin : task_overdue, user_registration, nogrp_user_registered
|
||||||
* User : user_task_overdue, achievement, course_unlocked,
|
* User : task_requirements_updated, user_task_overdue, achievement, course_unlocked,
|
||||||
* course_completed, certificate_issued, task_reminder, announcement,
|
* course_completed, certificate_issued, task_reminder, announcement,
|
||||||
* nogrp_welcome, tier_expired
|
* nogrp_welcome, tier_expired
|
||||||
|
* Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
|
||||||
*
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 19, 2026
|
* Date Created: Jun. 19, 2026
|
||||||
@@ -87,6 +88,20 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── Task ──────────────────────────────────────────────────────────────────
|
// ── Task ──────────────────────────────────────────────────────────────────
|
||||||
|
task_requirements_updated: {
|
||||||
|
type: 'task',
|
||||||
|
scope: 'user',
|
||||||
|
trigger: 'event',
|
||||||
|
build({ taskName, taskListId = null, groupId = null }) {
|
||||||
|
return {
|
||||||
|
type: 'task',
|
||||||
|
title: 'Task Updated',
|
||||||
|
message: `The requirements for "${taskName}" have been updated by your administrator.`,
|
||||||
|
data: { taskName, taskListId, groupId },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
user_task_overdue: {
|
user_task_overdue: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
@@ -106,12 +121,12 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'task',
|
type: 'task',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'cron',
|
trigger: 'cron',
|
||||||
build({ taskName, deadline }) {
|
build({ taskName, deadline, taskListId = null, groupId = null }) {
|
||||||
return {
|
return {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
title: 'Task Deadline Approaching',
|
title: 'Task Deadline Approaching',
|
||||||
message: `"${taskName}" is due on ${fmtDate(deadline)}.`,
|
message: `"${taskName}" is due on ${fmtDate(deadline)}.`,
|
||||||
data: { taskName, deadline },
|
data: { taskName, deadline, taskListId, groupId },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -136,12 +151,12 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'course',
|
type: 'course',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'event',
|
trigger: 'event',
|
||||||
build({ courseTitle }) {
|
build({ courseTitle, courseUuid = null }) {
|
||||||
return {
|
return {
|
||||||
type: 'course',
|
type: 'course',
|
||||||
title: 'New Course Available',
|
title: 'New Course Available',
|
||||||
message: `"${courseTitle}" has been added to your learning library.`,
|
message: `"${courseTitle}" has been added to your learning library.`,
|
||||||
data: { courseTitle },
|
data: { courseTitle, courseUuid },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -150,12 +165,12 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'course',
|
type: 'course',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'event',
|
trigger: 'event',
|
||||||
build({ courseTitle }) {
|
build({ courseTitle, courseUuid = null }) {
|
||||||
return {
|
return {
|
||||||
type: 'course',
|
type: 'course',
|
||||||
title: 'Course Completed',
|
title: 'Course Completed',
|
||||||
message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`,
|
message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`,
|
||||||
data: { courseTitle },
|
data: { courseTitle, courseUuid },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -179,7 +194,7 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'announcement',
|
type: 'announcement',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'event',
|
trigger: 'event',
|
||||||
build({ groupName, groupCode, accType }) {
|
build({ groupName, groupCode, accType, groupId = null }) {
|
||||||
const greeting = accType === 'admin'
|
const greeting = accType === 'admin'
|
||||||
? 'Welcome, Administrator!'
|
? 'Welcome, Administrator!'
|
||||||
: accType === 'staff'
|
: accType === 'staff'
|
||||||
@@ -189,7 +204,7 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'announcement',
|
type: 'announcement',
|
||||||
title: 'Welcome to Philproperties',
|
title: 'Welcome to Philproperties',
|
||||||
message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting,
|
message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting,
|
||||||
data: { groupName, groupCode, accType },
|
data: { groupName, groupCode, accType, groupId },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -214,12 +229,12 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'assessment',
|
type: 'assessment',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'event',
|
trigger: 'event',
|
||||||
build({ assessmentTitle, courseTitle }) {
|
build({ assessmentTitle, courseTitle, courseUuid = null }) {
|
||||||
return {
|
return {
|
||||||
type: 'assessment',
|
type: 'assessment',
|
||||||
title: 'Assessment Updated',
|
title: 'Assessment Updated',
|
||||||
message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`,
|
message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`,
|
||||||
data: { assessmentTitle, courseTitle },
|
data: { assessmentTitle, courseTitle, courseUuid },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -239,17 +254,32 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Broadcast (admin-composed, manual) ───────────────────────────────────
|
||||||
|
broadcast: {
|
||||||
|
type: 'announcement',
|
||||||
|
scope: 'both',
|
||||||
|
trigger: 'manual',
|
||||||
|
build({ title, message, targetType = null, targetId = null, groupId = null }) {
|
||||||
|
return {
|
||||||
|
type: 'announcement',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
data: { targetType, targetId, groupId },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// ── Tier ──────────────────────────────────────────────────────────────────
|
// ── Tier ──────────────────────────────────────────────────────────────────
|
||||||
tier_expired: {
|
tier_expired: {
|
||||||
type: 'tier_expired',
|
type: 'tier_expired',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'cron',
|
trigger: 'cron',
|
||||||
build({ tier, label }) {
|
build({ tier, label, planId = null }) {
|
||||||
return {
|
return {
|
||||||
type: 'tier_expired',
|
type: 'tier_expired',
|
||||||
title: 'Subscription Expired',
|
title: 'Subscription Expired',
|
||||||
message: `Your ${label ?? tier} plan has expired. Renew to keep access.`,
|
message: `Your ${label ?? tier} plan has expired. Renew to keep access.`,
|
||||||
data: { tier, label },
|
data: { tier, label, planId },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
await queryInterface.addColumn('users', 'preferred_currency', {
|
|
||||||
type: Sequelize.CHAR(3),
|
|
||||||
allowNull: false,
|
|
||||||
defaultValue: 'USD',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
await queryInterface.removeColumn('users', 'preferred_currency');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
async up(queryInterface, Sequelize) {
|
|
||||||
await queryInterface.createTable('plan_prices', {
|
|
||||||
price_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
|
||||||
plan_id: {
|
|
||||||
type: Sequelize.BIGINT,
|
|
||||||
allowNull: false,
|
|
||||||
references: { model: 'tier_plans', key: 'plan_id' },
|
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
onDelete: 'CASCADE',
|
|
||||||
},
|
|
||||||
currency: { type: Sequelize.CHAR(3), allowNull: false },
|
|
||||||
price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
|
|
||||||
createdAt: { type: Sequelize.DATE, allowNull: false },
|
|
||||||
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.addConstraint('plan_prices', {
|
|
||||||
fields: ['plan_id', 'currency'],
|
|
||||||
type: 'unique',
|
|
||||||
name: 'uq_plan_prices_plan_currency',
|
|
||||||
});
|
|
||||||
|
|
||||||
await queryInterface.addIndex('plan_prices', ['plan_id'], {
|
|
||||||
name: 'idx_plan_prices_plan_id',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async down(queryInterface) {
|
|
||||||
await queryInterface.dropTable('plan_prices');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('notification_broadcasts', {
|
||||||
|
broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
|
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
|
||||||
|
title: { type: Sequelize.STRING(255), allowNull: false },
|
||||||
|
message: { type: Sequelize.TEXT, allowNull: false },
|
||||||
|
audience: { type: Sequelize.ENUM('admin', 'user', 'both'), allowNull: false },
|
||||||
|
status: { type: Sequelize.ENUM('draft', 'sent', 'archived'), allowNull: false, defaultValue: 'draft' },
|
||||||
|
sent_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
|
recipient_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
|
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
|
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
deletedAt: { type: Sequelize.DATE, allowNull: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('notification_broadcasts', ['uuid']);
|
||||||
|
await queryInterface.addIndex('notification_broadcasts', ['status']);
|
||||||
|
await queryInterface.addIndex('notification_broadcasts', ['audience']);
|
||||||
|
await queryInterface.addIndex('notification_broadcasts', ['deletedAt']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable('notification_broadcasts');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// CockroachDB: add enum values in place, then rename the column.
|
||||||
|
// Type is named 'notification_broadcast_audience' (matches the explicit CREATE TYPE
|
||||||
|
// used when this table was first created — not Sequelize's default enum_<table>_<col> name).
|
||||||
|
await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'task_list'`);
|
||||||
|
await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'course'`);
|
||||||
|
await queryInterface.sequelize.query(`ALTER TYPE public.notification_broadcast_audience ADD VALUE IF NOT EXISTS 'tier_plan'`);
|
||||||
|
|
||||||
|
await queryInterface.renameColumn('notification_broadcasts', 'audience', 'target_type');
|
||||||
|
|
||||||
|
await queryInterface.addColumn('notification_broadcasts', 'target_id', {
|
||||||
|
type: Sequelize.STRING(64),
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.removeIndex('notification_broadcasts', ['audience']).catch(() => {});
|
||||||
|
await queryInterface.addIndex('notification_broadcasts', ['target_type']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('notification_broadcasts', 'target_id');
|
||||||
|
await queryInterface.renameColumn('notification_broadcasts', 'target_type', 'audience');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('cron_notification_settings', {
|
||||||
|
job_name: { type: Sequelize.STRING(64), primaryKey: true },
|
||||||
|
enabled: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
|
||||||
|
schedule: { type: Sequelize.STRING(20), allowNull: false },
|
||||||
|
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable('cron_notification_settings');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('advertisements', 'placement', {
|
||||||
|
type: Sequelize.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('advertisements', ['placement']);
|
||||||
|
|
||||||
|
// Backfill the only two placements that were actually wired up before this
|
||||||
|
// migration (Dashboard hero + popup). Any existing banner/sidebar rows are
|
||||||
|
// left with placement = NULL — dev data, an admin reassigns them via the
|
||||||
|
// edit form once the new Page/Position picker ships.
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE advertisements SET placement = 'dashboard.hero' WHERE type = 'hero' AND placement IS NULL
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE advertisements SET placement = 'dashboard.popup' WHERE type = 'popup' AND placement IS NULL
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeIndex('advertisements', ['placement']).catch(() => {});
|
||||||
|
await queryInterface.removeColumn('advertisements', 'placement');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('achievement_definitions', {
|
||||||
|
achievement_definition_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
|
key: { type: Sequelize.STRING(100), allowNull: false, unique: true },
|
||||||
|
type: { type: Sequelize.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge' },
|
||||||
|
label: { type: Sequelize.STRING(255), allowNull: false },
|
||||||
|
description: { type: Sequelize.TEXT, allowNull: true },
|
||||||
|
icon: { type: Sequelize.STRING(50), allowNull: true },
|
||||||
|
trigger: { type: Sequelize.STRING(30), allowNull: true },
|
||||||
|
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
|
||||||
|
is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the 9 built-in keys that services/achievements.service.js's trigger
|
||||||
|
// functions reference by name — these are marked is_system so they can't be
|
||||||
|
// deleted/renamed from the admin CRUD.
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
INSERT INTO achievement_definitions (key, type, label, description, icon, trigger, is_active, is_system, "createdAt", "updatedAt")
|
||||||
|
VALUES
|
||||||
|
('early_access', 'badge', 'Early Access', 'Registered during the Philproperties beta period (before Dec 31, 2026).', 'Star', 'auth', true, true, NOW(), NOW()),
|
||||||
|
('premium_first_time', 'badge', 'Premium Member', 'Purchased a Premium tier plan for the first time.', 'BadgeCheck', 'tier', true, true, NOW(), NOW()),
|
||||||
|
('exclusive_first_time', 'badge', 'Exclusive Member', 'Purchased an Exclusive tier plan for the first time.', 'Medal', 'tier', true, true, NOW(), NOW()),
|
||||||
|
('first_course_completed', 'milestone', 'First Course Completed', 'Completed your very first course on Philproperties.', 'BookOpen', 'course', true, true, NOW(), NOW()),
|
||||||
|
('courses_completed_5', 'milestone', 'Learning Streak', 'Completed 5 courses.', 'Flame', 'course', true, true, NOW(), NOW()),
|
||||||
|
('courses_completed_10', 'milestone', 'Knowledge Builder', 'Completed 10 courses.', 'Zap', 'course', true, true, NOW(), NOW()),
|
||||||
|
('perfect_quiz_score', 'milestone', 'Perfect Score', 'Achieved a perfect score on a quiz.', 'Target', 'course', true, true, NOW(), NOW()),
|
||||||
|
('profile_completed', 'milestone', 'Profile Complete', 'Filled out all personal profile information.', 'Shield', 'profile', true, true, NOW(), NOW()),
|
||||||
|
('first_referral', 'milestone', 'Referral Champion', 'Successfully referred a user to Philproperties.', 'Award', 'social', true, true, NOW(), NOW())
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable('achievement_definitions');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('achievements', 'icon', {
|
||||||
|
type: Sequelize.STRING(50),
|
||||||
|
allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('achievements', 'icon');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('email_templates', {
|
||||||
|
email_template_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
|
type: { type: Sequelize.STRING(100), allowNull: false, unique: true },
|
||||||
|
label: { type: Sequelize.STRING(150), allowNull: false },
|
||||||
|
subject: { type: Sequelize.STRING(255), allowNull: false },
|
||||||
|
html_body: { type: Sequelize.TEXT, allowNull: false },
|
||||||
|
is_system: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Seed the 8 built-in types that services/email.service.js's sendEmail()
|
||||||
|
// looks up by name — these are marked is_system so they can't be
|
||||||
|
// deleted/renamed from the admin CRUD. Body content only (no <html>/<body>
|
||||||
|
// wrapper) — the header/footer layout stays fixed in code, not admin-editable.
|
||||||
|
await queryInterface.bulkInsert('email_templates', [
|
||||||
|
{
|
||||||
|
type: 'OTP', label: 'OTP Verification', is_system: true,
|
||||||
|
subject: 'Email OTP Verification - STARR System',
|
||||||
|
html_body: `<p>Dear User,</p>
|
||||||
|
<p>Please use the One-Time Password (OTP) below to verify your email address. This code is valid for <strong>{{expiryMinutes}} minutes</strong>.</p>
|
||||||
|
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">{{otp}}</p>
|
||||||
|
<p>For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'WELCOME', label: 'Welcome Email', is_system: true,
|
||||||
|
subject: 'Welcome to STARR System',
|
||||||
|
html_body: `<p>Dear {{name}},</p>
|
||||||
|
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
|
||||||
|
<p>You may now access your dashboard and begin using the available services.</p>
|
||||||
|
<p>We look forward to supporting you.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'PASSWORD_CHANGED', label: 'Password Changed', is_system: true,
|
||||||
|
subject: 'Password Update Confirmation - STARR System',
|
||||||
|
html_body: `<p>Dear User,</p>
|
||||||
|
<p>This is to confirm that your account password has been successfully changed.</p>
|
||||||
|
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
|
||||||
|
<p>For your security, we recommend using a strong and unique password.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ADDED_TO_GROUP', label: 'Added to Group', is_system: true,
|
||||||
|
subject: 'Group Assignment Notification - STARR System',
|
||||||
|
html_body: `<p>Dear User,</p>
|
||||||
|
<p>You have been assigned to the group <strong>{{groupName}}</strong> in the STARR System.</p>
|
||||||
|
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
|
||||||
|
<p>Please log in to your account to view group details.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TASK_ASSIGNED', label: 'Task Assigned', is_system: true,
|
||||||
|
subject: 'New Task Assignment - STARR System',
|
||||||
|
html_body: `<p>Dear User,</p>
|
||||||
|
<p>You have been assigned a new task in the STARR System.</p>
|
||||||
|
<table style="${FONT}">
|
||||||
|
<tr><td><strong>Task</strong></td><td>{{taskTitle}}</td></tr>
|
||||||
|
<tr><td><strong>Due Date</strong></td><td>{{dueDate}}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>Kindly ensure completion within the specified timeframe.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BAN_LIFTED', label: 'Ban Lifted', is_system: true,
|
||||||
|
subject: 'Account Suspension Lifted - STARR System',
|
||||||
|
html_body: `<p>Dear {{name}},</p>
|
||||||
|
<p>We are writing to inform you that the suspension on your account (<strong>{{email}}</strong>) has been lifted effective <strong>{{date}}</strong>.</p>
|
||||||
|
<p>You may now log in and resume access to all services within the STARR System.</p>
|
||||||
|
<p>If you have any concerns, please do not hesitate to contact your administrator.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BANNED', label: 'Account Banned', is_system: true,
|
||||||
|
subject: 'Account Suspension Notice - STARR System',
|
||||||
|
html_body: `<p>Dear {{name}},</p>
|
||||||
|
<p>Your account (<strong>{{email}}</strong>) has been {{duration_word}} suspended from the STARR System effective <strong>{{date}}</strong>.</p>
|
||||||
|
<table style="${FONT}">
|
||||||
|
<tr><td><strong>Reason</strong></td><td>{{reason}}</td></tr>
|
||||||
|
<tr><td><strong>Duration</strong></td><td>{{duration_label}}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>During this period, access to all system services has been revoked.{{suspension_note}}</p>
|
||||||
|
<p>If you believe this was made in error, please contact your administrator.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ADD_STAFF', label: 'Staff Account Created', is_system: true,
|
||||||
|
subject: 'Your Staff Account Has Been Created - STARR System',
|
||||||
|
html_body: `<p>Dear {{name}},</p>
|
||||||
|
<p>Your staff account has been successfully created in the STARR System. Below are your login credentials:</p>
|
||||||
|
<table style="${FONT}">
|
||||||
|
<tr><td><strong>Email</strong></td><td>{{email}}</td></tr>
|
||||||
|
<tr><td><strong>Password</strong></td><td>{{password}}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>This temporary password is valid for <strong>{{expiryHours}} hours</strong>. You will be required to change it upon first login.</p>
|
||||||
|
<p>If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.</p>
|
||||||
|
<p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p>`,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable('email_templates');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('email_templates', 'category', {
|
||||||
|
type: Sequelize.ENUM('announcement', 'advertisement', 'system', 'other'),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'other',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The 8 built-in (is_system) templates are all account/auth notifications.
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE email_templates SET category = 'system' WHERE is_system = true;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('email_templates', 'category');
|
||||||
|
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_templates_category";`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Live `subject`/`html_body` become nullable — a brand-new template can
|
||||||
|
// now exist as a pure draft with no live content at all until it's sent.
|
||||||
|
await queryInterface.changeColumn('email_templates', 'subject', {
|
||||||
|
type: Sequelize.STRING(255), allowNull: true,
|
||||||
|
});
|
||||||
|
await queryInterface.changeColumn('email_templates', 'html_body', {
|
||||||
|
type: Sequelize.TEXT, allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addColumn('email_templates', 'status', {
|
||||||
|
type: Sequelize.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edits to a 'sent' template land here first — sendEmail() only ever reads
|
||||||
|
// the live subject/html_body columns, never these — until an admin
|
||||||
|
// explicitly re-sends (publishes), the pending edit can't affect real mail.
|
||||||
|
await queryInterface.addColumn('email_templates', 'draft_subject', {
|
||||||
|
type: Sequelize.STRING(255), allowNull: true,
|
||||||
|
});
|
||||||
|
await queryInterface.addColumn('email_templates', 'draft_html_body', {
|
||||||
|
type: Sequelize.TEXT, allowNull: true,
|
||||||
|
});
|
||||||
|
await queryInterface.addColumn('email_templates', 'last_sent_at', {
|
||||||
|
type: Sequelize.DATE, allowNull: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every row created before this migration already had required subject/
|
||||||
|
// html_body — meaning it was already "operating" in the old single-state
|
||||||
|
// world. Backfill them all as sent.
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE email_templates SET status = 'sent', last_sent_at = "updatedAt";
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('email_templates', 'last_sent_at');
|
||||||
|
await queryInterface.removeColumn('email_templates', 'draft_html_body');
|
||||||
|
await queryInterface.removeColumn('email_templates', 'draft_subject');
|
||||||
|
await queryInterface.removeColumn('email_templates', 'status');
|
||||||
|
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_templates_status";`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.createTable('email_broadcasts', {
|
||||||
|
email_broadcast_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
|
email_template_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'email_templates', key: 'email_template_id' } },
|
||||||
|
target_type: { type: Sequelize.ENUM('admin', 'user', 'both', 'task_list', 'course', 'tier_plan'), allowNull: false },
|
||||||
|
target_id: { type: Sequelize.STRING(64), allowNull: true },
|
||||||
|
status: { type: Sequelize.ENUM('queued', 'sending', 'completed', 'canceled'), allowNull: false, defaultValue: 'queued' },
|
||||||
|
total_recipients: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
sent_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
failed_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
started_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
|
completed_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
|
createdBy: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryInterface.addIndex('email_broadcasts', ['status']);
|
||||||
|
|
||||||
|
await queryInterface.createTable('email_broadcast_recipients', {
|
||||||
|
email_broadcast_recipient_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||||
|
email_broadcast_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'email_broadcasts', key: 'email_broadcast_id' } },
|
||||||
|
user_id: { type: Sequelize.BIGINT, allowNull: true },
|
||||||
|
email: { type: Sequelize.STRING(255), allowNull: false },
|
||||||
|
name: { type: Sequelize.STRING(255), allowNull: true },
|
||||||
|
status: { type: Sequelize.ENUM('pending', 'sent', 'failed'), allowNull: false, defaultValue: 'pending' },
|
||||||
|
error: { type: Sequelize.TEXT, allowNull: true },
|
||||||
|
sent_at: { type: Sequelize.DATE, allowNull: true },
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The cron's core query: "give me the next pending batch for a broadcast".
|
||||||
|
await queryInterface.addIndex('email_broadcast_recipients', ['email_broadcast_id', 'status']);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.dropTable('email_broadcast_recipients');
|
||||||
|
await queryInterface.dropTable('email_broadcasts');
|
||||||
|
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcast_recipients_status";`);
|
||||||
|
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcasts_status";`);
|
||||||
|
await queryInterface.sequelize.query(`DROP TYPE IF EXISTS "enum_email_broadcasts_target_type";`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// The admin editor now authors in Markdown (converted client-side to the
|
||||||
|
// HTML that subject/html_body already require) — these columns retain the
|
||||||
|
// original Markdown purely so reopening a template for editing shows the
|
||||||
|
// human-readable source again instead of the compiled HTML. Templates
|
||||||
|
// created before this migration (all 8 system templates included) have
|
||||||
|
// no Markdown source — html_body/draft_html_body remain hand-written HTML
|
||||||
|
// for them, and the editor falls back to editing that directly.
|
||||||
|
await queryInterface.addColumn('email_templates', 'body_markdown', {
|
||||||
|
type: Sequelize.TEXT, allowNull: true,
|
||||||
|
});
|
||||||
|
await queryInterface.addColumn('email_templates', 'draft_body_markdown', {
|
||||||
|
type: Sequelize.TEXT, allowNull: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.removeColumn('email_templates', 'draft_body_markdown');
|
||||||
|
await queryInterface.removeColumn('email_templates', 'body_markdown');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,21 +11,31 @@ const Advertisement = sequelize.define("Advertisement", {
|
|||||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
|
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
|
||||||
|
|
||||||
// ─── Identification / placement ──────────────────────────────────────────
|
// ─── Identification / placement ──────────────────────────────────────────
|
||||||
|
// placement is the source of truth for "where" (a registry key, see
|
||||||
|
// advertisements.placements.js); type is denormalized from it on every
|
||||||
|
// write (applyAdvertisementFields) and describes "what it looks like".
|
||||||
|
placement: {
|
||||||
|
type: DataTypes.STRING(100),
|
||||||
|
allowNull: true,
|
||||||
|
filterable: true,
|
||||||
|
label: "Placement", order: 1
|
||||||
|
},
|
||||||
type: {
|
type: {
|
||||||
type: DataTypes.ENUM("hero", "banner", "popup", "sidebar"),
|
type: DataTypes.ENUM("hero", "banner", "popup", "sidebar"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
label: "Type", order: 1
|
filterable: true,
|
||||||
|
label: "Format", order: 2
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"),
|
type: DataTypes.ENUM("draft", "active", "scheduled", "expired", "archived"),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: "draft", label: "Status", order: 2
|
defaultValue: "draft", label: "Status", order: 3
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Content ──────────────────────────────────────────────────────────────
|
// ─── Content ──────────────────────────────────────────────────────────────
|
||||||
badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 3 },
|
badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 },
|
||||||
headline: { type: DataTypes.STRING(255), label: "Headline", order: 4 },
|
headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 },
|
||||||
description: { type: DataTypes.TEXT, label: "Description", order: 5 },
|
description: { type: DataTypes.TEXT, label: "Description", order: 6 },
|
||||||
|
|
||||||
// ─── Media ────────────────────────────────────────────────────────────────
|
// ─── Media ────────────────────────────────────────────────────────────────
|
||||||
image_url: { type: DataTypes.STRING(512), label: "Image URL", order: 0, hidden: true },
|
image_url: { type: DataTypes.STRING(512), label: "Image URL", order: 0, hidden: true },
|
||||||
@@ -33,19 +43,19 @@ const Advertisement = sequelize.define("Advertisement", {
|
|||||||
|
|
||||||
// ─── Calls-to-action ──────────────────────────────────────────────────────
|
// ─── Calls-to-action ──────────────────────────────────────────────────────
|
||||||
// [{ label, link }, ...] — 0-2 entries depending on type
|
// [{ label, link }, ...] — 0-2 entries depending on type
|
||||||
ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 6 },
|
ctas: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "CTAs", order: 7 },
|
||||||
|
|
||||||
// ─── Scheduling ───────────────────────────────────────────────────────────
|
// ─── Scheduling ───────────────────────────────────────────────────────────
|
||||||
start_date: { type: DataTypes.DATE, label: "Start Date", order: 7 },
|
start_date: { type: DataTypes.DATE, label: "Start Date", order: 8 },
|
||||||
end_date: { type: DataTypes.DATE, label: "End Date", order: 8 },
|
end_date: { type: DataTypes.DATE, label: "End Date", order: 9 },
|
||||||
|
|
||||||
// ─── Display behavior ─────────────────────────────────────────────────────
|
// ─── Display behavior ─────────────────────────────────────────────────────
|
||||||
order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 9 },
|
order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, field: "order", label: "Order", order: 10 },
|
||||||
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 10 },
|
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Active", order: 11 },
|
||||||
size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 11 }, // banner-only, ignored by other types
|
size: { type: DataTypes.ENUM("sm", "md", "lg"), allowNull: true, label: "Size", order: 12 }, // banner-only, ignored by other types
|
||||||
|
|
||||||
// ─── Metrics ──────────────────────────────────────────────────────────────
|
// ─── Metrics ──────────────────────────────────────────────────────────────
|
||||||
click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 12, hidden: true },
|
click_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Clicks", order: 13, hidden: true },
|
||||||
|
|
||||||
// ─── Audit trails ─────────────────────────────────────────────────────────
|
// ─── Audit trails ─────────────────────────────────────────────────────────
|
||||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||||
@@ -58,6 +68,7 @@ const Advertisement = sequelize.define("Advertisement", {
|
|||||||
indexes: [
|
indexes: [
|
||||||
{ fields: ["uuid"] },
|
{ fields: ["uuid"] },
|
||||||
{ fields: ["type"] },
|
{ fields: ["type"] },
|
||||||
|
{ fields: ["placement"] },
|
||||||
{ fields: ["status"] },
|
{ fields: ["status"] },
|
||||||
{ fields: ["is_active"] },
|
{ fields: ["is_active"] },
|
||||||
{ fields: ["deletedAt"] },
|
{ fields: ["deletedAt"] },
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// models/advertisements/advertisements.placements.js
|
||||||
|
//
|
||||||
|
// Declarative registry of every ad placement in the client app. Each entry
|
||||||
|
// is a self-contained "slot" — a page + position pair — that determines the
|
||||||
|
// visual format (hero/banner/popup/sidebar) automatically. Adding a new
|
||||||
|
// placement should only ever require adding one entry here (and wiring the
|
||||||
|
// corresponding client page to fetch/render it) — nothing else in this file
|
||||||
|
// should need to change.
|
||||||
|
//
|
||||||
|
// `key` is what's stored on advertisements.placement. `format` is what gets
|
||||||
|
// denormalized onto advertisements.type on write (see applyAdvertisementFields
|
||||||
|
// in controllers/admin/advertisements.controller.js) — type is never accepted
|
||||||
|
// from the client once a placement is set.
|
||||||
|
|
||||||
|
const PLACEMENTS = [
|
||||||
|
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
|
||||||
|
{ key: "dashboard.popup", format: "popup", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Popup (on load)" },
|
||||||
|
{ key: "course_list.banner", format: "banner", page: "course_list", pageLabel: "Courses", slotLabel: "Banner (above course grid)" },
|
||||||
|
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
|
||||||
|
{ key: "course_details.sidebar", format: "sidebar", page: "course_details", pageLabel: "Course Details", slotLabel: "Sidebar (beside course content)" },
|
||||||
|
{ key: "plans.banner", format: "banner", page: "plans", pageLabel: "Plans", slotLabel: "Banner (above plan cards)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLACEMENT_MAP = Object.fromEntries(PLACEMENTS.map((p) => [p.key, p]));
|
||||||
|
const PLACEMENT_KEYS = PLACEMENTS.map((p) => p.key);
|
||||||
|
|
||||||
|
function getFormatForPlacement(key) {
|
||||||
|
return PLACEMENT_MAP[key]?.format ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PLACEMENTS, PLACEMENT_MAP, PLACEMENT_KEYS, getFormatForPlacement };
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
/***********************************************************************************************************************************************************************
|
||||||
* File Name: pending_certificate.mdl.js
|
* File Name: pending_certificate.mdl.js
|
||||||
* Type of Program: Model
|
* Type of Program: Model
|
||||||
* Description: Holds certificates queued for issuance after a 45-minute delay
|
* Description: Holds certificates queued for issuance after a 5-minute delay
|
||||||
* following a passed course assessment. The cron job
|
* following a passed course assessment. The cron job
|
||||||
* (cron/jobs/issue_certificates.cron.js) polls this table every
|
* (cron/jobs/issue_certificates.cron.js) runs hourly on the hour
|
||||||
* 5 minutes and processes rows where issue_at <= NOW().
|
* and processes rows where issue_at <= NOW().
|
||||||
*
|
*
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Jun. 24, 2026
|
* Date Created: Jun. 24, 2026
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: email_broadcast.mdl.js
|
||||||
|
* Type of Program: Model
|
||||||
|
* Description: One "send this email template to this audience" job. The API
|
||||||
|
* only ever creates this row + its email_broadcast_recipients
|
||||||
|
* rows (see controllers/admin/email_broadcasts.controller.js) —
|
||||||
|
* actual SMTP sending happens later, paced, in
|
||||||
|
* cron/jobs/dispatch_email_broadcasts.cron.js.
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../../config/db.config');
|
||||||
|
const mdl_EmailTemplate = require('./email_templates.mdl');
|
||||||
|
const mdl_Users = require('../users/users.mdl');
|
||||||
|
|
||||||
|
const mdl_EmailBroadcast = sequelize.define('EmailBroadcast', {
|
||||||
|
email_broadcast_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
|
||||||
|
email_template_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Template', order: 1 },
|
||||||
|
target_type: { type: DataTypes.ENUM('admin', 'user', 'both', 'task_list', 'course', 'tier_plan'), allowNull: false, label: 'Target', order: 2, filterable: true },
|
||||||
|
target_id: { type: DataTypes.STRING(64), allowNull: true, label: 'Target ID', order: 3 },
|
||||||
|
status: { type: DataTypes.ENUM('queued', 'sending', 'completed', 'canceled'), allowNull: false, defaultValue: 'queued', label: 'Status', order: 4, filterable: true },
|
||||||
|
total_recipients: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Total', order: 5 },
|
||||||
|
sent_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Sent', order: 6 },
|
||||||
|
failed_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Failed', order: 7 },
|
||||||
|
started_at: { type: DataTypes.DATE, allowNull: true, label: 'Started', order: 8 },
|
||||||
|
completed_at: { type: DataTypes.DATE, allowNull: true, label: 'Completed', order: 9 },
|
||||||
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' },
|
||||||
|
}, {
|
||||||
|
tableName: 'email_broadcasts',
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mdl_EmailBroadcast.belongsTo(mdl_EmailTemplate, { as: 'template', foreignKey: 'email_template_id' });
|
||||||
|
mdl_EmailBroadcast.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
|
||||||
|
|
||||||
|
module.exports = mdl_EmailBroadcast;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: email_broadcast_recipient.mdl.js
|
||||||
|
* Type of Program: Model
|
||||||
|
* Description: The outbox — one row per recipient of an email_broadcasts job.
|
||||||
|
* `email` is snapshotted at enqueue time so a later change to the
|
||||||
|
* user's account email doesn't affect an in-flight broadcast.
|
||||||
|
* cron/jobs/dispatch_email_broadcasts.cron.js is the only writer
|
||||||
|
* of `status`/`error`/`sent_at` after creation.
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../../config/db.config');
|
||||||
|
const mdl_EmailBroadcast = require('./email_broadcast.mdl');
|
||||||
|
|
||||||
|
const mdl_EmailBroadcastRecipient = sequelize.define('EmailBroadcastRecipient', {
|
||||||
|
email_broadcast_recipient_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
|
||||||
|
email_broadcast_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Broadcast', order: 1 },
|
||||||
|
user_id: { type: DataTypes.BIGINT, allowNull: true, label: 'User', order: 2 },
|
||||||
|
email: { type: DataTypes.STRING(255), allowNull: false, label: 'Email', order: 3 },
|
||||||
|
name: { type: DataTypes.STRING(255), allowNull: true, label: 'Name', order: 4 },
|
||||||
|
status: { type: DataTypes.ENUM('pending', 'sent', 'failed'), allowNull: false, defaultValue: 'pending', label: 'Status', order: 5, filterable: true },
|
||||||
|
error: { type: DataTypes.TEXT, allowNull: true, label: 'Error', order: 6 },
|
||||||
|
sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Sent At', order: 7 },
|
||||||
|
}, {
|
||||||
|
tableName: 'email_broadcast_recipients',
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mdl_EmailBroadcastRecipient.belongsTo(mdl_EmailBroadcast, { as: 'broadcast', foreignKey: 'email_broadcast_id' });
|
||||||
|
|
||||||
|
module.exports = mdl_EmailBroadcastRecipient;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: email_templates.mdl.js
|
||||||
|
* Type of Program: Model
|
||||||
|
* Description: Admin-managed catalog of email templates (subject + HTML body).
|
||||||
|
* Replaces the old static emailTemplates map in data/email_body.data.js.
|
||||||
|
* `is_system` rows are the built-in types referenced by name in
|
||||||
|
* services/email.service.js's sendEmail() callers — protected
|
||||||
|
* from deletion/type-rename by the admin controller. The outer
|
||||||
|
* layout (header/footer/signature) is NOT stored here — it stays
|
||||||
|
* fixed in services/email.service.js and is never admin-editable.
|
||||||
|
*
|
||||||
|
* Publish workflow: `subject`/`html_body` are the LIVE content —
|
||||||
|
* the only columns services/email.service.js's sendEmail() ever
|
||||||
|
* reads. Editing a 'sent' template writes to `draft_subject`/
|
||||||
|
* `draft_html_body` instead, leaving live content (and therefore
|
||||||
|
* real outgoing mail) untouched until an admin explicitly
|
||||||
|
* publishes again (see controllers/admin/email_templates.controller.js).
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../../config/db.config');
|
||||||
|
|
||||||
|
const mdl_EmailTemplate = sequelize.define('EmailTemplate', {
|
||||||
|
email_template_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
|
||||||
|
type: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Type', hidden: false, order: 1, filterable: true },
|
||||||
|
label: { type: DataTypes.STRING(150), allowNull: false, label: 'Label', hidden: false, order: 2, filterable: true },
|
||||||
|
category: { type: DataTypes.ENUM('announcement', 'advertisement', 'system', 'other'), allowNull: false, defaultValue: 'other', label: 'Category', hidden: false, order: 3, filterable: true },
|
||||||
|
status: { type: DataTypes.ENUM('draft', 'sent'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, order: 4, filterable: true },
|
||||||
|
subject: { type: DataTypes.STRING(255), allowNull: true, label: 'Subject', hidden: false, order: 5, filterable: false },
|
||||||
|
html_body: { type: DataTypes.TEXT, allowNull: true, label: 'HTML Body', hidden: false, order: 6, filterable: false },
|
||||||
|
// Markdown source for the live/draft HTML above — editor convenience only,
|
||||||
|
// never read by services/email.service.js. Null for templates authored
|
||||||
|
// before Markdown support (including all 8 system templates), which keep
|
||||||
|
// editing html_body/draft_html_body directly.
|
||||||
|
body_markdown: { type: DataTypes.TEXT, allowNull: true, label: 'Body (Markdown)', hidden: false, order: 6.5, filterable: false },
|
||||||
|
draft_subject: { type: DataTypes.STRING(255), allowNull: true, label: 'Draft Subject', hidden: false, order: 7, filterable: false },
|
||||||
|
draft_html_body: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Body', hidden: false, order: 8, filterable: false },
|
||||||
|
draft_body_markdown: { type: DataTypes.TEXT, allowNull: true, label: 'Draft Body (Markdown)', hidden: false, order: 8.5, filterable: false },
|
||||||
|
last_sent_at: { type: DataTypes.DATE, allowNull: true, label: 'Last Sent', hidden: false, order: 9, filterable: false },
|
||||||
|
is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 10, filterable: true },
|
||||||
|
}, {
|
||||||
|
tableName: 'email_templates',
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mdl_EmailTemplate;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// models/notifications/cron_notification_setting.mdl.js
|
||||||
|
const { DataTypes } = require("sequelize");
|
||||||
|
const sequelize = require("../../config/db.config");
|
||||||
|
|
||||||
|
const CronNotificationSetting = sequelize.define("CronNotificationSetting", {
|
||||||
|
job_name: { type: DataTypes.STRING(64), primaryKey: true, label: "Job" },
|
||||||
|
enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Enabled" },
|
||||||
|
schedule: { type: DataTypes.STRING(20), allowNull: false, label: "Schedule" },
|
||||||
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||||
|
}, {
|
||||||
|
tableName: "cron_notification_settings",
|
||||||
|
timestamps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = CronNotificationSetting;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// models/notifications/notification_broadcast.attributes.js
|
||||||
|
|
||||||
|
// ─── Exclude sets ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const excludeAttributes = [];
|
||||||
|
|
||||||
|
// Admins see everything
|
||||||
|
const adminExclude = [
|
||||||
|
...excludeAttributes,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── JSONB schemas ──────────────────────────────────────────────────────────
|
||||||
|
// No JSONB columns on this model.
|
||||||
|
const jsonbSchemas = {};
|
||||||
|
|
||||||
|
// ─── Computed attributes ──────────────────────────────────────────────────────
|
||||||
|
const computedAttributes = [];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
excludeAttributes,
|
||||||
|
adminExclude,
|
||||||
|
jsonbSchemas,
|
||||||
|
computedAttributes,
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// models/notifications/notification_broadcast.mdl.js
|
||||||
|
const { DataTypes } = require("sequelize");
|
||||||
|
const sequelize = require("../../config/db.config");
|
||||||
|
const mdl_Users = require("../users/users.mdl");
|
||||||
|
|
||||||
|
const NotificationBroadcast = sequelize.define("NotificationBroadcast", {
|
||||||
|
|
||||||
|
// ─── Identity ─────────────────────────────────────────────────────────────
|
||||||
|
broadcast_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "Broadcast ID", order: 0, hidden: true },
|
||||||
|
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, allowNull: false, unique: true, label: "UUID", order: 0, hidden: true },
|
||||||
|
|
||||||
|
// ─── Content ──────────────────────────────────────────────────────────────
|
||||||
|
title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 },
|
||||||
|
message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 },
|
||||||
|
|
||||||
|
// ─── Targeting ────────────────────────────────────────────────────────────
|
||||||
|
target_type: {
|
||||||
|
type: DataTypes.ENUM("admin", "user", "both", "task_list", "course", "tier_plan"),
|
||||||
|
allowNull: false,
|
||||||
|
label: "Target", order: 3
|
||||||
|
},
|
||||||
|
// Holds a task list UUID, course UUID, or tier plan ID (stringified) — only
|
||||||
|
// set when target_type is 'task_list' / 'course' / 'tier_plan'.
|
||||||
|
target_id: { type: DataTypes.STRING(64), allowNull: true, label: "Target ID", order: 3.5 },
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
status: {
|
||||||
|
type: DataTypes.ENUM("draft", "sent", "archived"),
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: "draft", label: "Status", order: 4
|
||||||
|
},
|
||||||
|
sent_at: { type: DataTypes.DATE, allowNull: true, label: "Sent At", order: 5 },
|
||||||
|
recipient_count: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: "Recipients", order: 6 },
|
||||||
|
|
||||||
|
// ─── Audit trails ─────────────────────────────────────────────────────────
|
||||||
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
|
||||||
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||||
|
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
|
||||||
|
}, {
|
||||||
|
tableName: "notification_broadcasts",
|
||||||
|
timestamps: true, // createdAt, updatedAt
|
||||||
|
paranoid: true,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ["uuid"] },
|
||||||
|
{ fields: ["status"] },
|
||||||
|
{ fields: ["target_type"] },
|
||||||
|
{ fields: ["deletedAt"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
NotificationBroadcast.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||||
|
NotificationBroadcast.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
||||||
|
|
||||||
|
module.exports = NotificationBroadcast;
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
const excludeAttributes = ['provider_payload'];
|
const excludeAttributes = ['provider_payload'];
|
||||||
const jsonbSchemas = {};
|
const jsonbSchemas = {};
|
||||||
const computedAttributes = [];
|
const computedAttributes = [
|
||||||
|
{ key: 'user_full_name', label: 'Full Name', type: 'text', order: 1, filterable: false, literal: `("user"."personal_info"->'name'->>'full_name')` },
|
||||||
|
{ key: 'user.email', label: 'Email Address', type: 'text', order: 2, filterable: false },
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = { excludeAttributes, jsonbSchemas, computedAttributes };
|
module.exports = { excludeAttributes, jsonbSchemas, computedAttributes };
|
||||||
@@ -25,13 +25,13 @@ const mdl_Payments = sequelize.define('Payment', {
|
|||||||
plan_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Plan ID', hidden: true, order: 2, filterable: true },
|
plan_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Plan ID', hidden: true, order: 2, filterable: true },
|
||||||
tier_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier ID', hidden: true, order: 3, filterable: true },
|
tier_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier ID', hidden: true, order: 3, filterable: true },
|
||||||
status: { type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending', label: 'Status', hidden: false, order: 4, filterable: true },
|
status: { type: DataTypes.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending', label: 'Status', hidden: false, order: 4, filterable: true },
|
||||||
amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Amount', hidden: false, order: 5, filterable: false },
|
amount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Amount', hidden: false, order: 3, filterable: false },
|
||||||
currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD', label: 'Currency', hidden: false, order: 6, filterable: true },
|
currency: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'USD', label: 'Currency', hidden: false, order: 6, filterable: true },
|
||||||
promo_code: { type: DataTypes.STRING(50), allowNull: true, label: 'Promo Code', hidden: false, order: 7, filterable: true },
|
promo_code: { type: DataTypes.STRING(50), allowNull: true, label: 'Promo Code', hidden: false, order: 7, filterable: true },
|
||||||
discount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00, label: 'Discount', hidden: false, order: 8, filterable: false },
|
discount: { type: DataTypes.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00, label: 'Discount', hidden: false, order: 8, filterable: false },
|
||||||
provider: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'paypal', label: 'Provider', hidden: false, order: 9, filterable: true },
|
provider: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'paypal', label: 'Provider', hidden: false, order: 9, filterable: true },
|
||||||
provider_payload: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Provider Payload', hidden: true, order: 10, filterable: false },
|
provider_payload: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Provider Payload', hidden: true, order: 10, filterable: false },
|
||||||
paid_at: { type: DataTypes.DATE, allowNull: true, label: 'Paid At', hidden: false, order: 11, filterable: false },
|
paid_at: { type: DataTypes.DATE, allowNull: true, label: 'Paid At', hidden: false, order: 5, filterable: false },
|
||||||
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' },
|
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Created By' },
|
||||||
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Updated By' },
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Updated By' },
|
||||||
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Deleted By' },
|
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: 'Deleted By' },
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
|
||||||
* File Name: plan_prices.mdl.js
|
|
||||||
* Type of Program: Model
|
|
||||||
* Description: Admin-managed localized price overrides for tier plans.
|
|
||||||
* One row per (plan_id, currency) pair. When a user's preferred_currency
|
|
||||||
* matches a row here, the override price is shown instead of the base price.
|
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
|
||||||
* Date Created: Jun. 29, 2026
|
|
||||||
***********************************************************************************************************************************************************************/
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { DataTypes } = require('sequelize');
|
|
||||||
const sequelize = require('../../config/db.config');
|
|
||||||
|
|
||||||
const mdl_PlanPrices = sequelize.define('PlanPrice', {
|
|
||||||
price_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
|
||||||
plan_id: { type: DataTypes.BIGINT, allowNull: false },
|
|
||||||
currency: { type: DataTypes.CHAR(3), allowNull: false, label: 'Currency' },
|
|
||||||
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
|
|
||||||
}, {
|
|
||||||
tableName: 'plan_prices',
|
|
||||||
timestamps: true,
|
|
||||||
paranoid: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = mdl_PlanPrices;
|
|
||||||
@@ -5,7 +5,6 @@ const mdl_UserTiers = require('./user_tiers.mdl');
|
|||||||
const mdl_Payments = require('./payments.mdl');
|
const mdl_Payments = require('./payments.mdl');
|
||||||
const mdl_PlanCourses = require('./plan_courses.mdl');
|
const mdl_PlanCourses = require('./plan_courses.mdl');
|
||||||
const mdl_PlanPolicies = require('./plan_policies.mdl');
|
const mdl_PlanPolicies = require('./plan_policies.mdl');
|
||||||
const mdl_PlanPrices = require('./plan_prices.mdl');
|
|
||||||
const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
|
const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
|
||||||
const Asset = require('../assets/assets.mdl');
|
const Asset = require('../assets/assets.mdl');
|
||||||
const { Course } = require('../courses/courses.mdl');
|
const { Course } = require('../courses/courses.mdl');
|
||||||
@@ -54,10 +53,6 @@ mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers'
|
|||||||
mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' });
|
mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' });
|
||||||
mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
|
mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
|
||||||
|
|
||||||
// ─── Plan ↔ Localized Prices ──────────────────────────────────────────────────
|
|
||||||
mdl_TierPlans.hasMany(mdl_PlanPrices, { foreignKey: 'plan_id', as: 'prices' });
|
|
||||||
mdl_PlanPrices.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
|
|
||||||
|
|
||||||
// ─── SystemBadge → Asset ─────────────────────────────────────────────────────
|
// ─── SystemBadge → Asset ─────────────────────────────────────────────────────
|
||||||
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
|
mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' });
|
||||||
|
|
||||||
@@ -68,6 +63,5 @@ module.exports = {
|
|||||||
mdl_Payments,
|
mdl_Payments,
|
||||||
mdl_PlanCourses,
|
mdl_PlanCourses,
|
||||||
mdl_PlanPolicies,
|
mdl_PlanPolicies,
|
||||||
mdl_PlanPrices,
|
|
||||||
mdl_SystemBadges,
|
mdl_SystemBadges,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: achievement_definitions.mdl.js
|
||||||
|
* Type of Program: Model
|
||||||
|
* Description: Admin-managed catalog of achievements (badges and milestones).
|
||||||
|
* Replaces the old static ACHIEVEMENT_REGISTRY in data/achievements.data.js.
|
||||||
|
* `is_system` rows are the built-in keys referenced by name in
|
||||||
|
* services/achievements.service.js's trigger functions — protected
|
||||||
|
* from deletion/key-rename by the admin controller.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../../config/db.config');
|
||||||
|
|
||||||
|
const mdl_AchievementDefinitions = sequelize.define('AchievementDefinition', {
|
||||||
|
achievement_definition_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'ID', hidden: true, order: 0 },
|
||||||
|
key: { type: DataTypes.STRING(100), allowNull: false, unique: true, label: 'Key', hidden: false, order: 1, filterable: true },
|
||||||
|
type: { type: DataTypes.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge', label: 'Type', hidden: false, order: 2, filterable: true },
|
||||||
|
label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true },
|
||||||
|
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false },
|
||||||
|
icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false },
|
||||||
|
trigger: { type: DataTypes.STRING(30), allowNull: true, label: 'Trigger', hidden: false, order: 6, filterable: true },
|
||||||
|
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active', hidden: false, order: 7, filterable: true },
|
||||||
|
is_system: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'System', hidden: false, order: 8, filterable: true },
|
||||||
|
}, {
|
||||||
|
tableName: 'achievement_definitions',
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mdl_AchievementDefinitions;
|
||||||
@@ -26,6 +26,7 @@ const mdl_Achievements = sequelize.define('Achievement', {
|
|||||||
key: { type: DataTypes.STRING(100), allowNull: false, label: 'Key', hidden: false, order: 2, filterable: true },
|
key: { type: DataTypes.STRING(100), allowNull: false, label: 'Key', hidden: false, order: 2, filterable: true },
|
||||||
label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true },
|
label: { type: DataTypes.STRING(255), allowNull: false, label: 'Label', hidden: false, order: 3, filterable: true },
|
||||||
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false },
|
description: { type: DataTypes.TEXT, allowNull: true, label: 'Description', hidden: false, order: 4, filterable: false },
|
||||||
|
icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Icon', hidden: false, order: 5, filterable: false },
|
||||||
granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By', hidden: false, order: 5, filterable: false },
|
granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By', hidden: false, order: 5, filterable: false },
|
||||||
granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At', hidden: false, order: 6, filterable: false },
|
granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At', hidden: false, order: 6, filterable: false },
|
||||||
metadata: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Metadata', hidden: true, order: 0 },
|
metadata: { type: DataTypes.JSONB, allowNull: true, defaultValue: {}, label: 'Metadata', hidden: true, order: 0 },
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ const mdl_Users = sequelize.define('User', {
|
|||||||
*/
|
*/
|
||||||
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 },
|
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 },
|
||||||
|
|
||||||
// ── Currency preference ──────────────────────────────────────────────────────
|
|
||||||
preferred_currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Preferred Currency' },
|
|
||||||
|
|
||||||
// ── Ban state ────────────────────────────────────────────────────────────────
|
// ── Ban state ────────────────────────────────────────────────────────────────
|
||||||
is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" },
|
is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" },
|
||||||
ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" },
|
ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" },
|
||||||
|
|||||||
Generated
+26
@@ -16,6 +16,7 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
|
"currency-codes": "^2.2.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^6.10.0",
|
"express-rate-limit": "^6.10.0",
|
||||||
@@ -3358,6 +3359,16 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/currency-codes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/currency-codes/-/currency-codes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-vpbQc5sEYHGdTVAYUhHnKv0DWiYLRvzl/KKyqeHzBh7HD/j3UlWoScpZ9tN/jG6w2feddWoObsBbaNVu5yDapg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"first-match": "~0.0.1",
|
||||||
|
"nub": "~0.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -3966,6 +3977,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/first-match": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/first-match/-/first-match-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-VvKbnaxrC0polTFDC+teKPTdl2mn6B/KUW+WB3C9RzKDeNwbzfLdnUz3FxC+tnjvus6bI0jWrWicQyVIPdS37A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fluent-ffmpeg": {
|
"node_modules/fluent-ffmpeg": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
|
||||||
@@ -6241,6 +6258,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nub": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nub/-/nub-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-dK0Ss9C34R/vV0FfYJXuqDAqHlaW9fvWVufq9MmGF2umCuDbd5GRfRD9fpi/LiM0l4ZXf8IBB+RYmZExqCrf0w==",
|
||||||
|
"license": "MIT/X11",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|||||||
+4
-1
@@ -23,6 +23,7 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csurf": "^1.11.0",
|
"csurf": "^1.11.0",
|
||||||
|
"currency-codes": "^2.2.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^6.10.0",
|
"express-rate-limit": "^6.10.0",
|
||||||
@@ -52,7 +53,9 @@
|
|||||||
"sequelize-cli": "^6.6.5"
|
"sequelize-cli": "^6.6.5"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testMatch": ["**/tests/**/*.test.js"],
|
"testMatch": [
|
||||||
|
"**/tests/**/*.test.js"
|
||||||
|
],
|
||||||
"forceExit": true
|
"forceExit": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const ctrl = require('../../controllers/admin/achievements.controller');
|
||||||
|
|
||||||
|
// Auth + requireAdmin applied by admin.routes.js
|
||||||
|
|
||||||
|
router.get ('/', ctrl.getAchievements);
|
||||||
|
router.post ('/', ctrl.createAchievement);
|
||||||
|
router.get ('/:id', ctrl.getAchievement);
|
||||||
|
router.put ('/:id', ctrl.updateAchievement);
|
||||||
|
router.delete('/:id', ctrl.deleteAchievement);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -37,7 +37,12 @@ const categoriesRoutes = require('./categories.routes');
|
|||||||
const productsRoutes = require('./products.routes');
|
const productsRoutes = require('./products.routes');
|
||||||
const advertisementRoutes = require('./advertisements.routes');
|
const advertisementRoutes = require('./advertisements.routes');
|
||||||
const notificationRoutes = require('./notifications.routes');
|
const notificationRoutes = require('./notifications.routes');
|
||||||
|
const notificationBroadcastRoutes = require('./notificationBroadcasts.routes');
|
||||||
|
const notificationSettingsRoutes = require('./notificationSettings.routes');
|
||||||
const mediaRoutes = require('./media.routes');
|
const mediaRoutes = require('./media.routes');
|
||||||
|
const achievementsRoutes = require('./achievements.routes');
|
||||||
|
const emailTemplatesRoutes = require('./email_templates.routes');
|
||||||
|
const emailBroadcastsRoutes = require('./email_broadcasts.routes');
|
||||||
const activityCtrl = require('../../controllers/admin/user_activity.controller');
|
const activityCtrl = require('../../controllers/admin/user_activity.controller');
|
||||||
|
|
||||||
// ── Guards — applied to ALL admin routes ──────────────────────────────────────
|
// ── Guards — applied to ALL admin routes ──────────────────────────────────────
|
||||||
@@ -58,7 +63,12 @@ router.use('/categories', categoriesRoutes);
|
|||||||
router.use('/products', productsRoutes);
|
router.use('/products', productsRoutes);
|
||||||
router.use('/advertisements', advertisementRoutes);
|
router.use('/advertisements', advertisementRoutes);
|
||||||
router.use('/notifications', notificationRoutes);
|
router.use('/notifications', notificationRoutes);
|
||||||
|
router.use('/notification-broadcasts', notificationBroadcastRoutes);
|
||||||
|
router.use('/notification-settings', notificationSettingsRoutes);
|
||||||
router.use('/media', mediaRoutes);
|
router.use('/media', mediaRoutes);
|
||||||
|
router.use('/achievements', achievementsRoutes);
|
||||||
|
router.use('/email-templates', emailTemplatesRoutes);
|
||||||
|
router.use('/email-broadcasts', emailBroadcastsRoutes);
|
||||||
|
|
||||||
// ── Activity feed — global ────────────────────────────────────────────────────
|
// ── Activity feed — global ────────────────────────────────────────────────────
|
||||||
router.get('/activity', activityCtrl.getActivity);
|
router.get('/activity', activityCtrl.getActivity);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ router.get("/lessons-flat", ctrl.getLessonsFlat);
|
|||||||
// ── then :courseId ────────────────────────────────────────────────────────────
|
// ── then :courseId ────────────────────────────────────────────────────────────
|
||||||
router.get("/archives/:courseId", ctrl.getArchivedCourse);
|
router.get("/archives/:courseId", ctrl.getArchivedCourse);
|
||||||
router.patch("/:courseId/restore", ctrl.restoreCourse);
|
router.patch("/:courseId/restore", ctrl.restoreCourse);
|
||||||
|
router.get("/:courseId/archive-impact", ctrl.getCourseArchiveImpact);
|
||||||
router.get("/:courseId", ctrl.getCourse);
|
router.get("/:courseId", ctrl.getCourse);
|
||||||
router.put("/:courseId", ctrl.updateCourse);
|
router.put("/:courseId", ctrl.updateCourse);
|
||||||
router.delete("/:courseId", ctrl.archiveCourse);
|
router.delete("/:courseId", ctrl.archiveCourse);
|
||||||
@@ -75,6 +76,7 @@ router.post("/:courseId/assessment/:assessmentId/questions", ctrl.createQuestion
|
|||||||
// static before :questionId
|
// static before :questionId
|
||||||
router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions);
|
router.delete("/:courseId/assessment/:assessmentId/questions/bulk", ctrl.bulkArchiveQuestions);
|
||||||
router.patch("/:courseId/assessment/:assessmentId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
|
router.patch("/:courseId/assessment/:assessmentId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
|
||||||
|
router.put("/:courseId/assessment/:assessmentId/questions/bulk-sync", ctrl.bulkSyncQuestions);
|
||||||
router.get("/:courseId/assessment/:assessmentId/questions/archives/:questionId", ctrl.getArchivedQuestion);
|
router.get("/:courseId/assessment/:assessmentId/questions/archives/:questionId", ctrl.getArchivedQuestion);
|
||||||
|
|
||||||
router.patch("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.updateQuestion);
|
router.patch("/:courseId/assessment/:assessmentId/questions/:questionId", ctrl.updateQuestion);
|
||||||
@@ -100,6 +102,7 @@ router.patch("/:courseId/units/restore/bulk", ctrl.bulkRestoreUnits);
|
|||||||
|
|
||||||
router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit);
|
router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit);
|
||||||
router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit);
|
router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit);
|
||||||
|
router.get("/:courseId/units/:unitId/archive-impact", ctrl.getUnitArchiveImpact);
|
||||||
router.get("/:courseId/units/:unitId", ctrl.getUnit);
|
router.get("/:courseId/units/:unitId", ctrl.getUnit);
|
||||||
router.put("/:courseId/units/:unitId", ctrl.updateUnit);
|
router.put("/:courseId/units/:unitId", ctrl.updateUnit);
|
||||||
router.delete("/:courseId/units/:unitId", ctrl.archiveUnit);
|
router.delete("/:courseId/units/:unitId", ctrl.archiveUnit);
|
||||||
@@ -125,6 +128,7 @@ router.post("/:courseId/units/:unitId/quiz/:quizId/questions", ctrl.createQuesti
|
|||||||
// static before :questionId
|
// static before :questionId
|
||||||
router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions);
|
router.delete("/:courseId/units/:unitId/quiz/:quizId/questions/bulk", ctrl.bulkArchiveQuestions);
|
||||||
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
|
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk", ctrl.bulkRestoreQuestions);
|
||||||
|
router.put("/:courseId/units/:unitId/quiz/:quizId/questions/bulk-sync", ctrl.bulkSyncQuestions);
|
||||||
router.get("/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId", ctrl.getArchivedQuestion);
|
router.get("/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId", ctrl.getArchivedQuestion);
|
||||||
|
|
||||||
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.updateQuestion);
|
router.patch("/:courseId/units/:unitId/quiz/:quizId/questions/:questionId", ctrl.updateQuestion);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const ctrl = require('../../controllers/admin/email_broadcasts.controller');
|
||||||
|
|
||||||
|
// Auth + requireAdmin applied by admin.routes.js
|
||||||
|
|
||||||
|
router.get ('/', ctrl.getEmailBroadcasts);
|
||||||
|
router.post ('/', ctrl.createEmailBroadcast);
|
||||||
|
router.get ('/:id', ctrl.getEmailBroadcast);
|
||||||
|
router.patch ('/:id/cancel', ctrl.cancelEmailBroadcast);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const ctrl = require('../../controllers/admin/email_templates.controller');
|
||||||
|
|
||||||
|
// Auth + requireAdmin applied by admin.routes.js
|
||||||
|
|
||||||
|
router.get ('/', ctrl.getEmailTemplates);
|
||||||
|
router.post ('/', ctrl.createEmailTemplate);
|
||||||
|
router.get ('/:id', ctrl.getEmailTemplate);
|
||||||
|
router.put ('/:id', ctrl.updateEmailTemplate);
|
||||||
|
router.delete('/:id', ctrl.deleteEmailTemplate);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// routes/admin/notificationBroadcasts.routes.js
|
||||||
|
const router = require('express').Router();
|
||||||
|
const controller = require('../../controllers/admin/notificationBroadcasts.controller');
|
||||||
|
const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
|
||||||
|
|
||||||
|
// ─── Static routes first ──────────────────────────────────────────────────────
|
||||||
|
router.get('/archived', controller.getArchivedBroadcasts);
|
||||||
|
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts);
|
||||||
|
router.patch('/bulk-restore', controller.restoreBroadcasts);
|
||||||
|
|
||||||
|
// ─── Collection ───────────────────────────────────────────────────────────────
|
||||||
|
router.get('/', controller.getBroadcasts);
|
||||||
|
router.post('/', controller.createBroadcast);
|
||||||
|
|
||||||
|
// ─── Dynamic routes last ──────────────────────────────────────────────────────
|
||||||
|
router.get('/:broadcastId', controller.getBroadcast);
|
||||||
|
router.patch('/:broadcastId', sensitiveOpsLimiter, controller.updateBroadcast);
|
||||||
|
router.patch('/:broadcastId/send', sensitiveOpsLimiter, controller.sendBroadcast);
|
||||||
|
router.patch('/:broadcastId/restore', sensitiveOpsLimiter, controller.restoreBroadcast);
|
||||||
|
router.delete('/:broadcastId', sensitiveOpsLimiter, controller.archiveBroadcast);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// routes/admin/notificationSettings.routes.js
|
||||||
|
const router = require('express').Router();
|
||||||
|
const controller = require('../../controllers/admin/notificationSettings.controller');
|
||||||
|
|
||||||
|
router.get('/', controller.getSettings);
|
||||||
|
router.patch('/:jobName', controller.updateSetting);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../../controllers/admin/tiers.controller');
|
const ctrl = require('../../controllers/admin/tiers.controller');
|
||||||
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
|
|
||||||
|
|
||||||
|
router.get ('/currencies', ctrl.getCurrencies);
|
||||||
router.get ('/', ctrl.getPlans);
|
router.get ('/', ctrl.getPlans);
|
||||||
router.post ('/', ctrl.createPlan);
|
router.post ('/', ctrl.createPlan);
|
||||||
router.get ('/field-values', ctrl.getPlanFieldValues);
|
router.get ('/field-values', ctrl.getPlanFieldValues);
|
||||||
@@ -17,16 +17,11 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers);
|
|||||||
router.post ('/users/tiers/grant', ctrl.grantTier);
|
router.post ('/users/tiers/grant', ctrl.grantTier);
|
||||||
router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier);
|
router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier);
|
||||||
|
|
||||||
// ← course + impact + prices routes before /:id
|
// ← course + impact routes before /:id
|
||||||
router.get ('/:id/impact', ctrl.getPlanImpact);
|
router.get ('/:id/impact', ctrl.getPlanImpact);
|
||||||
router.get ('/:id/courses', ctrl.getPlanCourses);
|
router.get ('/:id/courses', ctrl.getPlanCourses);
|
||||||
router.post ('/:id/courses', ctrl.syncPlanCourses);
|
router.post ('/:id/courses', ctrl.syncPlanCourses);
|
||||||
|
|
||||||
router.get ('/:id/prices', priceCtrl.getPrices);
|
|
||||||
router.post ('/:id/prices', priceCtrl.addPrice);
|
|
||||||
router.put ('/:id/prices/:currency', priceCtrl.updatePrice);
|
|
||||||
router.delete('/:id/prices/:currency', priceCtrl.removePrice);
|
|
||||||
|
|
||||||
router.get ('/:id', ctrl.getPlan);
|
router.get ('/:id', ctrl.getPlan);
|
||||||
router.put ('/:id', ctrl.updatePlan);
|
router.put ('/:id', ctrl.updatePlan);
|
||||||
router.delete('/:id', ctrl.archivePlan);
|
router.delete('/:id', ctrl.archivePlan);
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const controller = require('../../controllers/client/advertisements.controller');
|
const controller = require('../../controllers/client/advertisements.controller');
|
||||||
|
|
||||||
// ─── GET /api/client/advertisements/active?type=hero ──────────────────────────
|
// ─── GET /api/client/advertisements/active?placement=dashboard.hero ───────────
|
||||||
router.get('/active', controller.getActiveAdvertisement);
|
router.get('/active', controller.getActiveAdvertisement);
|
||||||
|
|
||||||
|
// ─── GET /api/client/advertisements/active-batch?placements=a,b,c ─────────────
|
||||||
|
router.get('/active-batch', controller.getActiveAdvertisements);
|
||||||
|
|
||||||
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────────
|
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────────
|
||||||
router.post('/:advertisementId/click', controller.trackClick);
|
router.post('/:advertisementId/click', controller.trackClick);
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ router.use(authenticate, requireClient());
|
|||||||
router.get('/profile', profileCtrl.getProfile);
|
router.get('/profile', profileCtrl.getProfile);
|
||||||
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
|
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
|
||||||
router.delete('/profile', profileCtrl.deleteAccount);
|
router.delete('/profile', profileCtrl.deleteAccount);
|
||||||
router.patch('/profile/currency', profileCtrl.updateCurrency);
|
|
||||||
router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar);
|
router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar);
|
||||||
router.delete('/profile/avatar', profileCtrl.deleteAvatar);
|
router.delete('/profile/avatar', profileCtrl.deleteAvatar);
|
||||||
router.get('/sessions', profileCtrl.getSessions);
|
router.get('/sessions', profileCtrl.getSessions);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* GET /api/client/notifications/unseen — unseen count
|
* GET /api/client/notifications/unseen — unseen count
|
||||||
* PATCH /api/client/notifications/seen-all — mark all seen
|
* PATCH /api/client/notifications/seen-all — mark all seen
|
||||||
* PATCH /api/client/notifications/:id/seen — mark one seen
|
* PATCH /api/client/notifications/:id/seen — mark one seen
|
||||||
|
* DELETE /api/client/notifications/clear-all — delete all notifications
|
||||||
*
|
*
|
||||||
* Guards: inherited from client.routes.js (authenticate → requireClient)
|
* Guards: inherited from client.routes.js (authenticate → requireClient)
|
||||||
*
|
*
|
||||||
@@ -15,11 +16,12 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { list, unseenCount, markSeen, markAllSeen } = require('../../controllers/client/notification.controller');
|
const { list, unseenCount, markSeen, markAllSeen, clearAll } = require('../../controllers/client/notification.controller');
|
||||||
|
|
||||||
router.get('/', list);
|
router.get('/', list);
|
||||||
router.get('/unseen', unseenCount);
|
router.get('/unseen', unseenCount);
|
||||||
router.patch('/seen-all', markAllSeen);
|
router.patch('/seen-all', markAllSeen);
|
||||||
router.patch('/:id/seen', markSeen);
|
router.patch('/:id/seen', markSeen);
|
||||||
|
router.delete('/clear-all', clearAll);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const ctrl = require('../../controllers/client/tiers.controller');
|
const ctrl = require('../../controllers/client/tiers.controller');
|
||||||
const priceCtrl = require('../../controllers/admin/plan_prices.controller');
|
|
||||||
|
|
||||||
// My tier
|
// My tier
|
||||||
router.get ('/me', ctrl.getMyTier);
|
router.get ('/me', ctrl.getMyTier);
|
||||||
@@ -28,7 +27,4 @@ router.get ('/categories', ctrl.getCategories);
|
|||||||
// System badges (public read for profile display)
|
// System badges (public read for profile display)
|
||||||
router.get ('/system-badges', ctrl.getSystemBadges);
|
router.get ('/system-badges', ctrl.getSystemBadges);
|
||||||
|
|
||||||
// Supported currencies (public — used by currency picker in settings + checkout)
|
|
||||||
router.get ('/currencies', priceCtrl.getCurrencies);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -44,6 +44,7 @@ require('./models/users/user_sessions.mdl');
|
|||||||
require('./models/users/user_groups.mdl');
|
require('./models/users/user_groups.mdl');
|
||||||
require('./models/notifications/admin_notification.mdl');
|
require('./models/notifications/admin_notification.mdl');
|
||||||
require('./models/notifications/user_notification.mdl');
|
require('./models/notifications/user_notification.mdl');
|
||||||
|
require('./models/notifications/notification_broadcast.mdl');
|
||||||
|
|
||||||
// ── Cron jobs ──────────────────────────────────────────────────────────────────
|
// ── Cron jobs ──────────────────────────────────────────────────────────────────
|
||||||
const { startAdminCronJobs } = require('./cron/admin.cron');
|
const { startAdminCronJobs } = require('./cron/admin.cron');
|
||||||
@@ -169,8 +170,8 @@ function printCronTable(jobs) {
|
|||||||
console.log('✅ Database connected.');
|
console.log('✅ Database connected.');
|
||||||
|
|
||||||
const cronJobs = [
|
const cronJobs = [
|
||||||
...startAdminCronJobs(),
|
...(await startAdminCronJobs()),
|
||||||
...startClientCronJobs(),
|
...(await startClientCronJobs()),
|
||||||
];
|
];
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
|
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const mdl_Achievements = require('../models/users/achievements.mdl');
|
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 mdl_Users = require('../models/users/users.mdl');
|
||||||
const { EARLY_ACCESS_CUTOFF, ACHIEVEMENT_REGISTRY } = require('../data/achievements.data');
|
const { EARLY_ACCESS_CUTOFF } = require('../data/achievements.data');
|
||||||
const UserNotification = require('../models/notifications/user_notification.mdl');
|
const UserNotification = require('../models/notifications/user_notification.mdl');
|
||||||
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
||||||
|
|
||||||
@@ -24,15 +25,15 @@ const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
|
|||||||
* Safe to call multiple times — idempotent via findOrCreate.
|
* Safe to call multiple times — idempotent via findOrCreate.
|
||||||
*
|
*
|
||||||
* @param {string|number} user_id
|
* @param {string|number} user_id
|
||||||
* @param {string} key — must exist in ACHIEVEMENT_REGISTRY
|
* @param {string} key — must exist in the achievement_definitions catalog
|
||||||
* @param {Object} metadata — optional extra data (course_id, score, etc.)
|
* @param {Object} metadata — optional extra data (course_id, score, etc.)
|
||||||
* @param {string|number} granted_by — null = system, user_id = admin manual grant
|
* @param {string|number} granted_by — null = system, user_id = admin manual grant
|
||||||
* @returns {{ achievement, created }} or null on error
|
* @returns {{ achievement, created }} or null on error
|
||||||
*/
|
*/
|
||||||
async function grantAchievement(user_id, key, metadata = {}, granted_by = null) {
|
async function grantAchievement(user_id, key, metadata = {}, granted_by = null) {
|
||||||
const def = ACHIEVEMENT_REGISTRY[key];
|
const def = await mdl_AchievementDefinitions.findOne({ where: { key, is_active: true } });
|
||||||
if (!def) {
|
if (!def) {
|
||||||
console.warn(`[ACHIEVEMENTS] Unknown achievement key: "${key}"`);
|
console.warn(`[ACHIEVEMENTS] Unknown or inactive achievement key: "${key}"`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ async function grantAchievement(user_id, key, metadata = {}, granted_by = null)
|
|||||||
key: def.key,
|
key: def.key,
|
||||||
label: def.label,
|
label: def.label,
|
||||||
description: def.description,
|
description: def.description,
|
||||||
|
icon: def.icon,
|
||||||
granted_by: granted_by ?? null,
|
granted_by: granted_by ?? null,
|
||||||
granted_at: new Date(),
|
granted_at: new Date(),
|
||||||
metadata,
|
metadata,
|
||||||
@@ -140,7 +142,6 @@ async function backfillEarlyAccess() {
|
|||||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ACHIEVEMENT_REGISTRY,
|
|
||||||
grantAchievement,
|
grantAchievement,
|
||||||
|
|
||||||
// Convenience triggers
|
// Convenience triggers
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name : certificate-record.service.js
|
||||||
|
* Type : Service
|
||||||
|
* Description : Resolves or lazily creates the persisted `certificates` row for a
|
||||||
|
* user/course pair (cert_no/ref_no assignment, instructor snapshot).
|
||||||
|
* Shared by the PDF download endpoint (certificate.controller.js) and
|
||||||
|
* the hourly issuance cron (cron/jobs/issue_certificates.cron.js) so
|
||||||
|
* both write through the same cert_no/ref_no sequence.
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 2, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { formatDuration } = require('../utils/duration.util');
|
||||||
|
|
||||||
|
const {
|
||||||
|
Course,
|
||||||
|
CourseAssessment,
|
||||||
|
QuizAttempt,
|
||||||
|
Certificate,
|
||||||
|
CourseInstructor,
|
||||||
|
} = require('../models/courses/courses.associations');
|
||||||
|
|
||||||
|
function formatInstructors(rows) {
|
||||||
|
const names = rows.map(r => r.display_name);
|
||||||
|
if (names.length === 0) return '';
|
||||||
|
if (names.length === 1) return names[0];
|
||||||
|
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||||
|
return `${names[0]}, ${names[1]} and et. al`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
|
||||||
|
async function buildCertNo(userId) {
|
||||||
|
const count = await Certificate.count({ where: { user_id: userId } });
|
||||||
|
const seq = String(count + 1).padStart(5, '0');
|
||||||
|
const uid = String(userId).padStart(6, '0');
|
||||||
|
const now = new Date();
|
||||||
|
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
return `${YYYYMM}-${uid}-${seq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
|
||||||
|
async function buildRefNo() {
|
||||||
|
const count = await Certificate.count();
|
||||||
|
const seq = String(count + 1).padStart(5, '0');
|
||||||
|
const now = new Date();
|
||||||
|
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
return `PP-${YYYYMM}-${seq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves (or creates) the Certificate row for a user/course. Idempotent —
|
||||||
|
* safe to call from both the cron job and the on-demand download endpoint.
|
||||||
|
* Returns null if the course has no assessment or the user hasn't passed it.
|
||||||
|
*/
|
||||||
|
async function ensureCertificateRecord({ userId, courseId }) {
|
||||||
|
const course = await Course.findOne({
|
||||||
|
where: { course_id: courseId },
|
||||||
|
attributes: ['course_id', 'title', 'duration_seconds'],
|
||||||
|
include: [
|
||||||
|
{ model: CourseAssessment, as: 'assessment', attributes: ['assessment_id'], required: false },
|
||||||
|
{
|
||||||
|
model: CourseInstructor,
|
||||||
|
as: 'instructors',
|
||||||
|
attributes: ['display_name', 'order_index'],
|
||||||
|
required: false,
|
||||||
|
order: [['order_index', 'ASC']],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!course || !course.assessment) return null;
|
||||||
|
|
||||||
|
const passedAttempt = await QuizAttempt.findOne({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
assessment_id: course.assessment.assessment_id,
|
||||||
|
passed: true,
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
attributes: ['score', 'createdAt'],
|
||||||
|
});
|
||||||
|
if (!passedAttempt) return null;
|
||||||
|
|
||||||
|
const liveInstructors = formatInstructors(course.instructors ?? []);
|
||||||
|
|
||||||
|
// CockroachDB does not support findOrCreate (uses temp PL/pgSQL functions).
|
||||||
|
let cert = await Certificate.findOne({ where: { user_id: userId, course_id: courseId } });
|
||||||
|
if (!cert) {
|
||||||
|
cert = await Certificate.create({
|
||||||
|
user_id: userId,
|
||||||
|
course_id: courseId,
|
||||||
|
cert_no: await buildCertNo(userId),
|
||||||
|
ref_no: await buildRefNo(),
|
||||||
|
instructors: liveInstructors,
|
||||||
|
score: passedAttempt.score ?? null,
|
||||||
|
length_str: formatDuration(course.duration_seconds),
|
||||||
|
issued_at: passedAttempt.createdAt,
|
||||||
|
});
|
||||||
|
} else if (liveInstructors !== (cert.instructors ?? '')) {
|
||||||
|
await cert.update({ instructors: liveInstructors });
|
||||||
|
}
|
||||||
|
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureCertificateRecord, formatInstructors, buildCertNo, buildRefNo };
|
||||||
+33
-10
@@ -2,18 +2,22 @@
|
|||||||
* File Name: email.service.js
|
* File Name: email.service.js
|
||||||
* Type of Program: Service
|
* Type of Program: Service
|
||||||
* Description: Nodemailer-based email service.
|
* Description: Nodemailer-based email service.
|
||||||
* Provides:
|
* Subject + body per type are loaded from the email_templates
|
||||||
* - sendOTPEmail() → sends a 6-digit OTP verification email
|
* table (admin-editable, see controllers/admin/email_templates.controller.js).
|
||||||
* - sendWelcomeEmail() → sent after successful email verification
|
* The outer layout (header/footer/signature) below is fixed in
|
||||||
* Author: rgrgogu
|
* code and is NOT admin-editable — only the body content is.
|
||||||
|
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
|
||||||
* Date Created: Oct. 6, 2025
|
* Date Created: Oct. 6, 2025
|
||||||
|
* Date Modified: Jul. 3, 2026 — templates moved from data/email_body.data.js into the DB
|
||||||
***********************************************************************************************************************************************************************
|
***********************************************************************************************************************************************************************
|
||||||
* HOW TO USE:
|
* HOW TO USE:
|
||||||
* const emailService = require('../services/email.service');
|
* const emailService = require('../services/email.service');
|
||||||
* await emailService.sendOTPEmail(user.email, otp);
|
* await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } });
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { emailTemplates } = require('../data/email_body.data')
|
const mdl_EmailTemplate = require('../models/email_templates/email_templates.mdl');
|
||||||
|
const { enrichEmailData } = require('../data/email_template_enrichers.data');
|
||||||
|
const { renderTemplate } = require('../utils/renderTemplate.util');
|
||||||
|
|
||||||
const port = Number(process.env.SMTP_PORT);
|
const port = Number(process.env.SMTP_PORT);
|
||||||
|
|
||||||
@@ -31,15 +35,34 @@ const transporter = nodemailer.createTransport({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fixed layout — admins cannot change header/footer/signature via the CRUD,
|
||||||
|
// only the body content per template type.
|
||||||
|
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
|
||||||
|
const wrap = (body) => `
|
||||||
|
<html>
|
||||||
|
<body style="${FONT} line-height: 1.6;">
|
||||||
|
${body.trim()}
|
||||||
|
<br><br>
|
||||||
|
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
|
||||||
|
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
|
||||||
|
</body>
|
||||||
|
</html>`.trim();
|
||||||
|
|
||||||
const sendEmail = async ({ to, type, data = {} }) => {
|
const sendEmail = async ({ to, type, data = {} }) => {
|
||||||
try {
|
try {
|
||||||
const templateFn = emailTemplates[type];
|
const template = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||||
|
if (!template) {
|
||||||
if (!templateFn) {
|
|
||||||
throw new Error(`Email template "${type}" not found`);
|
throw new Error(`Email template "${type}" not found`);
|
||||||
}
|
}
|
||||||
|
// Draft content (or a template that's never been sent) never reaches
|
||||||
|
// real mail — only the live subject/html_body columns count as "published".
|
||||||
|
if (!template.subject || !template.html_body) {
|
||||||
|
throw new Error(`Email template "${type}" has no published (sent) version yet`);
|
||||||
|
}
|
||||||
|
|
||||||
const { subject, html } = templateFn(data);
|
const enriched = enrichEmailData(type, data);
|
||||||
|
const subject = renderTemplate(template.subject, enriched);
|
||||||
|
const html = wrap(renderTemplate(template.html_body, enriched));
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
transporter.sendMail(
|
transporter.sendMail(
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: mediaToken.service.js
|
||||||
|
* Type of Program: Service
|
||||||
|
* Description: Issues + caches short-lived JWT stream tokens (and presigned S3
|
||||||
|
* thumbnail URLs) for asset preview. Shared by:
|
||||||
|
* - controllers/admin/media.controller.js (POST /admin/media/token(s))
|
||||||
|
* - controllers/admin/assets.controller.js (embeds tokens directly
|
||||||
|
* into GET /admin/assets rows so pickers don't need a second
|
||||||
|
* round-trip just to render thumbnails)
|
||||||
|
*
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const s3 = require("./s3.service");
|
||||||
|
|
||||||
|
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
|
||||||
|
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
|
||||||
|
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
|
||||||
|
|
||||||
|
// ─── In-memory token cache (no Redis yet) ──────────────────────────────────────
|
||||||
|
// Avoids re-signing a JWT / re-presigning the thumbnail S3 URL for an asset that
|
||||||
|
// already has a still-valid token. Keyed by (asset_id, ip) because the stream
|
||||||
|
// endpoint (/api/client/media/stream/:token) pins the token to the issuing
|
||||||
|
// request's IP — reusing a token minted for a different IP would get rejected.
|
||||||
|
// Single-process only; each app instance keeps its own cache.
|
||||||
|
const TOKEN_CACHE_MARGIN_SEC = 120; // re-mint a bit before actual expiry
|
||||||
|
const tokenCache = new Map(); // `${asset_id}:${ip}` -> { token, thumbnail_url, expiresAt }
|
||||||
|
|
||||||
|
function tokenCacheKey(assetId, ip) {
|
||||||
|
return `${assetId}:${ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedToken(assetId, ip) {
|
||||||
|
const key = tokenCacheKey(assetId, ip);
|
||||||
|
const entry = tokenCache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() >= entry.expiresAt) {
|
||||||
|
tokenCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedToken(assetId, ip, token, thumbnail_url) {
|
||||||
|
tokenCache.set(tokenCacheKey(assetId, ip), {
|
||||||
|
token,
|
||||||
|
thumbnail_url,
|
||||||
|
expiresAt: Date.now() + (TOKEN_TTL_SEC - TOKEN_CACHE_MARGIN_SEC) * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
|
||||||
|
// down to a single canonical form. Without this, a token minted off one
|
||||||
|
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
|
||||||
|
// request that happened to land on the other stack (IPv6) — browsers race
|
||||||
|
// both when resolving "localhost", so mint and stream requests can land on
|
||||||
|
// different stacks even from the same client.
|
||||||
|
function normalizeIp(ip) {
|
||||||
|
if (ip === "::1") return "127.0.0.1";
|
||||||
|
if (ip.startsWith("::ffff:")) return ip.slice(7);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIp(req) {
|
||||||
|
const forwarded = req.headers["x-forwarded-for"];
|
||||||
|
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
|
||||||
|
return normalizeIp(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function signToken(asset, userId, ip) {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
user_id: userId,
|
||||||
|
storage_key: asset.storage_key,
|
||||||
|
file_type: asset.file_type,
|
||||||
|
mime_type: asset.mime_type,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
MEDIA_SECRET,
|
||||||
|
{ expiresIn: TOKEN_TTL_SEC }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── issueForAsset ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Returns { token, thumbnail_url } for an S3 asset, minting + caching on first
|
||||||
|
// call and serving from tokenCache on subsequent calls within the TTL margin.
|
||||||
|
// `asset` needs: asset_id, storage_key, file_type, mime_type, thumbnail_storage_key.
|
||||||
|
//
|
||||||
|
async function issueForAsset(asset, userId, ip) {
|
||||||
|
const cached = getCachedToken(asset.asset_id, ip);
|
||||||
|
if (cached) return { token: cached.token, thumbnail_url: cached.thumbnail_url };
|
||||||
|
|
||||||
|
const token = signToken(asset, userId, ip);
|
||||||
|
|
||||||
|
let thumbnail_url = null;
|
||||||
|
if (asset.thumbnail_storage_key) {
|
||||||
|
try {
|
||||||
|
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — thumbnail is cosmetic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCachedToken(asset.asset_id, ip, token, thumbnail_url);
|
||||||
|
return { token, thumbnail_url };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TOKEN_TTL_SEC,
|
||||||
|
SUPPORTED_TYPES,
|
||||||
|
resolveIp,
|
||||||
|
issueForAsset,
|
||||||
|
};
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
|
||||||
* File Name: paypal.service.js
|
|
||||||
* Type of Program: Service
|
|
||||||
* Description: PayPal Orders API helpers — create order, capture order.
|
|
||||||
* Uses client-side JS SDK button → server capture flow.
|
|
||||||
* Author: Kenneth Obsequio (@lash0000)
|
|
||||||
* Date Created: Jun. 6, 2026
|
|
||||||
***********************************************************************************************************************************************************************/
|
|
||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
const BASE_URL = process.env.PAYPAL_ENV === 'live'
|
|
||||||
? 'https://api-m.paypal.com'
|
|
||||||
: 'https://api-m.sandbox.paypal.com';
|
|
||||||
|
|
||||||
const getAccessToken = async () => {
|
|
||||||
const { data } = await axios.post(
|
|
||||||
`${BASE_URL}/v1/oauth2/token`,
|
|
||||||
'grant_type=client_credentials',
|
|
||||||
{
|
|
||||||
auth: {
|
|
||||||
username: process.env.PAYPAL_CLIENT_ID,
|
|
||||||
password: process.env.PAYPAL_CLIENT_SECRET,
|
|
||||||
},
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return data.access_token;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl, cancelUrl }) => {
|
|
||||||
const token = await getAccessToken();
|
|
||||||
const { data } = await axios.post(
|
|
||||||
`${BASE_URL}/v2/checkout/orders`,
|
|
||||||
{
|
|
||||||
intent: 'CAPTURE',
|
|
||||||
purchase_units: [{
|
|
||||||
reference_id: referenceId,
|
|
||||||
amount: { currency_code: currency, value: String(amount) },
|
|
||||||
}],
|
|
||||||
application_context: {
|
|
||||||
return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`,
|
|
||||||
cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`,
|
|
||||||
brand_name: 'Philproperties',
|
|
||||||
user_action: 'PAY_NOW',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.captureOrder = async (orderId) => {
|
|
||||||
const token = await getAccessToken();
|
|
||||||
const { data } = await axios.post(
|
|
||||||
`${BASE_URL}/v2/checkout/orders/${orderId}/capture`,
|
|
||||||
{},
|
|
||||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
return data; // { id, status, purchase_units, payer }
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.refundCapture = async (captureId, amount, currency = 'USD') => {
|
|
||||||
const token = await getAccessToken();
|
|
||||||
const { data } = await axios.post(
|
|
||||||
`${BASE_URL}/v2/payments/captures/${captureId}/refund`,
|
|
||||||
{
|
|
||||||
amount: {
|
|
||||||
value: String(amount),
|
|
||||||
currency_code: currency,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
return data; // { id, status, amount, ... }
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: task_reading_progress_sync.service.js
|
||||||
|
* Type of Program: Service
|
||||||
|
* Description: Backfills task_progress for read_* task requirements from course_reading_progress.
|
||||||
|
*
|
||||||
|
* This covers the case where a user already completed reading a course/unit/lesson
|
||||||
|
* before a task requiring that item was created or assigned.
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl');
|
||||||
|
const { TaskProgress } = require('../models/task/task_progress.mdl');
|
||||||
|
|
||||||
|
const READ_TYPE_TO_PROGRESS_TYPE = {
|
||||||
|
read_course: 'course',
|
||||||
|
read_unit: 'unit',
|
||||||
|
read_lesson: 'lesson',
|
||||||
|
};
|
||||||
|
|
||||||
|
const READ_REQUIREMENT_TYPES = Object.keys(READ_TYPE_TO_PROGRESS_TYPE);
|
||||||
|
|
||||||
|
function readAttr(row, attr) {
|
||||||
|
if (!row) return undefined;
|
||||||
|
if (typeof row.get === 'function') return row.get(attr);
|
||||||
|
return row[attr];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequirement(row) {
|
||||||
|
const type = readAttr(row, 'type');
|
||||||
|
if (!READ_REQUIREMENT_TYPES.includes(type)) return null;
|
||||||
|
|
||||||
|
const referenceId = readAttr(row, 'reference_id');
|
||||||
|
if (!referenceId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
task_id: readAttr(row, 'task_id'),
|
||||||
|
requirement_id: readAttr(row, 'requirement_id'),
|
||||||
|
reference_id: referenceId,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateReadTaskProgress(userId, requirements = [], options = {}) {
|
||||||
|
const readRequirements = requirements
|
||||||
|
.map(normalizeRequirement)
|
||||||
|
.filter((req) => req && req.task_id && req.requirement_id);
|
||||||
|
|
||||||
|
if (!readRequirements.length) return [];
|
||||||
|
|
||||||
|
const referencesByProgressType = readRequirements.reduce((acc, req) => {
|
||||||
|
const progressType = READ_TYPE_TO_PROGRESS_TYPE[req.type];
|
||||||
|
if (!acc[progressType]) acc[progressType] = new Set();
|
||||||
|
acc[progressType].add(req.reference_id);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
user_id: userId,
|
||||||
|
status: 'completed',
|
||||||
|
[Op.or]: Object.entries(referencesByProgressType).map(([type, references]) => ({
|
||||||
|
type,
|
||||||
|
reference_id: { [Op.in]: [...references] },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const completedReadingRows = await CourseReadingProgress.findAll({
|
||||||
|
where,
|
||||||
|
attributes: ['type', 'reference_id', 'completed_at'],
|
||||||
|
transaction: options.transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!completedReadingRows.length) return [];
|
||||||
|
|
||||||
|
const completedReading = new Map(
|
||||||
|
completedReadingRows.map((row) => [
|
||||||
|
`${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`,
|
||||||
|
readAttr(row, 'completed_at'),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const rowsToUpsert = readRequirements.filter((req) =>
|
||||||
|
completedReading.has(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rowsToUpsert.length) return [];
|
||||||
|
|
||||||
|
const existingProgressRows = await TaskProgress.findAll({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
completed: true,
|
||||||
|
requirement_id: { [Op.in]: rowsToUpsert.map((req) => req.requirement_id) },
|
||||||
|
reference_id: { [Op.in]: rowsToUpsert.map((req) => req.reference_id) },
|
||||||
|
},
|
||||||
|
attributes: ['requirement_id', 'reference_id'],
|
||||||
|
transaction: options.transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingProgress = new Set(
|
||||||
|
existingProgressRows.map((row) =>
|
||||||
|
`${readAttr(row, 'requirement_id')}:${readAttr(row, 'reference_id')}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingRows = rowsToUpsert.filter((req) =>
|
||||||
|
!existingProgress.has(`${req.requirement_id}:${req.reference_id}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!missingRows.length) return [];
|
||||||
|
|
||||||
|
await Promise.all(missingRows.map((req) =>
|
||||||
|
TaskProgress.upsert(
|
||||||
|
{
|
||||||
|
task_id: req.task_id,
|
||||||
|
requirement_id: req.requirement_id,
|
||||||
|
user_id: userId,
|
||||||
|
reference_id: req.reference_id,
|
||||||
|
type: req.type,
|
||||||
|
completed: true,
|
||||||
|
completed_at: completedReading.get(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) ?? now,
|
||||||
|
createdBy: userId,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
|
||||||
|
transaction: options.transaction,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
));
|
||||||
|
|
||||||
|
return missingRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hydrateReadTaskProgress,
|
||||||
|
READ_REQUIREMENT_TYPES,
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Integration test: paypal.provider.js against a mocked HTTP boundary ───────
|
||||||
|
// Only axios (the true external boundary) is mocked — everything else in the
|
||||||
|
// provider (token exchange, URL selection, request shaping) runs for real.
|
||||||
|
// This is what tells us the provider is ready to go live: wrong env vars, a
|
||||||
|
// broken sandbox/live switch, or a malformed request body will fail here
|
||||||
|
// exactly like it would against the real PayPal API.
|
||||||
|
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
const BASE_ENV = {
|
||||||
|
PAYPAL_CLIENT_ID: 'test-client-id',
|
||||||
|
PAYPAL_CLIENT_SECRET: 'test-client-secret',
|
||||||
|
PAYPAL_BRAND_NAME: 'STARR',
|
||||||
|
FRONTEND_URL: 'https://app.new-starr.test',
|
||||||
|
};
|
||||||
|
|
||||||
|
// BASE_URL is computed once at module load time from PAYPAL_ENV, so every
|
||||||
|
// test that cares about sandbox/live must reset the module registry first.
|
||||||
|
// axios must be re-required from the same fresh registry, otherwise its mock
|
||||||
|
// calls land on a different automock instance than the one the provider uses.
|
||||||
|
function loadProvider(envOverrides = {}) {
|
||||||
|
jest.resetModules();
|
||||||
|
Object.assign(process.env, BASE_ENV, envOverrides);
|
||||||
|
const axios = require('axios');
|
||||||
|
const provider = require('../../providers/paypal.provider');
|
||||||
|
return { provider, axios };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAccessToken(axios, token = 'test-access-token') {
|
||||||
|
axios.post.mockImplementationOnce((url) => {
|
||||||
|
expect(url).toMatch(/\/v1\/oauth2\/token$/);
|
||||||
|
return Promise.resolve({ data: { access_token: token } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.PAYPAL_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Config: sandbox vs live host selection ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('environment / config wiring', () => {
|
||||||
|
|
||||||
|
test('defaults to the sandbox host when PAYPAL_ENV is unset', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await provider.createOrder({ amount: 10, referenceId: 'ref-1' });
|
||||||
|
|
||||||
|
expect(axios.post.mock.calls[0][0]).toBe('https://api-m.sandbox.paypal.com/v1/oauth2/token');
|
||||||
|
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches to the live host when PAYPAL_ENV=live', async () => {
|
||||||
|
const { provider, axios } = loadProvider({ PAYPAL_ENV: 'live' });
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await provider.createOrder({ amount: 10, referenceId: 'ref-1' });
|
||||||
|
|
||||||
|
expect(axios.post.mock.calls[0][0]).toBe('https://api-m.paypal.com/v1/oauth2/token');
|
||||||
|
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.paypal.com/v2/checkout/orders');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('token request authenticates with PAYPAL_CLIENT_ID / PAYPAL_CLIENT_SECRET via HTTP Basic auth', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await provider.createOrder({ amount: 10, referenceId: 'ref-1' });
|
||||||
|
|
||||||
|
const [, , config] = axios.post.mock.calls[0];
|
||||||
|
expect(config.auth).toEqual({ username: 'test-client-id', password: 'test-client-secret' });
|
||||||
|
expect(config.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected token exchange (bad credentials) surfaces as a rejected promise, not a silent failure', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
axios.post.mockRejectedValueOnce(new Error('401 invalid_client'));
|
||||||
|
|
||||||
|
await expect(provider.createOrder({ amount: 10, referenceId: 'ref-1' }))
|
||||||
|
.rejects.toThrow('401 invalid_client');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── createOrder ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('createOrder()', () => {
|
||||||
|
|
||||||
|
test('sends a CAPTURE intent order with the reference id, amount and currency', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios, 'tok-abc');
|
||||||
|
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } });
|
||||||
|
|
||||||
|
const result = await provider.createOrder({
|
||||||
|
amount: 19.99,
|
||||||
|
currency: 'PHP',
|
||||||
|
referenceId: 'plan-42',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [url, body, config] = axios.post.mock.calls[1];
|
||||||
|
expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders');
|
||||||
|
expect(body.intent).toBe('CAPTURE');
|
||||||
|
expect(body.purchase_units[0]).toMatchObject({
|
||||||
|
reference_id: 'plan-42',
|
||||||
|
amount: { currency_code: 'PHP', value: '19.99' },
|
||||||
|
});
|
||||||
|
expect(config.headers.Authorization).toBe('Bearer tok-abc');
|
||||||
|
expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults currency to USD when not provided', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[1];
|
||||||
|
expect(body.purchase_units[0].amount.currency_code).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to FRONTEND_URL for return/cancel urls when not provided', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[1];
|
||||||
|
expect(body.application_context.return_url).toBe('https://app.new-starr.test/plans/checkout');
|
||||||
|
expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/plans/checkout?cancelled=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors explicit return/cancel urls when provided', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await provider.createOrder({
|
||||||
|
amount: 5, referenceId: 'ref-1',
|
||||||
|
returnUrl: 'https://custom.test/ok',
|
||||||
|
cancelUrl: 'https://custom.test/cancel',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[1];
|
||||||
|
expect(body.application_context.return_url).toBe('https://custom.test/ok');
|
||||||
|
expect(body.application_context.cancel_url).toBe('https://custom.test/cancel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── captureOrder ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('captureOrder()', () => {
|
||||||
|
|
||||||
|
test('posts to the order capture endpoint with a bearer token and returns the capture payload', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios, 'tok-xyz');
|
||||||
|
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } });
|
||||||
|
|
||||||
|
const result = await provider.captureOrder('ORDER-1');
|
||||||
|
|
||||||
|
const [url, body, config] = axios.post.mock.calls[1];
|
||||||
|
expect(url).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture');
|
||||||
|
expect(body).toEqual({});
|
||||||
|
expect(config.headers.Authorization).toBe('Bearer tok-xyz');
|
||||||
|
expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── refundCapture ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('refundCapture()', () => {
|
||||||
|
|
||||||
|
test('posts a refund with the given amount and currency', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } });
|
||||||
|
|
||||||
|
const result = await provider.refundCapture('CAPTURE-1', 9.5, 'PHP');
|
||||||
|
|
||||||
|
const [url, body] = axios.post.mock.calls[1];
|
||||||
|
expect(url).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund');
|
||||||
|
expect(body).toEqual({ amount: { value: '9.5', currency_code: 'PHP' } });
|
||||||
|
expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults currency to USD when not provided', async () => {
|
||||||
|
const { provider, axios } = loadProvider({});
|
||||||
|
mockAccessToken(axios);
|
||||||
|
axios.post.mockResolvedValueOnce({ data: {} });
|
||||||
|
|
||||||
|
await provider.refundCapture('CAPTURE-1', 9.5);
|
||||||
|
|
||||||
|
const [, body] = axios.post.mock.calls[1];
|
||||||
|
expect(body.amount.currency_code).toBe('USD');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Provider contract test ────────────────────────────────────────────────────
|
||||||
|
// This suite is the "gate" for onboarding new payment providers. It does not
|
||||||
|
// hardcode "paypal" as the only case — it walks registry.list() and asserts
|
||||||
|
// every registered provider satisfies the shape payment.service.js relies on.
|
||||||
|
// Drop a second provider into providers/registry.js and this file validates it
|
||||||
|
// for free, with zero new test code required.
|
||||||
|
|
||||||
|
const registry = require('../../providers/registry');
|
||||||
|
|
||||||
|
const REQUIRED_METHODS = ['createOrder', 'captureOrder', 'refundCapture'];
|
||||||
|
|
||||||
|
describe('payment provider registry', () => {
|
||||||
|
|
||||||
|
test('lists at least one provider', () => {
|
||||||
|
expect(registry.list().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('currently registers paypal', () => {
|
||||||
|
expect(registry.list()).toContain('paypal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get() returns the provider module for a known name', () => {
|
||||||
|
const provider = registry.get('paypal');
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get() throws a descriptive error for an unknown provider', () => {
|
||||||
|
expect(() => registry.get('stripe')).toThrow(/Unknown payment provider: "stripe"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown-provider error lists the available providers so misconfiguration is easy to diagnose', () => {
|
||||||
|
try {
|
||||||
|
registry.get('does-not-exist');
|
||||||
|
throw new Error('expected registry.get to throw');
|
||||||
|
} catch (err) {
|
||||||
|
registry.list().forEach((name) => {
|
||||||
|
expect(err.message).toContain(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each(registry.list())('provider contract: %s', (name) => {
|
||||||
|
const provider = registry.get(name);
|
||||||
|
|
||||||
|
test.each(REQUIRED_METHODS)('exposes %s as a function', (method) => {
|
||||||
|
expect(typeof provider[method]).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Integration test: payment.service.js → providers/registry → paypal.provider ──
|
||||||
|
// Only the DB models and axios (the two real external boundaries) are mocked.
|
||||||
|
// registry.js and paypal.provider.js run unmodified, so this proves the full
|
||||||
|
// orchestration chain (service → registry lookup → provider → HTTP) is wired
|
||||||
|
// correctly end to end, not just that each piece works in isolation.
|
||||||
|
|
||||||
|
process.env.PAYPAL_CLIENT_ID = 'test-client-id';
|
||||||
|
process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret';
|
||||||
|
process.env.FRONTEND_URL = 'https://app.new-starr.test';
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
jest.mock('axios');
|
||||||
|
|
||||||
|
jest.mock('../../models/tiers/payment_policies.mdl', () => ({ findOne: jest.fn() }));
|
||||||
|
jest.mock('../../models/tiers/payments.mdl', () => ({ count: jest.fn() }));
|
||||||
|
|
||||||
|
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
|
||||||
|
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||||
|
const paymentService = require('../../services/payment.service');
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
function mockAccessToken(token = 'tok-abc') {
|
||||||
|
axios.post.mockImplementationOnce((url) => {
|
||||||
|
expect(url).toMatch(/\/v1\/oauth2\/token$/);
|
||||||
|
return Promise.resolve({ data: { access_token: token } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider delegation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('provider delegation (registry → paypal.provider → axios)', () => {
|
||||||
|
|
||||||
|
test('createOrder("paypal", ...) reaches PayPal\'s create-order endpoint', async () => {
|
||||||
|
mockAccessToken();
|
||||||
|
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'CREATED' } });
|
||||||
|
|
||||||
|
const result = await paymentService.createOrder('paypal', { amount: 25, referenceId: 'plan-1' });
|
||||||
|
|
||||||
|
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders');
|
||||||
|
expect(result).toEqual({ id: 'ORDER-1', status: 'CREATED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captureOrder("paypal", ...) reaches PayPal\'s capture endpoint', async () => {
|
||||||
|
mockAccessToken();
|
||||||
|
axios.post.mockResolvedValueOnce({ data: { id: 'ORDER-1', status: 'COMPLETED' } });
|
||||||
|
|
||||||
|
const result = await paymentService.captureOrder('paypal', 'ORDER-1');
|
||||||
|
|
||||||
|
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/checkout/orders/ORDER-1/capture');
|
||||||
|
expect(result).toEqual({ id: 'ORDER-1', status: 'COMPLETED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refundCapture("paypal", ...) reaches PayPal\'s refund endpoint', async () => {
|
||||||
|
mockAccessToken();
|
||||||
|
axios.post.mockResolvedValueOnce({ data: { id: 'REFUND-1', status: 'COMPLETED' } });
|
||||||
|
|
||||||
|
const result = await paymentService.refundCapture('paypal', 'CAPTURE-1', 10, 'USD');
|
||||||
|
|
||||||
|
expect(axios.post.mock.calls[1][0]).toBe('https://api-m.sandbox.paypal.com/v2/payments/captures/CAPTURE-1/refund');
|
||||||
|
expect(result).toEqual({ id: 'REFUND-1', status: 'COMPLETED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unconfigured/unknown provider name fails fast with a descriptive error instead of hitting axios', () => {
|
||||||
|
expect(() => paymentService.createOrder('stripe', { amount: 25, referenceId: 'plan-1' }))
|
||||||
|
.toThrow(/Unknown payment provider: "stripe"/);
|
||||||
|
expect(axios.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Refund policy ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('refund policy', () => {
|
||||||
|
|
||||||
|
test('falls back to the 5-minute default window when no policy is configured', () => {
|
||||||
|
expect(paymentService.getRefundWindowMs(null)).toBe(5 * 60_000);
|
||||||
|
expect(paymentService.isRefundAllowed(null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors a custom policy window and unit', () => {
|
||||||
|
const policy = { refund_policy: { allowed: false, window_value: 2, window_unit: 'hours' } };
|
||||||
|
expect(paymentService.getRefundWindowMs(policy)).toBe(2 * 3_600_000);
|
||||||
|
expect(paymentService.isRefundAllowed(policy)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPolicyForPlan() looks up the policy by plan_id', async () => {
|
||||||
|
mdl_PaymentPolicies.findOne.mockResolvedValue({ plan_id: 7 });
|
||||||
|
const policy = await paymentService.getPolicyForPlan(7);
|
||||||
|
expect(mdl_PaymentPolicies.findOne).toHaveBeenCalledWith({ where: { plan_id: 7 } });
|
||||||
|
expect(policy).toEqual({ plan_id: 7 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Promo evaluation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('evaluatePromo()', () => {
|
||||||
|
const plan = { plan_id: 1, price: 100 };
|
||||||
|
|
||||||
|
test('rejects when no code is provided', async () => {
|
||||||
|
const result = await paymentService.evaluatePromo({}, plan, '');
|
||||||
|
expect(result).toEqual({ valid: false, reason: 'No promo code provided.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an unknown code', async () => {
|
||||||
|
const policy = { promo_rules: [] };
|
||||||
|
const result = await paymentService.evaluatePromo(policy, plan, 'BOGUS');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.reason).toBe('Invalid promo code.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies a flat discount', async () => {
|
||||||
|
const policy = { promo_rules: [{ code: 'FLAT10', type: 'flat', value: 10 }] };
|
||||||
|
mdl_Payments.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const result = await paymentService.evaluatePromo(policy, plan, 'flat10');
|
||||||
|
expect(result).toMatchObject({ valid: true, code: 'FLAT10', discount: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies a percent discount capped by max_discount', async () => {
|
||||||
|
const policy = { promo_rules: [{ code: 'PCT50', type: 'percent', value: 50, max_discount: 30 }] };
|
||||||
|
mdl_Payments.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const result = await paymentService.evaluatePromo(policy, plan, 'PCT50');
|
||||||
|
expect(result).toMatchObject({ valid: true, discount: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an expired code', async () => {
|
||||||
|
const policy = { promo_rules: [{ code: 'OLD', type: 'flat', value: 5, expires_at: '2000-01-01' }] };
|
||||||
|
const result = await paymentService.evaluatePromo(policy, plan, 'OLD');
|
||||||
|
expect(result).toMatchObject({ valid: false, reason: 'Promo code has expired.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects once max_uses has been reached', async () => {
|
||||||
|
const policy = { promo_rules: [{ code: 'LIMITED', type: 'flat', value: 5, max_uses: 2 }] };
|
||||||
|
mdl_Payments.count.mockResolvedValue(2);
|
||||||
|
|
||||||
|
const result = await paymentService.evaluatePromo(policy, plan, 'LIMITED');
|
||||||
|
expect(result).toMatchObject({ valid: false, reason: 'Promo code has reached its usage limit.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects when the subtotal is below min_amount', async () => {
|
||||||
|
const policy = { promo_rules: [{ code: 'BIGSPEND', type: 'flat', value: 5, min_amount: 200 }] };
|
||||||
|
mdl_Payments.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const result = await paymentService.evaluatePromo(policy, plan, 'BIGSPEND');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.reason).toMatch(/minimum purchase/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluates against effectivePrice (localized currency) instead of plan.price when provided', async () => {
|
||||||
|
const policy = { promo_rules: [{ code: 'PCT10', type: 'percent', value: 10 }] };
|
||||||
|
mdl_Payments.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const result = await paymentService.evaluatePromo(policy, plan, 'PCT10', 50);
|
||||||
|
expect(result.discount).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: audienceResolver.util.js
|
||||||
|
* Type of Program: Utility
|
||||||
|
* Description: Shared "who does this target reach" resolution for anything
|
||||||
|
* broadcast-shaped (notification broadcasts, email broadcasts).
|
||||||
|
* Extracted out of controllers/admin/notificationBroadcasts.controller.js
|
||||||
|
* so both features resolve task_list/course/tier_plan targeting
|
||||||
|
* identically instead of drifting apart.
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
const { Op, QueryTypes } = require('sequelize');
|
||||||
|
const sequelize = require('../config/db.config');
|
||||||
|
const mdl_Users = require('../models/users/users.mdl');
|
||||||
|
const { TaskList } = require('../models/task/task.mdl');
|
||||||
|
const { Course } = require('../models/courses/courses.mdl');
|
||||||
|
const mdl_TierCategories = require('../models/tiers/tier_categories.mdl');
|
||||||
|
const mdl_UserTiers = require('../models/tiers/user_tiers.mdl');
|
||||||
|
const mdl_TierPlans = require('../models/tiers/tier_plans.mdl');
|
||||||
|
const mdl_Product = require('../models/courses/products.mdl');
|
||||||
|
const mdl_CoursePurchase = require('../models/courses/course_purchases.mdl');
|
||||||
|
|
||||||
|
const ALLOWED_TARGET_TYPES = ["admin", "user", "both", "task_list", "course", "tier_plan"];
|
||||||
|
const SCOPED_TARGET_TYPES = ["task_list", "course", "tier_plan"];
|
||||||
|
|
||||||
|
async function validateTargetId(target_type, target_id) {
|
||||||
|
if (target_type === "task_list") {
|
||||||
|
const row = await TaskList.findOne({ where: { task_list_id: target_id, deletedAt: null } });
|
||||||
|
if (!row) { const err = new Error("Selected task list was not found."); err.status = 400; throw err; }
|
||||||
|
} else if (target_type === "course") {
|
||||||
|
const row = await Course.findOne({ where: { uuid: target_id, deletedAt: null } });
|
||||||
|
if (!row) { const err = new Error("Selected course was not found."); err.status = 400; throw err; }
|
||||||
|
} else if (target_type === "tier_plan") {
|
||||||
|
const row = await mdl_TierPlans.findOne({ where: { plan_id: target_id, deletedAt: null } });
|
||||||
|
if (!row) { const err = new Error("Selected tier plan was not found."); err.status = 400; throw err; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank-0 (free) courses resolve like target_type: 'user' — everyone qualifies.
|
||||||
|
async function resolveCourseUserIds(courseUuid) {
|
||||||
|
const course = await Course.findOne({ where: { uuid: courseUuid, deletedAt: null }, attributes: ['course_id', 'subscription'] });
|
||||||
|
if (!course) return [];
|
||||||
|
|
||||||
|
// rank is BIGINT on CockroachDB — Sequelize returns it as a string, so normalize to Number.
|
||||||
|
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
||||||
|
const rankMap = Object.fromEntries(categories.map((c) => [c.slug, Number(c.rank)]));
|
||||||
|
const courseRank = rankMap[course.subscription] ?? Infinity;
|
||||||
|
|
||||||
|
const userIds = new Set();
|
||||||
|
|
||||||
|
if (courseRank === 0) {
|
||||||
|
const users = await mdl_Users.findAll({ attributes: ['user_id'], where: { acc_type: 'user', deletedAt: null }, raw: true });
|
||||||
|
users.forEach((u) => userIds.add(String(u.user_id)));
|
||||||
|
return [...userIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifyingSlugs = Object.entries(rankMap).filter(([, rank]) => rank >= courseRank).map(([slug]) => slug);
|
||||||
|
if (qualifyingSlugs.length) {
|
||||||
|
const holders = await mdl_UserTiers.findAll({
|
||||||
|
attributes: ['user_id'],
|
||||||
|
where: { status: 'active', tier: { [Op.in]: qualifyingSlugs } },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
holders.forEach((h) => userIds.add(String(h.user_id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await mdl_Product.findOne({ where: { course_id: course.course_id } });
|
||||||
|
if (product) {
|
||||||
|
const purchasers = await mdl_CoursePurchase.findAll({
|
||||||
|
attributes: ['user_id'],
|
||||||
|
where: {
|
||||||
|
product_id: product.id,
|
||||||
|
status: 'completed',
|
||||||
|
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||||
|
},
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
purchasers.forEach((p) => userIds.add(String(p.user_id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...userIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTaskListUserIds(taskListId) {
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`SELECT DISTINCT ugm.user_id
|
||||||
|
FROM task_list_groups tlg
|
||||||
|
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL
|
||||||
|
WHERE tlg.task_list_id = :taskListId`,
|
||||||
|
{ replacements: { taskListId }, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
return rows.map((r) => String(r.user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// user_id → group_id, for deep-linking task_list broadcasts to /group/:groupId/view/:taskListId.
|
||||||
|
// First matching group wins if a user belongs to more than one group tied to the task list.
|
||||||
|
async function resolveTaskListUserGroups(taskListId) {
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`SELECT ugm.user_id, tlg.group_id
|
||||||
|
FROM task_list_groups tlg
|
||||||
|
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id AND ugm."deletedAt" IS NULL
|
||||||
|
WHERE tlg.task_list_id = :taskListId`,
|
||||||
|
{ replacements: { taskListId }, type: QueryTypes.SELECT }
|
||||||
|
);
|
||||||
|
const map = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const uid = String(r.user_id);
|
||||||
|
if (!(uid in map)) map[uid] = r.group_id;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTierPlanUserIds(planId) {
|
||||||
|
const holders = await mdl_UserTiers.findAll({
|
||||||
|
attributes: ['user_id'],
|
||||||
|
where: { plan_id: planId, status: 'active' },
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
return holders.map((h) => String(h.user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatcher for the 3 "scoped" target types only — 'admin'/'user'/'both' mean
|
||||||
|
// different things to different callers (e.g. notification broadcasts post to
|
||||||
|
// the shared admin bell feed for 'admin'; email broadcasts email every admin/
|
||||||
|
// staff user instead), so those stay caller-specific rather than living here.
|
||||||
|
async function resolveTargetUserIds(target_type, target_id) {
|
||||||
|
if (target_type === 'task_list') return resolveTaskListUserIds(target_id);
|
||||||
|
if (target_type === 'course') return resolveCourseUserIds(target_id);
|
||||||
|
if (target_type === 'tier_plan') return resolveTierPlanUserIds(target_id);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAllUserIds({ transaction } = {}) {
|
||||||
|
const users = await mdl_Users.findAll({
|
||||||
|
attributes: ['user_id'],
|
||||||
|
where: { acc_type: 'user', deletedAt: null },
|
||||||
|
raw: true,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
return users.map((u) => String(u.user_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ALLOWED_TARGET_TYPES,
|
||||||
|
SCOPED_TARGET_TYPES,
|
||||||
|
validateTargetId,
|
||||||
|
resolveCourseUserIds,
|
||||||
|
resolveTaskListUserIds,
|
||||||
|
resolveTaskListUserGroups,
|
||||||
|
resolveTierPlanUserIds,
|
||||||
|
resolveTargetUserIds,
|
||||||
|
resolveAllUserIds,
|
||||||
|
};
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
|
||||||
* File Name: currency.util.js
|
|
||||||
* Type of Program: Utility
|
|
||||||
* Description: Currency formatting and resolution helpers for backend use (emails, receipts, notifications).
|
|
||||||
*
|
|
||||||
* All format functions accept an optional options object: { locale }
|
|
||||||
* locale — BCP 47 tag, defaults to 'en-US'
|
|
||||||
*
|
|
||||||
* USD is the platform's base/canonical currency. Plans may carry localized price
|
|
||||||
* overrides (plan_prices table). resolvePrice() applies the COALESCE logic:
|
|
||||||
* localized override wins → falls back to plan's base price + currency.
|
|
||||||
***********************************************************************************************************************************************************************/
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── Supported currencies ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SUPPORTED_CURRENCIES = [
|
|
||||||
{ code: 'USD', name: 'US Dollar', symbol: '$' },
|
|
||||||
{ code: 'EUR', name: 'Euro', symbol: '€' },
|
|
||||||
{ code: 'GBP', name: 'British Pound', symbol: '£' },
|
|
||||||
{ code: 'CNY', name: 'Chinese Yuan', symbol: '¥' },
|
|
||||||
{ code: 'JPY', name: 'Japanese Yen', symbol: '¥' },
|
|
||||||
{ code: 'PHP', name: 'Philippine Peso', symbol: '₱' },
|
|
||||||
{ code: 'KRW', name: 'South Korean Won', symbol: '₩' },
|
|
||||||
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$' },
|
|
||||||
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' },
|
|
||||||
{ code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' },
|
|
||||||
{ code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$'},
|
|
||||||
{ code: 'INR', name: 'Indian Rupee', symbol: '₹' },
|
|
||||||
{ code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' },
|
|
||||||
{ code: 'THB', name: 'Thai Baht', symbol: '฿' },
|
|
||||||
{ code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' },
|
|
||||||
{ code: 'TWD', name: 'Taiwan Dollar', symbol: 'NT$'},
|
|
||||||
{ code: 'VND', name: 'Vietnamese Dong', symbol: '₫' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SUPPORTED_CURRENCY_CODES = new Set(SUPPORTED_CURRENCIES.map((c) => c.code));
|
|
||||||
|
|
||||||
function isSupported(code) {
|
|
||||||
return SUPPORTED_CURRENCY_CODES.has(code?.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Formatting ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** "¥1,299.00" / "$9.99" */
|
|
||||||
function fmtCurrency(amount, currency = 'USD', { locale = 'en-US' } = {}) {
|
|
||||||
if (amount === null || amount === undefined) return '—';
|
|
||||||
return new Intl.NumberFormat(locale, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency ?? 'USD',
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
}).format(Number(amount));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Price resolution ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the effective { price, currency } for a plan given a user's preferred currency.
|
|
||||||
* plan.prices must be eager-loaded (as: 'prices') for the override to be considered.
|
|
||||||
* Falls back to plan.price + plan.currency when no override exists.
|
|
||||||
*/
|
|
||||||
function resolvePrice(plan, preferredCurrency) {
|
|
||||||
if (!preferredCurrency || preferredCurrency === plan.currency)
|
|
||||||
return { price: Number(plan.price), currency: plan.currency };
|
|
||||||
|
|
||||||
const override = (plan.prices ?? []).find((p) => p.currency === preferredCurrency);
|
|
||||||
if (override) return { price: Number(override.price), currency: override.currency };
|
|
||||||
|
|
||||||
return { price: Number(plan.price), currency: plan.currency };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Exchange rate fetching ───────────────────────────────────────────────────
|
|
||||||
// Uses frankfurter.app (ECB-backed, no API key, free).
|
|
||||||
// In-process cache with 1-hour TTL avoids hammering the API on every save.
|
|
||||||
|
|
||||||
const _rateCache = new Map();
|
|
||||||
|
|
||||||
async function fetchExchangeRate(from, to) {
|
|
||||||
if (from === to) return 1;
|
|
||||||
const key = `${from}:${to}`;
|
|
||||||
const now = Date.now();
|
|
||||||
const cached = _rateCache.get(key);
|
|
||||||
if (cached && cached.expiresAt > now) return cached.rate;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://api.frankfurter.app/latest?from=${from}&to=${to}`,
|
|
||||||
{ signal: AbortSignal.timeout(4000) },
|
|
||||||
);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
const json = await res.json();
|
|
||||||
const rate = json?.rates?.[to];
|
|
||||||
if (!rate) return null;
|
|
||||||
_rateCache.set(key, { rate, expiresAt: now + 60 * 60 * 1000 }); // 1 h TTL
|
|
||||||
return rate;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Localized price validation ───────────────────────────────────────────────
|
|
||||||
// Three zones relative to the market-rate conversion of the base price:
|
|
||||||
//
|
|
||||||
// pass → 85 % – 150 % of expected (green, saves normally)
|
|
||||||
// warn → 70 % – 85 % or 150 % – 300 % (saves with caution message)
|
|
||||||
// block → < 70 % or > 300 % (rejected — too far from market rate)
|
|
||||||
//
|
|
||||||
// If the exchange-rate API is unavailable the check is skipped (returns 'pass').
|
|
||||||
|
|
||||||
const PRICE_ZONES = {
|
|
||||||
LOWER_HARD: 0.70,
|
|
||||||
LOWER_WARN: 0.85,
|
|
||||||
UPPER_WARN: 1.50,
|
|
||||||
UPPER_HARD: 3.00,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function validateLocalizedPrice(basePrice, baseCurrency, localizedPrice, targetCurrency) {
|
|
||||||
const rate = await fetchExchangeRate(baseCurrency, targetCurrency);
|
|
||||||
if (!rate) return { zone: 'pass', skipped: true };
|
|
||||||
|
|
||||||
const expected = Number(basePrice) * rate;
|
|
||||||
const entered = Number(localizedPrice);
|
|
||||||
const { LOWER_HARD, LOWER_WARN, UPPER_WARN, UPPER_HARD } = PRICE_ZONES;
|
|
||||||
|
|
||||||
const hardMin = expected * LOWER_HARD;
|
|
||||||
const hardMax = expected * UPPER_HARD;
|
|
||||||
const warnMin = expected * LOWER_WARN;
|
|
||||||
const warnMax = expected * UPPER_WARN;
|
|
||||||
|
|
||||||
const fmt = (n) => n.toFixed(2);
|
|
||||||
const rateStr = `1 ${baseCurrency} = ${rate} ${targetCurrency}`;
|
|
||||||
|
|
||||||
if (entered < hardMin || entered > hardMax) {
|
|
||||||
return {
|
|
||||||
zone: 'block',
|
|
||||||
expected: fmt(expected),
|
|
||||||
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
|
|
||||||
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
|
|
||||||
message: `${fmt(entered)} ${targetCurrency} is too far from the current market rate (${rateStr}). ` +
|
|
||||||
`Acceptable range: ${fmt(hardMin)} – ${fmt(hardMax)} ${targetCurrency}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entered < warnMin || entered > warnMax) {
|
|
||||||
return {
|
|
||||||
zone: 'warn',
|
|
||||||
expected: fmt(expected),
|
|
||||||
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
|
|
||||||
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
|
|
||||||
message: `${fmt(entered)} ${targetCurrency} is outside the suggested range (${rateStr}). ` +
|
|
||||||
`Suggested: ${fmt(warnMin)} – ${fmt(warnMax)} ${targetCurrency}. Saved with caution.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
zone: 'pass',
|
|
||||||
expected: fmt(expected),
|
|
||||||
hardMin: fmt(hardMin), hardMax: fmt(hardMax),
|
|
||||||
warnMin: fmt(warnMin), warnMax: fmt(warnMax),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
SUPPORTED_CURRENCIES,
|
|
||||||
isSupported,
|
|
||||||
fmtCurrency,
|
|
||||||
resolvePrice,
|
|
||||||
fetchExchangeRate,
|
|
||||||
validateLocalizedPrice,
|
|
||||||
PRICE_ZONES,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/***********************************************************************************************************************************************************************
|
||||||
|
* File Name: renderTemplate.util.js
|
||||||
|
* Type of Program: Utility
|
||||||
|
* Description: Plain-text {{placeholder}} substitution — no eval/Function, so
|
||||||
|
* admin-supplied HTML can never execute arbitrary JS. Unknown or
|
||||||
|
* missing keys resolve to an empty string rather than throwing.
|
||||||
|
* Author: Kenneth Obsequio (@lash0000)
|
||||||
|
* Date Created: Jul. 3, 2026
|
||||||
|
***********************************************************************************************************************************************************************
|
||||||
|
* HOW TO USE:
|
||||||
|
* const { renderTemplate } = require('../utils/renderTemplate.util');
|
||||||
|
* renderTemplate('Hi {{name}}', { name: 'Ken' }); // "Hi Ken"
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
|
||||||
|
const renderTemplate = (str, data = {}) =>
|
||||||
|
String(str ?? '').replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
|
||||||
|
const val = data[key];
|
||||||
|
return (val === undefined || val === null) ? '' : String(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { renderTemplate };
|
||||||
Reference in New Issue
Block a user