feat: add optional reply address to communication threads and enhance email transport status handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import sequelize from '../database.js';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
ClubCommunicationThread,
|
||||
ClubCommunicationMessage,
|
||||
@@ -11,8 +12,11 @@ import {
|
||||
Member,
|
||||
MemberContact,
|
||||
User,
|
||||
ClubDocument,
|
||||
ClubDocumentLink,
|
||||
ClubDocumentVersion,
|
||||
} from '../models/index.js';
|
||||
import { sendClubCommunicationEmail } from './emailService.js';
|
||||
import { getEmailTransportStatus, sendClubCommunicationEmail } from './emailService.js';
|
||||
|
||||
const THREAD_TYPES = new Set(['direct', 'group', 'broadcast']);
|
||||
const THREAD_STATUSES = new Set(['draft', 'scheduled', 'sent', 'archived']);
|
||||
@@ -37,6 +41,12 @@ function normalizeId(value) {
|
||||
}
|
||||
|
||||
function normalizeThreadPayload(payload = {}) {
|
||||
const replyTo = trimText(payload.replyTo, 320);
|
||||
if (replyTo && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(replyTo)) {
|
||||
const error = new Error('Reply-To muss eine gültige E-Mail-Adresse sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
threadType: THREAD_TYPES.has(payload.threadType) ? payload.threadType : 'direct',
|
||||
subject: trimText(payload.subject, 255),
|
||||
@@ -45,9 +55,54 @@ function normalizeThreadPayload(payload = {}) {
|
||||
distributionGroupId: normalizeId(payload.distributionGroupId),
|
||||
scheduledAt: normalizeDate(payload.scheduledAt),
|
||||
recipientFilters: normalizeRecipientFilters(payload.recipientFilters),
|
||||
replyTo,
|
||||
attachmentDocumentIds: Array.isArray(payload.attachmentDocumentIds)
|
||||
? [...new Set(payload.attachmentDocumentIds.map(normalizeId).filter(Boolean))]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceThreadAttachments(clubId, threadId, documentIds, transaction) {
|
||||
const ids = Array.isArray(documentIds) ? documentIds : [];
|
||||
if (ids.length) {
|
||||
const count = await ClubDocument.count({
|
||||
where: { id: ids, clubId, status: 'active' },
|
||||
transaction,
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
const error = new Error('Mindestens ein ausgewähltes Dokument ist nicht mehr verfügbar.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await ClubDocumentLink.destroy({ where: { linkedEntityType: 'club_communication_thread', linkedEntityId: threadId }, transaction });
|
||||
if (ids.length) {
|
||||
await ClubDocumentLink.bulkCreate(ids.map((documentId) => ({
|
||||
documentId,
|
||||
linkedEntityType: 'club_communication_thread',
|
||||
linkedEntityId: threadId,
|
||||
})), { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadThreadAttachments(clubId, threadIds) {
|
||||
if (!threadIds.length) return new Map();
|
||||
const links = await ClubDocumentLink.findAll({
|
||||
where: { linkedEntityType: 'club_communication_thread', linkedEntityId: threadIds },
|
||||
include: [{ model: ClubDocument, as: 'document', required: true, where: { clubId }, include: [{ model: ClubDocumentVersion, as: 'versions', required: false }] }],
|
||||
});
|
||||
const result = new Map();
|
||||
for (const link of links) {
|
||||
const document = link.document;
|
||||
const version = [...(document?.versions || [])].sort((a, b) => Number(b.versionNo) - Number(a.versionNo))[0];
|
||||
if (!document || !version) continue;
|
||||
const entries = result.get(Number(link.linkedEntityId)) || [];
|
||||
entries.push({ id: Number(document.id), title: document.title, fileName: version.fileName, mimeType: version.mimeType, storagePath: version.storagePath });
|
||||
result.set(Number(link.linkedEntityId), entries);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeGroupPayload(payload = {}) {
|
||||
const memberIds = Array.isArray(payload.memberIds)
|
||||
? [...new Set(payload.memberIds.map(normalizeId).filter(Boolean))]
|
||||
@@ -364,7 +419,7 @@ async function replaceRecipientsForThread(thread, threadPayload, transaction) {
|
||||
|
||||
class ClubCommunicationService {
|
||||
async listClubCommunication(clubId) {
|
||||
const [threads, groups, members, templates] = await Promise.all([
|
||||
const [threads, groups, members, templates, availableDocuments] = await Promise.all([
|
||||
ClubCommunicationThread.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
@@ -410,9 +465,17 @@ class ClubCommunicationService {
|
||||
where: { clubId },
|
||||
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||
}),
|
||||
ClubDocument.findAll({ where: { clubId, status: 'active' }, include: [{ model: ClubDocumentVersion, as: 'versions', required: false }], order: [['title', 'ASC']] }),
|
||||
]);
|
||||
|
||||
return { threads, groups, members, templates };
|
||||
const attachmentsByThreadId = await loadThreadAttachments(clubId, threads.map((thread) => Number(thread.id)));
|
||||
threads.forEach((thread) => thread.setDataValue('attachments', attachmentsByThreadId.get(Number(thread.id)) || []));
|
||||
|
||||
return {
|
||||
threads, groups, members, templates,
|
||||
availableDocuments: availableDocuments.map((document) => ({ id: Number(document.id), title: document.title })),
|
||||
transport: getEmailTransportStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
async createThread(clubId, userId, payload) {
|
||||
@@ -428,6 +491,7 @@ class ClubCommunicationService {
|
||||
...normalized,
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
await replaceThreadAttachments(clubId, thread.id, normalized.attachmentDocumentIds, transaction);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
@@ -451,6 +515,7 @@ class ClubCommunicationService {
|
||||
sentAt: normalized.status === 'sent' ? (thread.sentAt || new Date()) : (normalized.status === 'archived' ? thread.sentAt : null),
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
await replaceThreadAttachments(clubId, thread.id, normalized.attachmentDocumentIds, transaction);
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
@@ -566,11 +631,17 @@ class ClubCommunicationService {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const attachmentsByThreadId = await loadThreadAttachments(clubId, [Number(thread.id)]);
|
||||
const attachments = (attachmentsByThreadId.get(Number(thread.id)) || [])
|
||||
.filter((attachment) => fs.existsSync(attachment.storagePath))
|
||||
.map((attachment) => ({ filename: attachment.fileName, path: attachment.storagePath, contentType: attachment.mimeType || undefined }));
|
||||
const result = await sendClubCommunicationEmail({
|
||||
to: recipient.emailSnapshot,
|
||||
subject: thread.subject,
|
||||
text: renderMessageText(latestMessage.body),
|
||||
html: renderMessageHtml(latestMessage.body),
|
||||
replyTo: thread.replyTo,
|
||||
attachments,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
|
||||
@@ -26,6 +26,18 @@ function getDefaultFrom() {
|
||||
return process.env.EMAIL_FROM || process.env.EMAIL_USER;
|
||||
}
|
||||
|
||||
function getEmailTransportStatus() {
|
||||
const configured = Boolean(process.env.EMAIL_USER && process.env.EMAIL_PASS);
|
||||
return {
|
||||
configured,
|
||||
from: configured ? getDefaultFrom() : null,
|
||||
provider: configured ? 'Gmail' : null,
|
||||
message: configured
|
||||
? 'E-Mail-Versand ist konfiguriert.'
|
||||
: 'E-Mail-Versand ist nicht konfiguriert. EMAIL_USER und EMAIL_PASS fehlen.',
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMail(mailOptions) {
|
||||
return getTransporter().sendMail({
|
||||
from: getDefaultFrom(),
|
||||
@@ -155,6 +167,7 @@ const sendClubCommunicationEmail = async ({
|
||||
text,
|
||||
html,
|
||||
replyTo,
|
||||
attachments,
|
||||
}) => {
|
||||
return sendMail({
|
||||
to,
|
||||
@@ -162,7 +175,8 @@ const sendClubCommunicationEmail = async ({
|
||||
text,
|
||||
html,
|
||||
replyTo: replyTo || undefined,
|
||||
attachments: Array.isArray(attachments) && attachments.length ? attachments : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };
|
||||
export { getEmailTransportStatus, sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };
|
||||
|
||||
Reference in New Issue
Block a user