add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-04 03:35:09 +08:00
parent a572c1e25f
commit 3c13bb9821
36 changed files with 896 additions and 928 deletions
@@ -390,3 +390,50 @@ exports.getArchivedAdvertisements = async (req, res) => {
};
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);
}
};
+77
View File
@@ -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) => {
+148
View File
@@ -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);
}
};
+127
View File
@@ -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");
+69 -1
View File
@@ -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 {
+124
View File
@@ -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) => {
+14 -5
View File
@@ -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();
-3
View File
@@ -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 ──────────────────────────────────────
-110
View File
@@ -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,
};
+117
View File
@@ -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 };
-28
View File
@@ -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;
-4
View File
@@ -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);
+2
View File
@@ -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;
+2
View File
@@ -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;
+8
View File
@@ -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
-13
View File
@@ -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;
-14
View File
@@ -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;
+2
View File
@@ -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;
+5
View File
@@ -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) ─────────────────────────────────────────
+3
View File
@@ -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;
+2
View File
@@ -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 ──────────────────────────────────────────────────────────────────────
+8 -31
View File
@@ -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(
+45
View File
@@ -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 };