new commits

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-03 16:20:58 +08:00
parent 1372f4e975
commit 1d12f04967
93 changed files with 3849 additions and 1063 deletions
@@ -0,0 +1,173 @@
'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);
}
};