'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); } };