From 3c13bb9821fb73f5afe06fde24102ee386a91c91 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sat, 4 Jul 2026 03:35:09 +0800 Subject: [PATCH] add: more things Signed-off-by: Kenneth Obsequio --- .../admin/advertisements.controller.js | 49 ++++- controllers/admin/assets.controller.js | 77 ++++++++ controllers/admin/courses.controller.js | 148 +++++++++++++++ .../admin/email_broadcasts.controller.js | 173 ------------------ .../admin/email_templates.controller.js | 173 ------------------ .../notificationBroadcasts.controller.js | 47 +++++ controllers/admin/task.controller.js | 127 +++++++++++++ controllers/admin/tiers.controller.js | 70 ++++++- controllers/admin/user_groups.controller.js | 46 +++++ controllers/admin/users.controller.js | 124 +++++++++++++ controllers/auth.controller.js | 19 +- cron/admin.cron.js | 3 - cron/jobs/dispatch_email_broadcasts.cron.js | 110 ----------- data/email_body.data.js | 117 ++++++++++++ data/email_template_enrichers.data.js | 28 --- .../20260703000003-create-email-templates.js | 114 ------------ ...3000004-add-category-to-email-templates.js | 21 --- ...add-publish-workflow-to-email-templates.js | 46 ----- .../20260703000006-create-email-broadcasts.js | 47 ----- ...-add-markdown-source-to-email-templates.js | 24 --- models/email_templates/email_broadcast.mdl.js | 38 ---- .../email_broadcast_recipient.mdl.js | 33 ---- models/email_templates/email_templates.mdl.js | 49 ----- routes/admin/admin.routes.js | 4 - routes/admin/advertisements.routes.js | 2 + routes/admin/assets.routes.js | 2 + routes/admin/courses.routes.js | 8 + routes/admin/email_broadcasts.routes.js | 13 -- routes/admin/email_templates.routes.js | 14 -- routes/admin/groups.routes.js | 2 + routes/admin/notificationBroadcasts.routes.js | 2 + routes/admin/task.routes.js | 5 + routes/admin/tiers.routes.js | 3 + routes/admin/users.routes.js | 2 + services/email.service.js | 39 +--- utils/courses/permanentDelete.util.js | 45 +++++ 36 files changed, 896 insertions(+), 928 deletions(-) delete mode 100644 controllers/admin/email_broadcasts.controller.js delete mode 100644 controllers/admin/email_templates.controller.js delete mode 100644 cron/jobs/dispatch_email_broadcasts.cron.js create mode 100644 data/email_body.data.js delete mode 100644 data/email_template_enrichers.data.js delete mode 100644 database/migrations/20260703000003-create-email-templates.js delete mode 100644 database/migrations/20260703000004-add-category-to-email-templates.js delete mode 100644 database/migrations/20260703000005-add-publish-workflow-to-email-templates.js delete mode 100644 database/migrations/20260703000006-create-email-broadcasts.js delete mode 100644 database/migrations/20260703000007-add-markdown-source-to-email-templates.js delete mode 100644 models/email_templates/email_broadcast.mdl.js delete mode 100644 models/email_templates/email_broadcast_recipient.mdl.js delete mode 100644 models/email_templates/email_templates.mdl.js delete mode 100644 routes/admin/email_broadcasts.routes.js delete mode 100644 routes/admin/email_templates.routes.js create mode 100644 utils/courses/permanentDelete.util.js diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js index 56a8cdb..8f94471 100644 --- a/controllers/admin/advertisements.controller.js +++ b/controllers/admin/advertisements.controller.js @@ -389,4 +389,51 @@ exports.getArchivedAdvertisements = async (req, res) => { } }; -exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT"); \ No newline at end of file +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); + } +}; \ No newline at end of file diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index 92453cd..6782813 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -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) => { diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 91197b8..028216c 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -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 // ══════════════════════════════════════════════════════════════════════════════ diff --git a/controllers/admin/email_broadcasts.controller.js b/controllers/admin/email_broadcasts.controller.js deleted file mode 100644 index 660fcab..0000000 --- a/controllers/admin/email_broadcasts.controller.js +++ /dev/null @@ -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); - } -}; diff --git a/controllers/admin/email_templates.controller.js b/controllers/admin/email_templates.controller.js deleted file mode 100644 index c4023e9..0000000 --- a/controllers/admin/email_templates.controller.js +++ /dev/null @@ -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); - } -}; diff --git a/controllers/admin/notificationBroadcasts.controller.js b/controllers/admin/notificationBroadcasts.controller.js index e840c2c..408dbc8 100644 --- a/controllers/admin/notificationBroadcasts.controller.js +++ b/controllers/admin/notificationBroadcasts.controller.js @@ -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); + } +}; diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 797e329..64b62ac 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -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"); \ No newline at end of file diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index 00b7fd5..b9e6a4e 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -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: [], }); diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 6d06bed..3e4c694 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -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 { diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index c0bed21..28e9d9e 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -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) => { diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index c6c7cd4..9077724 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -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(); diff --git a/cron/admin.cron.js b/cron/admin.cron.js index 06d8045..5e1a497 100644 --- a/cron/admin.cron.js +++ b/cron/admin.cron.js @@ -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 ────────────────────────────────────── diff --git a/cron/jobs/dispatch_email_broadcasts.cron.js b/cron/jobs/dispatch_email_broadcasts.cron.js deleted file mode 100644 index 38d289c..0000000 --- a/cron/jobs/dispatch_email_broadcasts.cron.js +++ /dev/null @@ -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, -}; diff --git a/data/email_body.data.js b/data/email_body.data.js new file mode 100644 index 0000000..28f8acc --- /dev/null +++ b/data/email_body.data.js @@ -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) => ` + + + ${body.trim()} +

+

Regards,
Philproperties IT Team

+

This is an automated message from STARR System. Please do not reply.

+ +`.trim(); + +const emailTemplates = { + OTP: ({ otp, expiryMinutes = 10 }) => ({ + subject: "Email OTP Verification - STARR System", + html: wrap(` +

Dear User,

+

Please use the One-Time Password (OTP) below to verify your email address. This code is valid for ${expiryMinutes} minutes.

+

${otp}

+

For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.

+ `), + }), + + WELCOME: ({ name }) => ({ + subject: "Welcome to STARR System", + html: wrap(` +

Dear ${name},

+

We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.

+

You may now access your dashboard and begin using the available services.

+

We look forward to supporting you.

+ `), + }), + + PASSWORD_CHANGED: () => ({ + subject: "Password Update Confirmation - STARR System", + html: wrap(` +

Dear User,

+

This is to confirm that your account password has been successfully changed.

+

If you did not perform this action, please reset your password immediately or contact support.

+

For your security, we recommend using a strong and unique password.

+ `), + }), + + ADDED_TO_GROUP: ({ groupName }) => ({ + subject: "Group Assignment Notification - STARR System", + html: wrap(` +

Dear User,

+

You have been assigned to the group ${groupName} in the STARR System.

+

This assignment grants you access to shared resources and collaboration tools within the group.

+

Please log in to your account to view group details.

+ `), + }), + + TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({ + subject: "New Task Assignment - STARR System", + html: wrap(` +

Dear User,

+

You have been assigned a new task in the STARR System.

+ + + +
Task${taskTitle}
Due Date${dueDate}
+

Kindly ensure completion within the specified timeframe.

+ `), + }), + + BAN_LIFTED: ({ name, email, date }) => ({ + subject: "Account Suspension Lifted - STARR System", + html: wrap(` +

Dear ${name},

+

We are writing to inform you that the suspension on your account (${email}) has been lifted effective ${date}.

+

You may now log in and resume access to all services within the STARR System.

+

If you have any concerns, please do not hesitate to contact your administrator.

+ `), + }), + + BANNED: ({ name, email, date, reason, ban_type }) => ({ + subject: "Account Suspension Notice - STARR System", + html: wrap(` +

Dear ${name},

+

Your account (${email}) has been ${ban_type === 'permanent' ? 'permanently' : 'temporarily'} suspended from the STARR System effective ${date}.

+ + + +
Reason${reason}
Duration${ban_type === 'permanent' ? 'Permanent' : 'Temporary'}
+

During this period, access to all system services has been revoked.${ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.'}

+

If you believe this was made in error, please contact your administrator.

+ `), + }), + + ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({ + subject: "Your Staff Account Has Been Created - STARR System", + html: wrap(` +

Dear ${name},

+

Your staff account has been successfully created in the STARR System. Below are your login credentials:

+ + + +
Email${email}
Password${password}
+

This temporary password is valid for ${expiryHours} hours. You will be required to change it upon first login.

+

If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.

+

For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.

+ `), + }), +}; + +module.exports = { emailTemplates }; diff --git a/data/email_template_enrichers.data.js b/data/email_template_enrichers.data.js deleted file mode 100644 index 8d19191..0000000 --- a/data/email_template_enrichers.data.js +++ /dev/null @@ -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 }; diff --git a/database/migrations/20260703000003-create-email-templates.js b/database/migrations/20260703000003-create-email-templates.js deleted file mode 100644 index d8122f3..0000000 --- a/database/migrations/20260703000003-create-email-templates.js +++ /dev/null @@ -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 / - // 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: `

Dear User,

-

Please use the One-Time Password (OTP) below to verify your email address. This code is valid for {{expiryMinutes}} minutes.

-

{{otp}}

-

For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.

`, - createdAt: now, updatedAt: now, - }, - { - type: 'WELCOME', label: 'Welcome Email', is_system: true, - subject: 'Welcome to STARR System', - html_body: `

Dear {{name}},

-

We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.

-

You may now access your dashboard and begin using the available services.

-

We look forward to supporting you.

`, - createdAt: now, updatedAt: now, - }, - { - type: 'PASSWORD_CHANGED', label: 'Password Changed', is_system: true, - subject: 'Password Update Confirmation - STARR System', - html_body: `

Dear User,

-

This is to confirm that your account password has been successfully changed.

-

If you did not perform this action, please reset your password immediately or contact support.

-

For your security, we recommend using a strong and unique password.

`, - createdAt: now, updatedAt: now, - }, - { - type: 'ADDED_TO_GROUP', label: 'Added to Group', is_system: true, - subject: 'Group Assignment Notification - STARR System', - html_body: `

Dear User,

-

You have been assigned to the group {{groupName}} in the STARR System.

-

This assignment grants you access to shared resources and collaboration tools within the group.

-

Please log in to your account to view group details.

`, - createdAt: now, updatedAt: now, - }, - { - type: 'TASK_ASSIGNED', label: 'Task Assigned', is_system: true, - subject: 'New Task Assignment - STARR System', - html_body: `

Dear User,

-

You have been assigned a new task in the STARR System.

- - - -
Task{{taskTitle}}
Due Date{{dueDate}}
-

Kindly ensure completion within the specified timeframe.

`, - createdAt: now, updatedAt: now, - }, - { - type: 'BAN_LIFTED', label: 'Ban Lifted', is_system: true, - subject: 'Account Suspension Lifted - STARR System', - html_body: `

Dear {{name}},

-

We are writing to inform you that the suspension on your account ({{email}}) has been lifted effective {{date}}.

-

You may now log in and resume access to all services within the STARR System.

-

If you have any concerns, please do not hesitate to contact your administrator.

`, - createdAt: now, updatedAt: now, - }, - { - type: 'BANNED', label: 'Account Banned', is_system: true, - subject: 'Account Suspension Notice - STARR System', - html_body: `

Dear {{name}},

-

Your account ({{email}}) has been {{duration_word}} suspended from the STARR System effective {{date}}.

- - - -
Reason{{reason}}
Duration{{duration_label}}
-

During this period, access to all system services has been revoked.{{suspension_note}}

-

If you believe this was made in error, please contact your administrator.

`, - 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: `

Dear {{name}},

-

Your staff account has been successfully created in the STARR System. Below are your login credentials:

- - - -
Email{{email}}
Password{{password}}
-

This temporary password is valid for {{expiryHours}} hours. You will be required to change it upon first login.

-

If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.

-

For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.

`, - createdAt: now, updatedAt: now, - }, - ]); - }, - - async down(queryInterface) { - await queryInterface.dropTable('email_templates'); - }, -}; diff --git a/database/migrations/20260703000004-add-category-to-email-templates.js b/database/migrations/20260703000004-add-category-to-email-templates.js deleted file mode 100644 index b9e44b2..0000000 --- a/database/migrations/20260703000004-add-category-to-email-templates.js +++ /dev/null @@ -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";`); - }, -}; diff --git a/database/migrations/20260703000005-add-publish-workflow-to-email-templates.js b/database/migrations/20260703000005-add-publish-workflow-to-email-templates.js deleted file mode 100644 index 86a8ffa..0000000 --- a/database/migrations/20260703000005-add-publish-workflow-to-email-templates.js +++ /dev/null @@ -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";`); - }, -}; diff --git a/database/migrations/20260703000006-create-email-broadcasts.js b/database/migrations/20260703000006-create-email-broadcasts.js deleted file mode 100644 index 6b4f649..0000000 --- a/database/migrations/20260703000006-create-email-broadcasts.js +++ /dev/null @@ -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";`); - }, -}; diff --git a/database/migrations/20260703000007-add-markdown-source-to-email-templates.js b/database/migrations/20260703000007-add-markdown-source-to-email-templates.js deleted file mode 100644 index 78d36f0..0000000 --- a/database/migrations/20260703000007-add-markdown-source-to-email-templates.js +++ /dev/null @@ -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'); - }, -}; diff --git a/models/email_templates/email_broadcast.mdl.js b/models/email_templates/email_broadcast.mdl.js deleted file mode 100644 index 8a8ebe4..0000000 --- a/models/email_templates/email_broadcast.mdl.js +++ /dev/null @@ -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; diff --git a/models/email_templates/email_broadcast_recipient.mdl.js b/models/email_templates/email_broadcast_recipient.mdl.js deleted file mode 100644 index 86e2e08..0000000 --- a/models/email_templates/email_broadcast_recipient.mdl.js +++ /dev/null @@ -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; diff --git a/models/email_templates/email_templates.mdl.js b/models/email_templates/email_templates.mdl.js deleted file mode 100644 index 574484f..0000000 --- a/models/email_templates/email_templates.mdl.js +++ /dev/null @@ -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; diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 24a4e5c..c7b2c1a 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -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); diff --git a/routes/admin/advertisements.routes.js b/routes/admin/advertisements.routes.js index 0711249..07ef863 100644 --- a/routes/admin/advertisements.routes.js +++ b/routes/admin/advertisements.routes.js @@ -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; \ No newline at end of file diff --git a/routes/admin/assets.routes.js b/routes/admin/assets.routes.js index ea9541b..2b54e6e 100644 --- a/routes/admin/assets.routes.js +++ b/routes/admin/assets.routes.js @@ -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; \ No newline at end of file diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index 8a29a68..5a848d8 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -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 diff --git a/routes/admin/email_broadcasts.routes.js b/routes/admin/email_broadcasts.routes.js deleted file mode 100644 index cc1b380..0000000 --- a/routes/admin/email_broadcasts.routes.js +++ /dev/null @@ -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; diff --git a/routes/admin/email_templates.routes.js b/routes/admin/email_templates.routes.js deleted file mode 100644 index 4d05d83..0000000 --- a/routes/admin/email_templates.routes.js +++ /dev/null @@ -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; diff --git a/routes/admin/groups.routes.js b/routes/admin/groups.routes.js index d31c5ef..87d9a45 100644 --- a/routes/admin/groups.routes.js +++ b/routes/admin/groups.routes.js @@ -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); diff --git a/routes/admin/notificationBroadcasts.routes.js b/routes/admin/notificationBroadcasts.routes.js index 915021c..7a9a59c 100644 --- a/routes/admin/notificationBroadcasts.routes.js +++ b/routes/admin/notificationBroadcasts.routes.js @@ -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; diff --git a/routes/admin/task.routes.js b/routes/admin/task.routes.js index 12cd8ad..24303dc 100644 --- a/routes/admin/task.routes.js +++ b/routes/admin/task.routes.js @@ -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) ───────────────────────────────────────── diff --git a/routes/admin/tiers.routes.js b/routes/admin/tiers.routes.js index dfe09af..e67518b 100644 --- a/routes/admin/tiers.routes.js +++ b/routes/admin/tiers.routes.js @@ -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; \ No newline at end of file diff --git a/routes/admin/users.routes.js b/routes/admin/users.routes.js index a013228..009492b 100644 --- a/routes/admin/users.routes.js +++ b/routes/admin/users.routes.js @@ -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 ────────────────────────────────────────────────────────────────────── diff --git a/services/email.service.js b/services/email.service.js index 37ddb2f..d3b6696 100644 --- a/services/email.service.js +++ b/services/email.service.js @@ -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) => ` - - - ${body.trim()} -

-

Regards,
Philproperties IT Team

-

This is an automated message from STARR System. Please do not reply.

- -`.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( diff --git a/utils/courses/permanentDelete.util.js b/utils/courses/permanentDelete.util.js new file mode 100644 index 0000000..18aeea9 --- /dev/null +++ b/utils/courses/permanentDelete.util.js @@ -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 };