mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -389,4 +389,51 @@ exports.getArchivedAdvertisements = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT");
|
||||
exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT");
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
if (!advertisement.deletedAt) return R.error(res, "Advertisement must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await advertisement.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||
return R.success(res, "Advertisement permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisement.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisements = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!advertisements.length) return R.error(res, "No advertisements found.", 404);
|
||||
|
||||
const archived = advertisements.filter((a) => a.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected advertisements must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((a) => a.advertisement_id);
|
||||
|
||||
await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} advertisement(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisements.", 500);
|
||||
}
|
||||
};
|
||||
@@ -660,6 +660,83 @@ exports.restoreAssets = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ─────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
|
||||
if (!asset) return R.error(res, "Asset not found.", 404);
|
||||
if (!asset.deletedAt) return R.error(res, "Asset must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
|
||||
|
||||
await asset.destroy({ force: true });
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (svc) {
|
||||
if (storage_key) {
|
||||
try { await svc.deleteFile(storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${storage_key}":`, err.message); }
|
||||
}
|
||||
if (thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "Asset permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ASSET][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAssets = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!assets.length) return R.error(res, "No assets found.", 404);
|
||||
|
||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||
if (!archivedAssets.length) return R.error(res, "All selected assets must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
for (const asset of archivedAssets) {
|
||||
const svc = getProvider(asset.storage_provider);
|
||||
if (!svc) continue;
|
||||
if (asset.storage_key) {
|
||||
try { await svc.deleteFile(asset.storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${asset.storage_key}":`, err.message); }
|
||||
}
|
||||
if (asset.thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(asset.thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${asset.thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} asset(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedAssets = async (req, res) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/cour
|
||||
const { syncJunction } = require("../../utils/courses/junction.util");
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
@@ -313,6 +314,57 @@ exports.getCourseArchiveImpact = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteCourse = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const record = await permanentDeleteOne(Course, { course_id: courseId }, t);
|
||||
if (record === null) return R.error(res, "Course not found.", 404);
|
||||
if (record === false) return R.error(res, "Course must be archived before it can be permanently deleted.", 400);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_course', { entityType: 'course', entityId: Number(courseId) });
|
||||
return R.success(res, "Course permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[COURSE][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete course.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteCourses = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const count = await permanentDeleteMany(Course, "course_id", ids, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_courses', { entityType: 'course', details: { ids, count } });
|
||||
return R.success(res, `${count} course${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[COURSE][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete courses.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCoursePermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
|
||||
const unitCount = await Unit.count({ where: { course_id: courseId }, paranoid: false });
|
||||
const units = await Unit.findAll({ where: { course_id: courseId }, attributes: ["unit_id"], paranoid: false });
|
||||
const lessonCount = units.length
|
||||
? await Lesson.count({ where: { unit_id: units.map((u) => u.unit_id) }, paranoid: false })
|
||||
: 0;
|
||||
|
||||
return R.success(res, "Impact retrieved.", { unitCount, lessonCount });
|
||||
} catch (err) {
|
||||
console.error("[COURSE][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// COURSE PREREQUISITES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -600,6 +652,57 @@ exports.bulkRestoreUnits = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId, unitId } = req.params;
|
||||
const record = await permanentDeleteOne(Unit, { unit_id: unitId, course_id: courseId }, t);
|
||||
if (record === null) return R.error(res, "Unit not found.", 404);
|
||||
if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_unit', { entityType: 'unit', entityId: Number(unitId) });
|
||||
return R.success(res, "Unit permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids, course_id: courseId }, paranoid: false });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await permanentDeleteMany(Unit, "unit_id", validIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_units', { entityType: 'unit', details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const lessonCount = await Lesson.count({ where: { unit_id: unitId }, paranoid: false });
|
||||
|
||||
return R.success(res, "Impact retrieved.", { lessonCount });
|
||||
} catch (err) {
|
||||
console.error("[UNIT][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -859,6 +962,51 @@ exports.bulkRestoreLessons = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId, unitId, lessonId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const record = await permanentDeleteOne(Lesson, { lesson_id: lessonId, unit_id: unitId }, t);
|
||||
if (record === null) return R.error(res, "Lesson not found.", 404);
|
||||
if (record === false) return R.error(res, "Lesson must be archived before it can be permanently deleted.", 400);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { courseId, unitId } = req.params;
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId }, paranoid: false });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids, unit_id: unitId }, paranoid: false });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON PAGE
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
|
||||
const mdl_EmailBroadcastRecipient = require('../../models/email_templates/email_broadcast_recipient.mdl');
|
||||
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const {
|
||||
ALLOWED_TARGET_TYPES,
|
||||
SCOPED_TARGET_TYPES,
|
||||
validateTargetId,
|
||||
resolveTargetUserIds,
|
||||
resolveAllUserIds,
|
||||
} = require('../../utils/audienceResolver.util');
|
||||
|
||||
// Only these categories are ever appropriate to blast to real recipients —
|
||||
// system/transactional templates (OTP, welcome, banned, etc.) are triggered
|
||||
// per-user by app events and are deliberately NOT broadcastable here.
|
||||
const BROADCASTABLE_CATEGORIES = ['announcement', 'advertisement'];
|
||||
|
||||
// 'admin' means "email every admin/staff user" for this feature — distinct
|
||||
// from notification broadcasts' 'admin' (which posts to the shared bell feed
|
||||
// instead of emailing anyone). Kept local to this controller for that reason.
|
||||
async function resolveAdminStaffUserIds() {
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { acc_type: { [Op.in]: ['admin', 'staff'] }, deletedAt: null },
|
||||
raw: true,
|
||||
});
|
||||
return users.map((u) => String(u.user_id));
|
||||
}
|
||||
|
||||
async function resolveAudienceUserIds(target_type, target_id) {
|
||||
if (target_type === 'admin') return resolveAdminStaffUserIds();
|
||||
if (target_type === 'user') return resolveAllUserIds();
|
||||
if (target_type === 'both') {
|
||||
const [admins, users] = await Promise.all([resolveAdminStaffUserIds(), resolveAllUserIds()]);
|
||||
return [...new Set([...admins, ...users])];
|
||||
}
|
||||
return resolveTargetUserIds(target_type, target_id);
|
||||
}
|
||||
|
||||
// ─── GET /admin/email-broadcasts ──────────────────────────────────────────────
|
||||
|
||||
exports.getEmailBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const broadcasts = await mdl_EmailBroadcast.findAll({
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
|
||||
});
|
||||
return R.success(res, 'Email broadcasts retrieved.', broadcasts);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL BROADCASTS]', err);
|
||||
return R.error(res, 'Could not retrieve email broadcasts.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/email-broadcasts/:id ──────────────────────────────────────────
|
||||
|
||||
exports.getEmailBroadcast = async (req, res) => {
|
||||
try {
|
||||
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id, {
|
||||
include: [{ model: mdl_EmailTemplate, as: 'template', attributes: ['email_template_id', 'type', 'label', 'category'] }],
|
||||
});
|
||||
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
|
||||
|
||||
const failedRecipients = await mdl_EmailBroadcastRecipient.findAll({
|
||||
where: { email_broadcast_id: broadcast.email_broadcast_id, status: 'failed' },
|
||||
attributes: ['email', 'error'],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
return R.success(res, 'Email broadcast retrieved.', { ...broadcast.toJSON(), failed_recipients: failedRecipients });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL BROADCAST]', err);
|
||||
return R.error(res, 'Could not retrieve email broadcast.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/email-broadcasts ─────────────────────────────────────────────
|
||||
// Enqueues recipients and returns immediately — actual sending happens later,
|
||||
// paced, in cron/jobs/dispatch_email_broadcasts.cron.js. Never loops over
|
||||
// recipients or calls sendEmail() here.
|
||||
|
||||
exports.createEmailBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { email_template_id, target_type, target_id, createdBy } = req.body;
|
||||
|
||||
if (!email_template_id) return R.error(res, 'email_template_id is required.', 400);
|
||||
if (!target_type) return R.error(res, 'target_type is required.', 400);
|
||||
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(', ')}`, 400);
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, 'target_id is required for this target_type.', 400);
|
||||
|
||||
const template = await mdl_EmailTemplate.findByPk(email_template_id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
if (!BROADCASTABLE_CATEGORIES.includes(template.category)) {
|
||||
return R.error(res, `Only ${BROADCASTABLE_CATEGORIES.join('/')} templates can be sent to recipients.`, 400);
|
||||
}
|
||||
if (template.status !== 'sent' || !template.subject || !template.html_body) {
|
||||
return R.error(res, 'This template has no published (sent) version yet — publish it before sending to recipients.', 400);
|
||||
}
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||
|
||||
const userIds = await resolveAudienceUserIds(target_type, target_id);
|
||||
if (!userIds.length) return R.error(res, 'No recipients match this target.', 400);
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id', 'email', 'personal_info'],
|
||||
where: { user_id: { [Op.in]: userIds }, email: { [Op.ne]: null }, deletedAt: null },
|
||||
raw: true,
|
||||
});
|
||||
if (!users.length) return R.error(res, 'None of the matched recipients have a usable email address.', 400);
|
||||
|
||||
const broadcast = await mdl_EmailBroadcast.create({
|
||||
email_template_id,
|
||||
target_type,
|
||||
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
||||
status: 'queued',
|
||||
total_recipients: users.length,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await mdl_EmailBroadcastRecipient.bulkCreate(
|
||||
users.map((u) => ({
|
||||
email_broadcast_id: broadcast.email_broadcast_id,
|
||||
user_id: u.user_id,
|
||||
email: u.email,
|
||||
name: u.personal_info?.name?.full_name ?? null,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
logActivity(req.user?.user_id, 'create_email_broadcast', {
|
||||
entityType: 'email_broadcast',
|
||||
entityId: broadcast.email_broadcast_id,
|
||||
details: { email_template_id, target_type, target_id, recipient_count: users.length },
|
||||
});
|
||||
|
||||
return R.success(res, `Broadcast queued for ${users.length} recipient(s).`, broadcast, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE EMAIL BROADCAST]', err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, 'Internal server error.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PATCH /admin/email-broadcasts/:id/cancel ─────────────────────────────────
|
||||
// Soft stop — the cron simply won't pick up any more pending recipients for a
|
||||
// canceled broadcast. Whatever's already sent stays sent.
|
||||
|
||||
exports.cancelEmailBroadcast = async (req, res) => {
|
||||
try {
|
||||
const broadcast = await mdl_EmailBroadcast.findByPk(req.params.id);
|
||||
if (!broadcast) return R.error(res, 'Email broadcast not found.', 404);
|
||||
if (broadcast.status === 'completed' || broadcast.status === 'canceled') {
|
||||
return R.error(res, `Broadcast is already ${broadcast.status}.`, 400);
|
||||
}
|
||||
|
||||
await broadcast.update({ status: 'canceled' });
|
||||
logActivity(req.user?.user_id, 'cancel_email_broadcast', { entityType: 'email_broadcast', entityId: broadcast.email_broadcast_id });
|
||||
return R.success(res, 'Email broadcast canceled.', broadcast);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CANCEL EMAIL BROADCAST]', err);
|
||||
return R.error(res, 'Internal server error.', 500);
|
||||
}
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_EmailTemplate = require('../../models/email_templates/email_templates.mdl');
|
||||
const mdl_EmailBroadcast = require('../../models/email_templates/email_broadcast.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const TYPE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
||||
const VALID_CATEGORIES = ['announcement', 'advertisement', 'system', 'other'];
|
||||
|
||||
// ─── GET /admin/email-templates ───────────────────────────────────────────────
|
||||
|
||||
exports.getEmailTemplates = async (req, res) => {
|
||||
try {
|
||||
const templates = await mdl_EmailTemplate.findAll({
|
||||
order: [['category', 'ASC'], ['is_system', 'DESC'], ['type', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Email templates retrieved.', templates);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL TEMPLATES]', err);
|
||||
return R.error(res, 'Could not retrieve email templates.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/email-templates/:id ───────────────────────────────────────────
|
||||
|
||||
exports.getEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
return R.success(res, 'Email template retrieved.', template);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not retrieve email template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/email-templates ──────────────────────────────────────────────
|
||||
|
||||
exports.createEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
|
||||
if (!type || !label || !subject || !html_body) {
|
||||
return R.error(res, 'type, label, subject and html_body are required.', 400);
|
||||
}
|
||||
if (!TYPE_PATTERN.test(type)) {
|
||||
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
|
||||
}
|
||||
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
|
||||
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
|
||||
}
|
||||
|
||||
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
|
||||
|
||||
// "Send Now" writes straight to the live columns sendEmail() reads.
|
||||
// "Save as Draft" keeps the content out of the live columns entirely, so
|
||||
// there's nothing for sendEmail() to pick up until it's published.
|
||||
const isPublishing = publish === true || publish === 'true';
|
||||
|
||||
const template = await mdl_EmailTemplate.create({
|
||||
type,
|
||||
label,
|
||||
category: category || 'other',
|
||||
status: isPublishing ? 'sent' : 'draft',
|
||||
subject: isPublishing ? subject : null,
|
||||
html_body: isPublishing ? html_body : null,
|
||||
body_markdown: isPublishing ? (body_markdown ?? null) : null,
|
||||
draft_subject: isPublishing ? null : subject,
|
||||
draft_html_body: isPublishing ? null : html_body,
|
||||
draft_body_markdown: isPublishing ? null : (body_markdown ?? null),
|
||||
last_sent_at: isPublishing ? new Date() : null,
|
||||
is_system: false, // only seed data may be system-protected
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_email_template', { entityType: 'email_template', details: { type, label, category: template.category, status: template.status } });
|
||||
|
||||
return R.success(res, 'Email template created.', template, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not create email template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/email-templates/:id ───────────────────────────────────────────
|
||||
|
||||
exports.updateEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
|
||||
const { type, label, category, subject, html_body, body_markdown, publish } = req.body;
|
||||
|
||||
if (template.is_system && type !== undefined && type !== template.type) {
|
||||
return R.error(res, 'The type of a system email template cannot be changed.', 400);
|
||||
}
|
||||
if (type !== undefined && !TYPE_PATTERN.test(type)) {
|
||||
return R.error(res, 'type must be uppercase letters, numbers or underscores, starting with a letter (e.g. INVOICE_RECEIPT).', 400);
|
||||
}
|
||||
if (category !== undefined && !VALID_CATEGORIES.includes(category)) {
|
||||
return R.error(res, `category must be one of: ${VALID_CATEGORIES.join(', ')}.`, 400);
|
||||
}
|
||||
|
||||
if (!template.is_system && type !== undefined && type !== template.type) {
|
||||
const exists = await mdl_EmailTemplate.findOne({ where: { type } });
|
||||
if (exists) return R.error(res, `An email template with type "${type}" already exists.`, 409);
|
||||
}
|
||||
|
||||
if (subject !== undefined && !subject.trim()) return R.error(res, 'subject cannot be empty.', 400);
|
||||
if (html_body !== undefined && !html_body.trim()) return R.error(res, 'html_body cannot be empty.', 400);
|
||||
|
||||
// "Send" publishes subject/html_body straight to the live columns that
|
||||
// sendEmail() reads and clears any pending draft. A plain save (no
|
||||
// publish flag) writes into draft_subject/draft_html_body instead, so
|
||||
// real outgoing mail keeps using the last-published content until an
|
||||
// admin comes back and explicitly sends again.
|
||||
const isPublishing = publish === true || publish === 'true';
|
||||
const nextSubject = subject ?? template.draft_subject ?? template.subject;
|
||||
const nextHtmlBody = html_body ?? template.draft_html_body ?? template.html_body;
|
||||
const nextMarkdown = body_markdown ?? template.draft_body_markdown ?? template.body_markdown;
|
||||
|
||||
await template.update({
|
||||
type: (!template.is_system && type !== undefined) ? type : template.type,
|
||||
label: label ?? template.label,
|
||||
category: category ?? template.category,
|
||||
...(isPublishing
|
||||
? {
|
||||
status: 'sent',
|
||||
subject: nextSubject,
|
||||
html_body: nextHtmlBody,
|
||||
body_markdown: nextMarkdown,
|
||||
draft_subject: null,
|
||||
draft_html_body: null,
|
||||
draft_body_markdown: null,
|
||||
last_sent_at: new Date(),
|
||||
}
|
||||
: {
|
||||
draft_subject: nextSubject,
|
||||
draft_html_body: nextHtmlBody,
|
||||
draft_body_markdown: nextMarkdown,
|
||||
}),
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_email_template', { entityType: 'email_template', entityId: template.email_template_id, details: { type: template.type, published: isPublishing } });
|
||||
|
||||
return R.success(res, 'Email template updated.', template);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not update email template.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/email-templates/:id ────────────────────────────────────────
|
||||
|
||||
exports.deleteEmailTemplate = async (req, res) => {
|
||||
try {
|
||||
const template = await mdl_EmailTemplate.findByPk(req.params.id);
|
||||
if (!template) return R.error(res, 'Email template not found.', 404);
|
||||
if (template.is_system) return R.error(res, 'Built-in system email templates cannot be deleted.', 400);
|
||||
|
||||
const broadcastCount = await mdl_EmailBroadcast.count({ where: { email_template_id: template.email_template_id } });
|
||||
if (broadcastCount > 0) {
|
||||
return R.error(res, `Cannot delete — ${broadcastCount} broadcast(s) reference this template. Its send history would be lost.`, 409);
|
||||
}
|
||||
|
||||
await template.destroy();
|
||||
logActivity(req.user?.user_id, 'delete_email_template', { entityType: 'email_template', details: { type: template.type } });
|
||||
return R.success(res, 'Email template deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE EMAIL TEMPLATE]', err);
|
||||
return R.error(res, 'Could not delete email template.', 500);
|
||||
}
|
||||
};
|
||||
@@ -419,3 +419,50 @@ exports.getArchivedBroadcasts = async (req, res) => {
|
||||
return R.error(res, "Could not retrieve archived notification broadcasts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
if (!broadcast.deletedAt) return R.error(res, "Notification broadcast must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await broadcast.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Notification broadcast permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete notification broadcast.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
|
||||
|
||||
const broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||
|
||||
const archived = broadcasts.filter((b) => b.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected notification broadcasts must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||
|
||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} notification broadcast(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete notification broadcasts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,6 +23,8 @@ const { filterableFields } = require('../../models/task/task.attributes');
|
||||
const { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { TaskCompletion } = require('../../models/task/task_completion.mdl');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
|
||||
@@ -260,6 +262,76 @@ exports.bulkRestoreTaskLists = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
|
||||
const record = await permanentDeleteOne(TaskList, { task_list_id: taskListId }, t);
|
||||
if (record === null) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
if (record === false) { await t.rollback(); return R.error(res, 'Task list must be archived before it can be permanently deleted.', 400); }
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
|
||||
return R.success(res, 'Task list permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE TASK LIST]', err);
|
||||
return R.error(res, 'Could not permanently delete task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteTaskLists = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task list IDs provided.', 400);
|
||||
|
||||
const taskLists = await TaskList.findAll({ where: { task_list_id: ids }, paranoid: false });
|
||||
if (!taskLists.length) return R.error(res, 'No task lists found.', 404);
|
||||
|
||||
const deletedIds = taskLists.filter((tl) => tl.deletedAt).map((tl) => tl.task_list_id);
|
||||
if (!deletedIds.length)
|
||||
return R.error(res, 'All selected task lists must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const count = await permanentDeleteMany(TaskList, 'task_list_id', deletedIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_task_lists', { entityType: 'task_list', details: { ids: deletedIds, count } });
|
||||
return R.success(res, `${count} task list(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE TASK LISTS]', err);
|
||||
return R.error(res, 'Could not permanently delete task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE IMPACT ──────────────────────────────────────────────────
|
||||
|
||||
exports.getTaskListPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
|
||||
const taskCount = await Task.count({ where: { task_list_id: taskListId }, paranoid: false });
|
||||
const tasks = await Task.findAll({ where: { task_list_id: taskListId }, attributes: ['task_id'], paranoid: false });
|
||||
const completionCount = tasks.length
|
||||
? await TaskCompletion.count({ where: { task_id: tasks.map((task) => task.task_id) }, paranoid: false })
|
||||
: 0;
|
||||
|
||||
return R.success(res, 'Impact retrieved.', { taskCount, completionCount });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][TASK LIST PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK LIST GROUPS (assign / unassign / list) ──────────────────────────────
|
||||
// =============================================================================
|
||||
@@ -848,5 +920,60 @@ exports.bulkRestoreTasks = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
|
||||
const record = await permanentDeleteOne(Task, { task_id: taskId, task_list_id: taskListId }, t);
|
||||
if (record === null) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
if (record === false) { await t.rollback(); return R.error(res, 'Task must be archived before it can be permanently deleted.', 400); }
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'permanently_delete_task', { entityType: 'task', entityId: Number(taskId) });
|
||||
return R.success(res, 'Task permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE TASK]', err);
|
||||
return R.error(res, 'Could not permanently delete task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteTasks = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task IDs provided.', 400);
|
||||
|
||||
const tasks = await Task.findAll({
|
||||
where: { task_id: ids, task_list_id: taskListId },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!tasks.length) return R.error(res, 'No tasks found.', 404);
|
||||
|
||||
const deletedIds = tasks.filter((task) => task.deletedAt).map((task) => task.task_id);
|
||||
if (!deletedIds.length)
|
||||
return R.error(res, 'All selected tasks must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const count = await permanentDeleteMany(Task, 'task_id', deletedIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_tasks', { entityType: 'task', details: { ids: deletedIds, count } });
|
||||
return R.success(res, `${count} task(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE TASKS]', err);
|
||||
return R.error(res, 'Could not permanently delete tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getTaskFieldValues = getFieldValues(Task, "TASK");
|
||||
exports.getTaskListFieldValues = getFieldValues(TaskList, "TASKLIST");
|
||||
@@ -8,7 +8,7 @@
|
||||
* Author: rgrgogu
|
||||
* Date Created: Jun. 6, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const { Op, ForeignKeyConstraintError } = require('sequelize');
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
@@ -279,6 +279,74 @@ exports.bulkRestorePlans = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlanPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id: req.params.id }, paranoid: false });
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const active_subscriber_count = await mdl_UserTiers.count({
|
||||
where: { plan_id: req.params.id, status: 'active' },
|
||||
});
|
||||
const payment_count = await mdl_Payments.count({ where: { plan_id: req.params.id } });
|
||||
|
||||
return R.success(res, 'Plan permanent-delete impact retrieved.', { active_subscriber_count, payment_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve plan permanent-delete impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeletePlan = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findOne({
|
||||
where: { plan_id: req.params.id }, paranoid: false,
|
||||
});
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
if (!plan.deletedAt) return R.error(res, 'Plan must be archived before it can be permanently deleted.', 400);
|
||||
|
||||
await plan.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
|
||||
return R.success(res, 'Plan permanently deleted.');
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: this plan still has payment records on file. Payment history is preserved and cannot be removed.', 400);
|
||||
}
|
||||
console.error('[ADMIN][PERMANENT DELETE PLAN]', err);
|
||||
return R.error(res, 'Could not permanently delete plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeletePlans = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No plan IDs provided.', 400);
|
||||
|
||||
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false });
|
||||
if (!plans.length) return R.error(res, 'No plans found.', 404);
|
||||
|
||||
const archivedPlans = plans.filter((p) => p.deletedAt);
|
||||
if (!archivedPlans.length)
|
||||
return R.error(res, 'All selected plans must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const archivedIds = archivedPlans.map((p) => p.plan_id);
|
||||
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: one or more selected plans still have payment records on file. Payment history is preserved and cannot be removed.', 400);
|
||||
}
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err);
|
||||
return R.error(res, 'Could not permanently delete plans.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getPlanFieldValues = getFieldValues(mdl_TierPlans, 'TIER_PLAN', {
|
||||
blockedFields: [],
|
||||
});
|
||||
|
||||
@@ -253,6 +253,52 @@ exports.bulkRestoreGroups = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
exports.permanentlyDeleteGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.deletedAt) return R.error(res, 'Group must be deactivated before it can be permanently deleted.', 400);
|
||||
|
||||
await group.destroy({ force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_group', { entityType: 'group', entityId: group.group_id });
|
||||
return R.success(res, 'Group permanently deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PERMANENT DELETE GROUP]', err);
|
||||
return R.error(res, 'Could not permanently delete group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
exports.bulkPermanentlyDeleteGroups = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||
if (!deletedGroups.length)
|
||||
return R.error(res, 'All selected groups must be deactivated before they can be permanently deleted.', 400);
|
||||
|
||||
const deletedIds = deletedGroups.map((g) => g.group_id);
|
||||
|
||||
await mdl_UserGroups.destroy({ where: { group_id: deletedIds }, force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } });
|
||||
return R.success(res, `${deletedIds.length} group(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE GROUPS]', err);
|
||||
return R.error(res, 'Could not permanently delete groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||
exports.getArchivedGroups = async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,9 @@ const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
|
||||
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||
const mdl_UserBans = require('../../models/users/user_bans.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
@@ -291,6 +294,127 @@ exports.bulkRestoreUsers = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The live DB has several FKs on users.user_id that are NO ACTION rather than
|
||||
// the CASCADE the migration source claims (schema drift) — achievements,
|
||||
// quiz_attempts, user_tiers, and payments all block a hard delete unless their
|
||||
// rows for this user are removed first. Also null out the "who did this"
|
||||
// actor columns (granted_by/revoked_by/lifted_by) on OTHER users' records,
|
||||
// since those aren't this user's own data and shouldn't be deleted.
|
||||
//
|
||||
// UserBan.banned_by is NOT NULL (a ban record must always keep its banner),
|
||||
// so a user who has ever banned someone can't be nulled out or hard-deleted —
|
||||
// getBannerBlockedIds() below identifies those and callers must exclude them.
|
||||
async function purgeUserDependents(userIds, t) {
|
||||
await mdl_Achievements.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_QuizAttempt.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_UserTiers.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_Payments.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
|
||||
await mdl_Achievements.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ revoked_by: null }, { where: { revoked_by: userIds }, transaction: t });
|
||||
await mdl_UserBans.update({ lifted_by: null }, { where: { lifted_by: userIds }, transaction: t });
|
||||
}
|
||||
|
||||
async function getBannerBlockedIds(userIds, t) {
|
||||
const bans = await mdl_UserBans.findAll({
|
||||
where: { banned_by: userIds }, attributes: ['banned_by'], group: ['banned_by'], transaction: t,
|
||||
});
|
||||
return bans.map((b) => b.banned_by);
|
||||
}
|
||||
|
||||
exports.permanentlyDeleteUser = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id }, paranoid: false, transaction: t,
|
||||
});
|
||||
if (!user) { await t.rollback(); return R.error(res, 'User not found.', 404); }
|
||||
if (!user.deletedAt) { await t.rollback(); return R.error(res, 'User must be deactivated before it can be permanently deleted.', 400); }
|
||||
|
||||
const bannerBlockedIds = await getBannerBlockedIds([user.user_id], t);
|
||||
if (bannerBlockedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: this user has banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents([user.user_id], t);
|
||||
await user.destroy({ force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_user', { entityType: 'user', entityId: Number(req.params.id) });
|
||||
|
||||
return R.success(res, 'User permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE USER]', err);
|
||||
return R.error(res, 'Could not permanently delete user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteUsers = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
}
|
||||
|
||||
if (ids.includes(req.user.user_id)) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false, transaction: t });
|
||||
if (!users.length) { await t.rollback(); return R.error(res, 'No users found.', 404); }
|
||||
|
||||
const deletedUsers = users.filter((u) => u.deletedAt);
|
||||
if (!deletedUsers.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected users must be deactivated before they can be permanently deleted.', 400);
|
||||
}
|
||||
|
||||
const archivedIds = deletedUsers.map((u) => u.user_id);
|
||||
const bannerBlockedIds = await getBannerBlockedIds(archivedIds, t);
|
||||
const deletedIds = archivedIds.filter((id) => !bannerBlockedIds.includes(id));
|
||||
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: all selected users have banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents(deletedIds, t);
|
||||
await mdl_Users.destroy({ where: { user_id: deletedIds }, force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_users', {
|
||||
entityType: 'user',
|
||||
details: { ids: deletedIds, count: deletedIds.length },
|
||||
});
|
||||
|
||||
return R.success(res, `${deletedIds.length} user(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE USERS]', err);
|
||||
return R.error(res, 'Could not permanently delete users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SESSIONS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* GET /api/auth/google/callback
|
||||
***********************************************************************************************************************************************************************/
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const sequelize = require('../config/db.config')
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
|
||||
@@ -103,10 +104,13 @@ exports.register = async (req, res) => {
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await sendEmail({ to: email, type: 'OTP', data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||||
// an otherwise-successful registration — resendOTP covers redelivery.
|
||||
sendEmail({ to: email, type: 'OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||
|
||||
// Fire-and-forget: notify admins — explicit group or NOGRP fallback
|
||||
if (group) {
|
||||
renderNotification({ type: 'user_registration', data: {
|
||||
@@ -146,7 +150,9 @@ exports.verifyOTP = async (req, res) => {
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_verified) return R.error(res, 'Account already verified.', 400);
|
||||
|
||||
if (!crypto.timingSafeEqual(Buffer.from(user.otp_code ?? ''), Buffer.from(otp)))
|
||||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||
const givenOTP = Buffer.from(otp ?? '');
|
||||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||
return R.error(res, 'Invalid OTP.', 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||||
|
||||
@@ -235,10 +241,13 @@ exports.resendOTP = async (req, res) => {
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
||||
|
||||
await sendEmail({ to: email, type: "OTP", data: { otp } });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||||
// the already-persisted OTP refresh.
|
||||
sendEmail({ to: email, type: "OTP", data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||
|
||||
return R.success(res, 'A new OTP has been sent to your email.');
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
|
||||
Reference in New Issue
Block a user