diff --git a/backend/migrations/20260722_add_reply_to_to_club_communication_threads.sql b/backend/migrations/20260722_add_reply_to_to_club_communication_threads.sql new file mode 100644 index 00000000..5457dbbc --- /dev/null +++ b/backend/migrations/20260722_add_reply_to_to_club_communication_threads.sql @@ -0,0 +1,3 @@ +-- Optional reply address for a communication thread. +ALTER TABLE club_communication_threads + ADD COLUMN reply_to VARCHAR(320) NULL AFTER subject; diff --git a/backend/models/ClubCommunicationThread.js b/backend/models/ClubCommunicationThread.js index a5426c5f..3bea36e9 100755 --- a/backend/models/ClubCommunicationThread.js +++ b/backend/models/ClubCommunicationThread.js @@ -23,6 +23,11 @@ const ClubCommunicationThread = sequelize.define('ClubCommunicationThread', { type: DataTypes.STRING(255), allowNull: false, }, + replyTo: { + type: DataTypes.STRING(320), + allowNull: true, + field: 'reply_to', + }, status: { type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'archived'), allowNull: false, diff --git a/backend/services/clubCommunicationService.js b/backend/services/clubCommunicationService.js index e559922a..ab86b014 100755 --- a/backend/services/clubCommunicationService.js +++ b/backend/services/clubCommunicationService.js @@ -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) => { diff --git a/backend/services/emailService.js b/backend/services/emailService.js index ebe55f48..35c29004 100755 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -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 }; diff --git a/docs/manual_sql_migrations.md b/docs/manual_sql_migrations.md index 2ca2c7c3..db766b67 100755 --- a/docs/manual_sql_migrations.md +++ b/docs/manual_sql_migrations.md @@ -16,6 +16,21 @@ Ergaenzend: - Manuelle Migrationsschritte gehoeren in diese Datei, nicht nur in Chat-Verlaeufe oder Commit-Messages. - Wenn eine Aenderung rueckwaertskompatibel ist, soll das hier explizit vermerkt werden. +## 2026-07-22 + +### `club_communication_threads.reply_to` + +Optionale Antwortadresse je Kommunikationsvorgang: + +```sql +ALTER TABLE club_communication_threads + ADD COLUMN reply_to VARCHAR(320) NULL AFTER subject; +``` + +Rueckwaertskompatibilitaet: + +- Bestehende Vorgange behalten eine leere Antwortadresse und verwenden weiterhin die globale Absenderadresse. + ## 2026-03-17 ### `predefined_activities.exclude_from_stats` diff --git a/frontend/sql/tt-verein-v1-schema.mysql.sql b/frontend/sql/tt-verein-v1-schema.mysql.sql index 766767ed..218a4e43 100755 --- a/frontend/sql/tt-verein-v1-schema.mysql.sql +++ b/frontend/sql/tt-verein-v1-schema.mysql.sql @@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS `club_communication_threads` ( `club_id` bigint NOT NULL, `thread_type` varchar(32) NOT NULL DEFAULT 'direct', `subject` varchar(255) NOT NULL, + `reply_to` varchar(320) DEFAULT NULL, `status` varchar(32) NOT NULL DEFAULT 'draft', `created_by_user_id` bigint NULL, `distribution_group_id` bigint NULL, diff --git a/frontend/sql/tt-verein-v1-schema.sql b/frontend/sql/tt-verein-v1-schema.sql index 7d20825e..23686e3c 100755 --- a/frontend/sql/tt-verein-v1-schema.sql +++ b/frontend/sql/tt-verein-v1-schema.sql @@ -99,6 +99,7 @@ CREATE TABLE IF NOT EXISTS club_communication_threads ( club_id bigint NOT NULL, thread_type varchar(32) NOT NULL DEFAULT 'direct', subject varchar(255) NOT NULL, + reply_to varchar(320), status varchar(32) NOT NULL DEFAULT 'draft', created_by_user_id bigint, distribution_group_id bigint, diff --git a/frontend/src/views/ClubCommunicationView.vue b/frontend/src/views/ClubCommunicationView.vue index 0d3fae9c..f21dfcef 100755 --- a/frontend/src/views/ClubCommunicationView.vue +++ b/frontend/src/views/ClubCommunicationView.vue @@ -211,6 +211,7 @@