mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -389,4 +389,51 @@ exports.getArchivedAdvertisements = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT");
|
||||
exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT");
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
if (!advertisement.deletedAt) return R.error(res, "Advertisement must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await advertisement.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||
return R.success(res, "Advertisement permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisement.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisements = 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 advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!advertisements.length) return R.error(res, "No advertisements found.", 404);
|
||||
|
||||
const archived = advertisements.filter((a) => a.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected advertisements must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((a) => a.advertisement_id);
|
||||
|
||||
await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} advertisement(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisements.", 500);
|
||||
}
|
||||
};
|
||||
@@ -660,6 +660,83 @@ exports.restoreAssets = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ─────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||
if (!asset.deletedAt) return R.error(res, "Asset must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
|
||||
|
||||
await asset.destroy({ force: true });
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (svc) {
|
||||
if (storage_key) {
|
||||
try { await svc.deleteFile(storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${storage_key}":`, err.message); }
|
||||
}
|
||||
if (thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ASSET][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAssets = 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 assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!assets.length) return R.error(res, "No assets found.", 404);
|
||||
|
||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||
if (!archivedAssets.length) return R.error(res, "All selected assets must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
for (const asset of archivedAssets) {
|
||||
const svc = getProvider(asset.storage_provider);
|
||||
if (!svc) continue;
|
||||
if (asset.storage_key) {
|
||||
try { await svc.deleteFile(asset.storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${asset.storage_key}":`, err.message); }
|
||||
}
|
||||
if (asset.thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(asset.thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${asset.thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} asset(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedAssets = async (req, res) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/cour
|
||||
const { syncJunction } = require("../../utils/courses/junction.util");
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
@@ -313,6 +314,57 @@ exports.getCourseArchiveImpact = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteCourse = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const record = await permanentDeleteOne(Course, { course_id: courseId }, t);
|
||||
if (record === null) return R.error(res, "Course not found.", 404);
|
||||
if (record === false) return R.error(res, "Course must be archived before it can be permanently deleted.", 400);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_course', { entityType: 'course', entityId: Number(courseId) });
|
||||
return R.success(res, "Course permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[COURSE][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete course.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteCourses = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const count = await permanentDeleteMany(Course, "course_id", ids, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_courses', { entityType: 'course', details: { ids, count } });
|
||||
return R.success(res, `${count} course${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[COURSE][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete courses.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCoursePermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
|
||||
const unitCount = await Unit.count({ where: { course_id: courseId }, paranoid: false });
|
||||
const units = await Unit.findAll({ where: { course_id: courseId }, attributes: ["unit_id"], paranoid: false });
|
||||
const lessonCount = units.length
|
||||
? await Lesson.count({ where: { unit_id: units.map((u) => u.unit_id) }, paranoid: false })
|
||||
: 0;
|
||||
|
||||
return R.success(res, "Impact retrieved.", { unitCount, lessonCount });
|
||||
} catch (err) {
|
||||
console.error("[COURSE][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COURSE PREREQUISITES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -600,6 +652,57 @@ exports.bulkRestoreUnits = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId, unitId } = req.params;
|
||||
const record = await permanentDeleteOne(Unit, { unit_id: unitId, course_id: courseId }, t);
|
||||
if (record === null) return R.error(res, "Unit not found.", 404);
|
||||
if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_unit', { entityType: 'unit', entityId: Number(unitId) });
|
||||
return R.success(res, "Unit permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId }, paranoid: false });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await permanentDeleteMany(Unit, "unit_id", validIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_units', { entityType: 'unit', details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const lessonCount = await Lesson.count({ where: { unit_id: unitId }, paranoid: false });
|
||||
|
||||
return R.success(res, "Impact retrieved.", { lessonCount });
|
||||
} catch (err) {
|
||||
console.error("[UNIT][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -859,6 +962,51 @@ exports.bulkRestoreLessons = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId, unitId, lessonId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const record = await permanentDeleteOne(Lesson, { lesson_id: lessonId, unit_id: unitId }, t);
|
||||
if (record === null) return R.error(res, "Lesson not found.", 404);
|
||||
if (record === false) return R.error(res, "Lesson must be archived before it can be permanently deleted.", 400);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId, unitId } = req.params;
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids, unit_id: unitId }, paranoid: false });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON PAGE
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
'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);
|
||||
}
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
'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);
|
||||
}
|
||||
};
|
||||
@@ -419,3 +419,50 @@ exports.getArchivedBroadcasts = async (req, res) => {
|
||||
return R.error(res, "Could not retrieve archived notification broadcasts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcast = 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 must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await broadcast.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Notification broadcast permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete notification broadcast.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcasts = 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 must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||
|
||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} notification broadcast(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete notification broadcasts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@ const { filterableFields } = require('../../models/task/task.attributes');
|
||||
const { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { TaskCompletion } = require('../../models/task/task_completion.mdl');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
|
||||
@@ -260,6 +262,76 @@ exports.bulkRestoreTaskLists = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
|
||||
const record = await permanentDeleteOne(TaskList, { task_list_id: taskListId }, t);
|
||||
if (record === null) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
if (record === false) { await t.rollback(); return R.error(res, 'Task list must be archived before it can be permanently deleted.', 400); }
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
|
||||
return R.success(res, 'Task list permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE TASK LIST]', err);
|
||||
return R.error(res, 'Could not permanently delete task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteTaskLists = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task list IDs provided.', 400);
|
||||
|
||||
const taskLists = await TaskList.findAll({ where: { task_list_id: ids }, paranoid: false });
|
||||
if (!taskLists.length) return R.error(res, 'No task lists found.', 404);
|
||||
|
||||
const deletedIds = taskLists.filter((tl) => tl.deletedAt).map((tl) => tl.task_list_id);
|
||||
if (!deletedIds.length)
|
||||
return R.error(res, 'All selected task lists must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const count = await permanentDeleteMany(TaskList, 'task_list_id', deletedIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_task_lists', { entityType: 'task_list', details: { ids: deletedIds, count } });
|
||||
return R.success(res, `${count} task list(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE TASK LISTS]', err);
|
||||
return R.error(res, 'Could not permanently delete task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE IMPACT ──────────────────────────────────────────────────
|
||||
|
||||
exports.getTaskListPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
|
||||
const taskCount = await Task.count({ where: { task_list_id: taskListId }, paranoid: false });
|
||||
const tasks = await Task.findAll({ where: { task_list_id: taskListId }, attributes: ['task_id'], paranoid: false });
|
||||
const completionCount = tasks.length
|
||||
? await TaskCompletion.count({ where: { task_id: tasks.map((task) => task.task_id) }, paranoid: false })
|
||||
: 0;
|
||||
|
||||
return R.success(res, 'Impact retrieved.', { taskCount, completionCount });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][TASK LIST PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK LIST GROUPS (assign / unassign / list) ──────────────────────────────
|
||||
// =============================================================================
|
||||
@@ -848,5 +920,60 @@ exports.bulkRestoreTasks = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
|
||||
const record = await permanentDeleteOne(Task, { task_id: taskId, task_list_id: taskListId }, t);
|
||||
if (record === null) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
if (record === false) { await t.rollback(); return R.error(res, 'Task must be archived before it can be permanently deleted.', 400); }
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_task', { entityType: 'task', entityId: Number(taskId) });
|
||||
return R.success(res, 'Task permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE TASK]', err);
|
||||
return R.error(res, 'Could not permanently delete task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteTasks = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task IDs provided.', 400);
|
||||
|
||||
const tasks = await Task.findAll({
|
||||
where: { task_id: ids, task_list_id: taskListId },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!tasks.length) return R.error(res, 'No tasks found.', 404);
|
||||
|
||||
const deletedIds = tasks.filter((task) => task.deletedAt).map((task) => task.task_id);
|
||||
if (!deletedIds.length)
|
||||
return R.error(res, 'All selected tasks must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const count = await permanentDeleteMany(Task, 'task_id', deletedIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_tasks', { entityType: 'task', details: { ids: deletedIds, count } });
|
||||
return R.success(res, `${count} task(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE TASKS]', err);
|
||||
return R.error(res, 'Could not permanently delete tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getTaskFieldValues = getFieldValues(Task, "TASK");
|
||||
exports.getTaskListFieldValues = getFieldValues(TaskList, "TASKLIST");
|
||||
@@ -8,7 +8,7 @@
|
||||
* Author: rgrgogu
|
||||
* Date Created: Jun. 6, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const { Op, ForeignKeyConstraintError } = require('sequelize');
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
@@ -279,6 +279,74 @@ exports.bulkRestorePlans = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlanPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id: req.params.id }, paranoid: false });
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const active_subscriber_count = await mdl_UserTiers.count({
|
||||
where: { plan_id: req.params.id, status: 'active' },
|
||||
});
|
||||
const payment_count = await mdl_Payments.count({ where: { plan_id: req.params.id } });
|
||||
|
||||
return R.success(res, 'Plan permanent-delete impact retrieved.', { active_subscriber_count, payment_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve plan permanent-delete impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeletePlan = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findOne({
|
||||
where: { plan_id: req.params.id }, paranoid: false,
|
||||
});
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
if (!plan.deletedAt) return R.error(res, 'Plan must be archived before it can be permanently deleted.', 400);
|
||||
|
||||
await plan.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
|
||||
return R.success(res, 'Plan permanently deleted.');
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: this plan still has payment records on file. Payment history is preserved and cannot be removed.', 400);
|
||||
}
|
||||
console.error('[ADMIN][PERMANENT DELETE PLAN]', err);
|
||||
return R.error(res, 'Could not permanently delete plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeletePlans = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No plan IDs provided.', 400);
|
||||
|
||||
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false });
|
||||
if (!plans.length) return R.error(res, 'No plans found.', 404);
|
||||
|
||||
const archivedPlans = plans.filter((p) => p.deletedAt);
|
||||
if (!archivedPlans.length)
|
||||
return R.error(res, 'All selected plans must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const archivedIds = archivedPlans.map((p) => p.plan_id);
|
||||
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: one or more selected plans still have payment records on file. Payment history is preserved and cannot be removed.', 400);
|
||||
}
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err);
|
||||
return R.error(res, 'Could not permanently delete plans.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlanFieldValues = getFieldValues(mdl_TierPlans, 'TIER_PLAN', {
|
||||
blockedFields: [],
|
||||
});
|
||||
|
||||
@@ -253,6 +253,52 @@ exports.bulkRestoreGroups = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
exports.permanentlyDeleteGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.deletedAt) return R.error(res, 'Group must be deactivated before it can be permanently deleted.', 400);
|
||||
|
||||
await group.destroy({ force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_group', { entityType: 'group', entityId: group.group_id });
|
||||
return R.success(res, 'Group permanently deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PERMANENT DELETE GROUP]', err);
|
||||
return R.error(res, 'Could not permanently delete group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
exports.bulkPermanentlyDeleteGroups = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||
if (!deletedGroups.length)
|
||||
return R.error(res, 'All selected groups must be deactivated before they can be permanently deleted.', 400);
|
||||
|
||||
const deletedIds = deletedGroups.map((g) => g.group_id);
|
||||
|
||||
await mdl_UserGroups.destroy({ where: { group_id: deletedIds }, force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } });
|
||||
return R.success(res, `${deletedIds.length} group(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE GROUPS]', err);
|
||||
return R.error(res, 'Could not permanently delete groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||
exports.getArchivedGroups = async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,9 @@ const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||
const mdl_UserBans = require('../../models/users/user_bans.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
@@ -291,6 +294,127 @@ exports.bulkRestoreUsers = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The live DB has several FKs on users.user_id that are NO ACTION rather than
|
||||
// the CASCADE the migration source claims (schema drift) — achievements,
|
||||
// quiz_attempts, user_tiers, and payments all block a hard delete unless their
|
||||
// rows for this user are removed first. Also null out the "who did this"
|
||||
// actor columns (granted_by/revoked_by/lifted_by) on OTHER users' records,
|
||||
// since those aren't this user's own data and shouldn't be deleted.
|
||||
//
|
||||
// UserBan.banned_by is NOT NULL (a ban record must always keep its banner),
|
||||
// so a user who has ever banned someone can't be nulled out or hard-deleted —
|
||||
// getBannerBlockedIds() below identifies those and callers must exclude them.
|
||||
async function purgeUserDependents(userIds, t) {
|
||||
await mdl_Achievements.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_QuizAttempt.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_UserTiers.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_Payments.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
|
||||
await mdl_Achievements.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ revoked_by: null }, { where: { revoked_by: userIds }, transaction: t });
|
||||
await mdl_UserBans.update({ lifted_by: null }, { where: { lifted_by: userIds }, transaction: t });
|
||||
}
|
||||
|
||||
async function getBannerBlockedIds(userIds, t) {
|
||||
const bans = await mdl_UserBans.findAll({
|
||||
where: { banned_by: userIds }, attributes: ['banned_by'], group: ['banned_by'], transaction: t,
|
||||
});
|
||||
return bans.map((b) => b.banned_by);
|
||||
}
|
||||
|
||||
exports.permanentlyDeleteUser = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id }, paranoid: false, transaction: t,
|
||||
});
|
||||
if (!user) { await t.rollback(); return R.error(res, 'User not found.', 404); }
|
||||
if (!user.deletedAt) { await t.rollback(); return R.error(res, 'User must be deactivated before it can be permanently deleted.', 400); }
|
||||
|
||||
const bannerBlockedIds = await getBannerBlockedIds([user.user_id], t);
|
||||
if (bannerBlockedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: this user has banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents([user.user_id], t);
|
||||
await user.destroy({ force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_user', { entityType: 'user', entityId: Number(req.params.id) });
|
||||
|
||||
return R.success(res, 'User permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE USER]', err);
|
||||
return R.error(res, 'Could not permanently delete user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteUsers = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
}
|
||||
|
||||
if (ids.includes(req.user.user_id)) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false, transaction: t });
|
||||
if (!users.length) { await t.rollback(); return R.error(res, 'No users found.', 404); }
|
||||
|
||||
const deletedUsers = users.filter((u) => u.deletedAt);
|
||||
if (!deletedUsers.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected users must be deactivated before they can be permanently deleted.', 400);
|
||||
}
|
||||
|
||||
const archivedIds = deletedUsers.map((u) => u.user_id);
|
||||
const bannerBlockedIds = await getBannerBlockedIds(archivedIds, t);
|
||||
const deletedIds = archivedIds.filter((id) => !bannerBlockedIds.includes(id));
|
||||
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: all selected users have banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents(deletedIds, t);
|
||||
await mdl_Users.destroy({ where: { user_id: deletedIds }, force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_users', {
|
||||
entityType: 'user',
|
||||
details: { ids: deletedIds, count: deletedIds.length },
|
||||
});
|
||||
|
||||
return R.success(res, `${deletedIds.length} user(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE USERS]', err);
|
||||
return R.error(res, 'Could not permanently delete users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SESSIONS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* GET /api/auth/google/callback
|
||||
***********************************************************************************************************************************************************************/
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const sequelize = require('../config/db.config')
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
|
||||
@@ -103,10 +104,13 @@ exports.register = async (req, res) => {
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await sendEmail({ to: email, type: 'OTP', data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||||
// an otherwise-successful registration — resendOTP covers redelivery.
|
||||
sendEmail({ to: email, type: 'OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||
|
||||
// Fire-and-forget: notify admins — explicit group or NOGRP fallback
|
||||
if (group) {
|
||||
renderNotification({ type: 'user_registration', data: {
|
||||
@@ -146,7 +150,9 @@ exports.verifyOTP = async (req, res) => {
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_verified) return R.error(res, 'Account already verified.', 400);
|
||||
|
||||
if (!crypto.timingSafeEqual(Buffer.from(user.otp_code ?? ''), Buffer.from(otp)))
|
||||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||
const givenOTP = Buffer.from(otp ?? '');
|
||||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||
return R.error(res, 'Invalid OTP.', 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||||
|
||||
@@ -235,10 +241,13 @@ exports.resendOTP = async (req, res) => {
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "OTP", data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||||
// the already-persisted OTP refresh.
|
||||
sendEmail({ to: email, type: "OTP", data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||
|
||||
return R.success(res, 'A new OTP has been sent to your email.');
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
* Currently registered:
|
||||
* - 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)
|
||||
* Date Created: Jun. 17, 2026
|
||||
@@ -31,7 +30,6 @@
|
||||
const cron = require('node-cron');
|
||||
const taskOverdue = require('./jobs/task_overdue.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 ─────────────────────────
|
||||
@@ -42,7 +40,6 @@ const settingsBackedJobs = [
|
||||
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
||||
const plainJobs = [
|
||||
liftExpiredBans,
|
||||
dispatchEmailBroadcasts,
|
||||
];
|
||||
|
||||
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: email_body.data.js
|
||||
* Type of Program: Data
|
||||
* Description: Hardcoded subject/body per email type, keyed by the `type`
|
||||
* string passed to services/email.service.js's sendEmail(). To
|
||||
* add or edit an email, edit the template function here directly
|
||||
* and redeploy — there is no admin UI or DB table for this.
|
||||
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
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 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>
|
||||
`),
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = { emailTemplates };
|
||||
@@ -1,28 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 };
|
||||
@@ -1,114 +0,0 @@
|
||||
'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');
|
||||
},
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
'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";`);
|
||||
},
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
'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";`);
|
||||
},
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
'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";`);
|
||||
},
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
'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');
|
||||
},
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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;
|
||||
@@ -1,33 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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;
|
||||
@@ -1,49 +0,0 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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;
|
||||
@@ -41,8 +41,6 @@ const notificationBroadcastRoutes = require('./notificationBroadcasts.routes');
|
||||
const notificationSettingsRoutes = require('./notificationSettings.routes');
|
||||
const mediaRoutes = require('./media.routes');
|
||||
const achievementsRoutes = require('./achievements.routes');
|
||||
const emailTemplatesRoutes = require('./email_templates.routes');
|
||||
const emailBroadcastsRoutes = require('./email_broadcasts.routes');
|
||||
const notificationTemplatesRoutes = require('./notification_templates.routes');
|
||||
const activityCtrl = require('../../controllers/admin/user_activity.controller');
|
||||
|
||||
@@ -69,8 +67,6 @@ router.use('/notification-settings', notificationSettingsRoutes);
|
||||
router.use('/notification-templates', notificationTemplatesRoutes);
|
||||
router.use('/media', mediaRoutes);
|
||||
router.use('/achievements', achievementsRoutes);
|
||||
router.use('/email-templates', emailTemplatesRoutes);
|
||||
router.use('/email-broadcasts', emailBroadcastsRoutes);
|
||||
|
||||
// ── Activity feed — global ────────────────────────────────────────────────────
|
||||
router.get('/activity', activityCtrl.getActivity);
|
||||
|
||||
@@ -8,6 +8,7 @@ router.get('/archived', controller.getArchivedAdvertisements);
|
||||
router.get('/field-values', controller.getAdvertisementFieldValues);
|
||||
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveAdvertisements);
|
||||
router.patch('/bulk-restore', controller.restoreAdvertisements);
|
||||
router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAdvertisements);
|
||||
|
||||
// ─── Collection ───────────────────────────────────────────────────────────────
|
||||
router.get('/', controller.getAdvertisements);
|
||||
@@ -18,5 +19,6 @@ router.get('/:advertisementId', controller.getAdvertisement);
|
||||
router.patch('/:advertisementId', sensitiveOpsLimiter, controller.updateAdvertisement);
|
||||
router.patch('/:advertisementId/restore', sensitiveOpsLimiter, controller.restoreAdvertisement);
|
||||
router.delete('/:advertisementId', sensitiveOpsLimiter, controller.archiveAdvertisement);
|
||||
router.delete('/:advertisementId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAdvertisement);
|
||||
|
||||
module.exports = router;
|
||||
@@ -10,6 +10,7 @@ const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware
|
||||
router.get('/archived', controller.getArchivedAssets);
|
||||
router.get('/field-values', controller.getAssetFieldValues);
|
||||
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveAssets);
|
||||
router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAssets);
|
||||
router.patch('/bulk-restore', controller.restoreAssets);
|
||||
|
||||
// ─── Collection ───────────────────────────────────────────────────────────────
|
||||
@@ -21,5 +22,6 @@ router.get('/:assetId', controller.getAsset);
|
||||
router.patch('/:assetId', sensitiveOpsLimiter, upload.fields([{ name: 'file', maxCount: 1 }]), controller.updateAsset);
|
||||
router.patch('/:assetId/restore', sensitiveOpsLimiter, controller.restoreAsset);
|
||||
router.delete('/:assetId', sensitiveOpsLimiter, controller.archiveAsset);
|
||||
router.delete('/:assetId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteAsset);
|
||||
|
||||
module.exports = router;
|
||||
@@ -18,6 +18,7 @@ router.post("/", ctrl.createCourse);
|
||||
// ── static segments first ────────────────────────────────────────────────────
|
||||
router.get("/field-values", ctrl.getCourseFieldValues);
|
||||
router.delete("/bulk", ctrl.bulkArchiveCourses);
|
||||
router.delete("/bulk/permanent", ctrl.bulkPermanentlyDeleteCourses);
|
||||
router.get("/archives", ctrl.getArchivedCourses);
|
||||
router.patch("/restore/bulk", ctrl.bulkRestoreCourses);
|
||||
router.get("/flat", ctrl.getCoursesFlat);
|
||||
@@ -29,9 +30,11 @@ router.get("/lessons-flat", ctrl.getLessonsFlat);
|
||||
router.get("/archives/:courseId", ctrl.getArchivedCourse);
|
||||
router.patch("/:courseId/restore", ctrl.restoreCourse);
|
||||
router.get("/:courseId/archive-impact", ctrl.getCourseArchiveImpact);
|
||||
router.get("/:courseId/permanent-delete-impact", ctrl.getCoursePermanentDeleteImpact);
|
||||
router.get("/:courseId", ctrl.getCourse);
|
||||
router.put("/:courseId", ctrl.updateCourse);
|
||||
router.delete("/:courseId", ctrl.archiveCourse);
|
||||
router.delete("/:courseId/permanent", ctrl.permanentlyDeleteCourse);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COURSE INSTRUCTORS
|
||||
@@ -97,15 +100,18 @@ router.post("/:courseId/units", ctrl.createUnit);
|
||||
|
||||
// static before :unitId
|
||||
router.delete("/:courseId/units/bulk", ctrl.bulkArchiveUnits);
|
||||
router.delete("/:courseId/units/bulk/permanent", ctrl.bulkPermanentlyDeleteUnits);
|
||||
router.get("/:courseId/units/archives", ctrl.getArchivedUnits);
|
||||
router.patch("/:courseId/units/restore/bulk", ctrl.bulkRestoreUnits);
|
||||
|
||||
router.get("/:courseId/units/archives/:unitId", ctrl.getArchivedUnit);
|
||||
router.patch("/:courseId/units/:unitId/restore", ctrl.restoreUnit);
|
||||
router.get("/:courseId/units/:unitId/archive-impact", ctrl.getUnitArchiveImpact);
|
||||
router.get("/:courseId/units/:unitId/permanent-delete-impact", ctrl.getUnitPermanentDeleteImpact);
|
||||
router.get("/:courseId/units/:unitId", ctrl.getUnit);
|
||||
router.put("/:courseId/units/:unitId", ctrl.updateUnit);
|
||||
router.delete("/:courseId/units/:unitId", ctrl.archiveUnit);
|
||||
router.delete("/:courseId/units/:unitId/permanent", ctrl.permanentlyDeleteUnit);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIT QUIZ
|
||||
@@ -148,6 +154,7 @@ router.post("/:courseId/units/:unitId/lessons", ctrl.createLesson);
|
||||
|
||||
// static before :lessonId
|
||||
router.delete("/:courseId/units/:unitId/lessons/bulk", ctrl.bulkArchiveLessons);
|
||||
router.delete("/:courseId/units/:unitId/lessons/bulk/permanent", ctrl.bulkPermanentlyDeleteLessons);
|
||||
router.get("/:courseId/units/:unitId/lessons/archives", ctrl.getArchivedLessons);
|
||||
router.patch("/:courseId/units/:unitId/lessons/restore/bulk", ctrl.bulkRestoreLessons);
|
||||
|
||||
@@ -156,6 +163,7 @@ router.patch("/:courseId/units/:unitId/lessons/:lessonId/restore", ctrl.restoreL
|
||||
router.get("/:courseId/units/:unitId/lessons/:lessonId", ctrl.getLesson);
|
||||
router.put("/:courseId/units/:unitId/lessons/:lessonId", ctrl.updateLesson);
|
||||
router.delete("/:courseId/units/:unitId/lessons/:lessonId", ctrl.archiveLesson);
|
||||
router.delete("/:courseId/units/:unitId/lessons/:lessonId/permanent", ctrl.permanentlyDeleteLesson);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON PAGE
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
'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;
|
||||
@@ -1,14 +0,0 @@
|
||||
'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;
|
||||
@@ -8,6 +8,7 @@ const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware
|
||||
router.get('/field-values', groupsCtrl.getGroupFieldValues);
|
||||
router.post('/bulk/restore', sensitiveOpsLimiter, groupsCtrl.bulkRestoreGroups);
|
||||
router.delete('/bulk', sensitiveOpsLimiter, groupsCtrl.bulkDeactivateGroups);
|
||||
router.delete('/bulk/permanent', sensitiveOpsLimiter, groupsCtrl.bulkPermanentlyDeleteGroups);
|
||||
router.get('/archived', groupsCtrl.getArchivedGroups);
|
||||
router.get('/', groupsCtrl.getGroups);
|
||||
router.get('/:gid', groupsCtrl.getGroup);
|
||||
@@ -15,6 +16,7 @@ router.post('/', sensitiveOpsLimiter, groupsCtrl.createGroup);
|
||||
router.put('/:gid', sensitiveOpsLimiter, groupsCtrl.updateGroup);
|
||||
router.patch('/:gid/deactivate', sensitiveOpsLimiter, groupsCtrl.deactivateGroup);
|
||||
router.patch('/:gid/restore', sensitiveOpsLimiter, groupsCtrl.restoreGroup);
|
||||
router.delete('/:gid/permanent', sensitiveOpsLimiter, groupsCtrl.permanentlyDeleteGroup);
|
||||
router.get('/:gid/users', groupsCtrl.getUsersInGroup);
|
||||
router.get('/:gid/users/add', groupsCtrl.getUsersNotInGroup);
|
||||
router.post('/:gid/users', sensitiveOpsLimiter, groupsCtrl.addUserToGroup);
|
||||
|
||||
@@ -7,6 +7,7 @@ const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware
|
||||
router.get('/archived', controller.getArchivedBroadcasts);
|
||||
router.delete('/bulk', sensitiveOpsLimiter, controller.archiveBroadcasts);
|
||||
router.patch('/bulk-restore', controller.restoreBroadcasts);
|
||||
router.delete('/bulk/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteBroadcasts);
|
||||
|
||||
// ─── Collection ───────────────────────────────────────────────────────────────
|
||||
router.get('/', controller.getBroadcasts);
|
||||
@@ -18,5 +19,6 @@ router.patch('/:broadcastId', sensitiveOpsLimiter, controller.updateBroa
|
||||
router.patch('/:broadcastId/send', sensitiveOpsLimiter, controller.sendBroadcast);
|
||||
router.patch('/:broadcastId/restore', sensitiveOpsLimiter, controller.restoreBroadcast);
|
||||
router.delete('/:broadcastId', sensitiveOpsLimiter, controller.archiveBroadcast);
|
||||
router.delete('/:broadcastId/permanent', sensitiveOpsLimiter, controller.permanentlyDeleteBroadcast);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -10,11 +10,14 @@ router.get("/field-values", controller.getTaskListFieldValues);
|
||||
router.post("/", sensitiveOpsLimiter, controller.createTaskList);
|
||||
router.post("/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTaskLists);
|
||||
router.post("/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTaskLists);
|
||||
router.post("/bulk-delete", sensitiveOpsLimiter, controller.bulkPermanentlyDeleteTaskLists);
|
||||
|
||||
// ─── Task List (single) ───────────────────────────────────────────────────────
|
||||
router.get("/:taskListId", controller.getTaskList);
|
||||
router.get("/:taskListId/permanent-delete-impact", controller.getTaskListPermanentDeleteImpact);
|
||||
router.patch("/:taskListId", sensitiveOpsLimiter, controller.updateTaskList);
|
||||
router.delete("/:taskListId", sensitiveOpsLimiter, controller.archiveTaskList);
|
||||
router.delete("/:taskListId/permanent", sensitiveOpsLimiter, controller.permanentlyDeleteTaskList);
|
||||
router.patch("/:taskListId/restore", sensitiveOpsLimiter, controller.restoreTaskList);
|
||||
|
||||
// ─── Task List Groups ─────────────────────────────────────────────────────────
|
||||
@@ -29,11 +32,13 @@ router.get("/:taskListId/tasks/field-values", controller.getTaskFieldValues);
|
||||
router.post("/:taskListId/tasks", sensitiveOpsLimiter, controller.createTask);
|
||||
router.post("/:taskListId/tasks/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks);
|
||||
router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks);
|
||||
router.post("/:taskListId/tasks/bulk-delete", sensitiveOpsLimiter, controller.bulkPermanentlyDeleteTasks);
|
||||
|
||||
// ─── Tasks (single) ───────────────────────────────────────────────────────────
|
||||
router.get("/:taskListId/tasks/:taskId", controller.getTask);
|
||||
router.patch("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.updateTask);
|
||||
router.delete("/:taskListId/tasks/:taskId", sensitiveOpsLimiter, controller.archiveTask);
|
||||
router.delete("/:taskListId/tasks/:taskId/permanent", sensitiveOpsLimiter, controller.permanentlyDeleteTask);
|
||||
router.patch("/:taskListId/tasks/:taskId/restore", sensitiveOpsLimiter, controller.restoreTask);
|
||||
|
||||
// ─── Completions (nested under task) ─────────────────────────────────────────
|
||||
|
||||
@@ -8,6 +8,7 @@ router.post ('/', ctrl.createPlan);
|
||||
router.get ('/field-values', ctrl.getPlanFieldValues);
|
||||
router.post ('/bulk/archive', ctrl.bulkArchivePlans);
|
||||
router.post ('/bulk/restore', ctrl.bulkRestorePlans);
|
||||
router.post ('/bulk/permanent-delete', ctrl.bulkPermanentlyDeletePlans);
|
||||
|
||||
router.get ('/payments/field-values', ctrl.getPaymentFieldValues);
|
||||
router.get ('/payments', ctrl.getPayments);
|
||||
@@ -19,6 +20,7 @@ router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier);
|
||||
|
||||
// ← course + impact routes before /:id
|
||||
router.get ('/:id/impact', ctrl.getPlanImpact);
|
||||
router.get ('/:id/permanent-delete-impact', ctrl.getPlanPermanentDeleteImpact);
|
||||
router.get ('/:id/courses', ctrl.getPlanCourses);
|
||||
router.post ('/:id/courses', ctrl.syncPlanCourses);
|
||||
|
||||
@@ -26,5 +28,6 @@ router.get ('/:id', ctrl.getPlan);
|
||||
router.put ('/:id', ctrl.updatePlan);
|
||||
router.delete('/:id', ctrl.archivePlan);
|
||||
router.post ('/:id/restore', ctrl.restorePlan);
|
||||
router.delete('/:id/permanent', ctrl.permanentlyDeletePlan);
|
||||
|
||||
module.exports = router;
|
||||
@@ -7,6 +7,7 @@ const { sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware
|
||||
|
||||
router.post('/bulk/restore', sensitiveOpsLimiter, usersCtrl.bulkRestoreUsers);
|
||||
router.delete('/bulk', sensitiveOpsLimiter, usersCtrl.bulkDeactivateUsers);
|
||||
router.delete('/bulk/permanent', sensitiveOpsLimiter, usersCtrl.bulkPermanentlyDeleteUsers);
|
||||
router.get('/field-values', usersCtrl.getUserFieldValues);
|
||||
router.get('/archived', usersCtrl.getArchivedUsers);
|
||||
router.post('/staff', sensitiveOpsLimiter, usersCtrl.addStaffUser);
|
||||
@@ -14,6 +15,7 @@ router.get('/', usersCtrl.getUsers);
|
||||
router.get('/:id', usersCtrl.getUser);
|
||||
router.put('/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
|
||||
router.delete('/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser);
|
||||
router.delete('/:id/permanent', sensitiveOpsLimiter, usersCtrl.permanentlyDeleteUser);
|
||||
router.post('/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser);
|
||||
|
||||
// ── Bans ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,22 +2,18 @@
|
||||
* File Name: email.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Nodemailer-based email service.
|
||||
* Subject + body per type are loaded from the email_templates
|
||||
* table (admin-editable, see controllers/admin/email_templates.controller.js).
|
||||
* The outer layout (header/footer/signature) below is fixed in
|
||||
* code and is NOT admin-editable — only the body content is.
|
||||
* Subject + body per type are hardcoded in data/email_body.data.js —
|
||||
* there is no admin UI or DB table. To add or change an email,
|
||||
* edit that file directly and redeploy.
|
||||
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Oct. 6, 2025
|
||||
* Date Modified: Jul. 3, 2026 — templates moved from data/email_body.data.js into the DB
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const emailService = require('../services/email.service');
|
||||
* await emailService.sendEmail({ to: user.email, type: 'OTP', data: { otp } });
|
||||
***********************************************************************************************************************************************************************/
|
||||
const nodemailer = require('nodemailer');
|
||||
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 { emailTemplates } = require('../data/email_body.data');
|
||||
|
||||
const port = Number(process.env.SMTP_PORT);
|
||||
|
||||
@@ -35,34 +31,15 @@ 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 = {} }) => {
|
||||
try {
|
||||
const template = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||
if (!template) {
|
||||
const templateFn = emailTemplates[type];
|
||||
|
||||
if (!templateFn) {
|
||||
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 enriched = enrichEmailData(type, data);
|
||||
const subject = renderTemplate(template.subject, enriched);
|
||||
const html = wrap(renderTemplate(template.html_body, enriched));
|
||||
const { subject, html } = templateFn(data);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
transporter.sendMail(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
|
||||
/**
|
||||
* Permanently delete a single archived record.
|
||||
* Returns the record on success, null if not found, false if found but not archived.
|
||||
*
|
||||
* @param {Model} Model - Sequelize model
|
||||
* @param {object} where - Where clause (primary key lookup)
|
||||
* @param {object} t - Sequelize transaction
|
||||
*/
|
||||
async function permanentDeleteOne(Model, where, t) {
|
||||
const record = await Model.findOne({ where, paranoid: false, transaction: t });
|
||||
if (!record) return null;
|
||||
if (!record.deletedAt) return false;
|
||||
|
||||
await record.destroy({ force: true, transaction: t });
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete many archived records by primary key.
|
||||
* Returns the count of deleted records.
|
||||
*
|
||||
* @param {Model} Model - Sequelize model
|
||||
* @param {string} pkColumn - Primary key column name (e.g. "course_id")
|
||||
* @param {Array} ids - Array of primary key values to delete
|
||||
* @param {object} t - Sequelize transaction
|
||||
*/
|
||||
async function permanentDeleteMany(Model, pkColumn, ids, t) {
|
||||
if (!ids?.length) return 0;
|
||||
|
||||
const count = await Model.destroy({
|
||||
where: { [pkColumn]: ids, ...onlyDeleted },
|
||||
transaction: t,
|
||||
paranoid: false,
|
||||
force: true,
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
module.exports = { permanentDeleteOne, permanentDeleteMany };
|
||||
Reference in New Issue
Block a user