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

This commit is contained in:
Torsten Schulz (local)
2026-07-22 15:28:39 +02:00
parent fd0b3020bf
commit d190c2cb70
8 changed files with 140 additions and 4 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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 };

View File

@@ -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`

View File

@@ -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,

View File

@@ -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,

View File

@@ -211,6 +211,7 @@
<h3>{{ threadForm.id ? 'Vorgang bearbeiten' : 'Neuer Vorgang' }}</h3>
</div>
<p v-if="!canEdit" class="state-banner">Lesemodus aktiv. Vorgänge lassen sich prüfen, aber nicht ändern.</p>
<p v-if="transport && !transport.configured" class="state-banner state-banner-error">{{ transport.message }}</p>
<form class="thread-form" @submit.prevent="saveThread">
<div class="form-grid">
@@ -238,6 +239,19 @@
<input v-model.trim="threadForm.subject" type="text" :disabled="!canEdit" required />
</label>
<label>
<span>Antwortadresse (optional)</span>
<input v-model.trim="threadForm.replyTo" type="email" :disabled="!canEdit" placeholder="vorstand@verein.de" />
</label>
<label>
<span>Dokumentanhänge</span>
<select v-model="threadForm.attachmentDocumentIds" multiple :disabled="!canEdit || availableDocuments.length === 0">
<option v-for="document in availableDocuments" :key="document.id" :value="document.id">{{ document.title }}</option>
</select>
<small v-if="availableDocuments.length === 0">Keine aktiven Vereinsdokumente verfügbar.</small>
</label>
<div v-if="threadForm.threadType === 'direct'">
<label>
<span>Empfänger</span>
@@ -546,6 +560,8 @@ function normalizeThread(thread = {}) {
id: Number(thread.id),
threadType: thread.threadType || thread.thread_type || 'direct',
subject: thread.subject || '',
replyTo: thread.replyTo || thread.reply_to || '',
attachments: Array.isArray(thread.attachments) ? thread.attachments : [],
status: thread.status || 'draft',
recipientMemberId: Number(thread.recipientMemberId || thread.recipient_member_id || 0) || null,
distributionGroupId: Number(thread.distributionGroupId || thread.distribution_group_id || 0) || null,
@@ -608,6 +624,8 @@ function createEmptyThreadForm() {
id: null,
threadType: 'direct',
subject: '',
replyTo: '',
attachmentDocumentIds: [],
status: 'draft',
recipientMemberId: '',
distributionGroupId: '',
@@ -679,6 +697,8 @@ export default {
groups: [],
members: [],
templates: [],
availableDocuments: [],
transport: null,
selectedThreadId: null,
selectedGroupId: null,
selectedTemplateId: null,
@@ -772,6 +792,8 @@ export default {
id: thread.id,
threadType: thread.threadType,
subject: thread.subject,
replyTo: thread.replyTo || '',
attachmentDocumentIds: (thread.attachments || []).map((attachment) => Number(attachment.id)),
status: thread.status,
recipientMemberId: thread.recipientMemberId || '',
distributionGroupId: thread.distributionGroupId || '',
@@ -811,6 +833,8 @@ export default {
this.groups = [];
this.members = [];
this.templates = [];
this.availableDocuments = [];
this.transport = null;
this.selectedThreadId = null;
this.selectedGroupId = null;
this.selectedTemplateId = null;
@@ -941,6 +965,8 @@ export default {
this.groups = Array.isArray(response.data?.groups) ? response.data.groups.map(normalizeGroup) : [];
this.members = Array.isArray(response.data?.members) ? response.data.members.map(normalizeMember) : [];
this.templates = Array.isArray(response.data?.templates) ? response.data.templates.map(normalizeTemplate) : [];
this.availableDocuments = Array.isArray(response.data?.availableDocuments) ? response.data.availableDocuments : [];
this.transport = response.data?.transport || null;
if (this.selectedThreadId && !this.threads.some((thread) => thread.id === this.selectedThreadId)) {
this.selectedThreadId = null;
}