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:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user