mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
174 lines
7.9 KiB
JavaScript
174 lines
7.9 KiB
JavaScript
'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);
|
|
}
|
|
};
|