Compare commits

..

1 Commits

55 changed files with 19794 additions and 12864 deletions

7
.env Normal file
View File

@@ -0,0 +1,7 @@
SMTP_HOST=smtp.1blu.de
SMTP_PORT=465
SMTP_USER=e226079_0-kontakt
SMTP_PASS=hitomisan
SMTP_FROM=kontakt@tsschulz.de
FRONTEND_URL=http://localhost:8080
VUE_APP_BACKEND_URL=http://localhost:3002/api

View File

@@ -1,19 +0,0 @@
# E-Mail-Konfiguration für Passwort-Reset
SMTP_HOST=smtp.1blu.de
SMTP_PORT=465
SMTP_USER=your-email@domain.com
SMTP_PASS=your-password
SMTP_FROM=noreply@miriamgemeinde.de
# Frontend-URL für Reset-Links
FRONTEND_URL=http://localhost:8080
# Backend-URL für das Frontend
VUE_APP_BACKEND_URL=http://localhost:3002/api
# Datenbank-Konfiguration (falls benötigt)
DB_HOST=localhost
DB_PORT=3306
DB_NAME=miriamgemeinde
DB_USER=miriam_user
DB_PASS=your-database-password

2
.gitignore vendored
View File

@@ -27,5 +27,3 @@ server.key
server.cert
public/images/uploads/1ba24ea7-f52c-4179-896f-1909269cab58.jpg
actualize.sh
files/uploads/GD 24.08.2025-04.01.2026 Stand 12.08.2025.docx

View File

@@ -2,4 +2,4 @@ module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
};
}

View File

@@ -1,21 +1,21 @@
{
"development": {
"username": "miriamgemeinde",
"password": "hitomisan",
"username": "miriam_user",
"password": "qTCTTWwpEwy3vPDU",
"database": "miriamgemeinde",
"host": "localhost",
"host": "tsschulz.de",
"dialect": "mysql"
},
"test": {
"username": "miriam_user",
"password": "hitomisan",
"password": "qTCTTWwpEwy3vPDU",
"database": "miriamgemeinde",
"host": "tsschulz.de",
"dialect": "mysql"
},
"production": {
"username": "miriam_user",
"password": "hitomisan",
"password": "qTCTTWwpEwy3vPDU",
"database": "miriamgemeinde",
"host": "tsschulz.de",
"dialect": "mysql"

View File

@@ -1,7 +1,7 @@
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize('miriamgemeinde', 'miriamgemeinde', 'hitomisan', {
host: 'localhost',
const sequelize = new Sequelize('miriamgemeinde', 'miriam_user', 'qTCTTWwpEwy3vPDU', {
host: 'tsschulz.de',
dialect: 'mysql',
retry: {
match: [
@@ -26,7 +26,7 @@ const sequelize = new Sequelize('miriamgemeinde', 'miriamgemeinde', 'hitomisan',
async function connectWithRetry() {
try {
await sequelize.authenticate();
console.log(`Connection has been established successfully. Database server: ${sequelize.config.host}`);
console.log('Connection has been established successfully.');
} catch (error) {
console.error('Unable to connect to the database:', error);
setTimeout(connectWithRetry, 5000);

View File

@@ -1,205 +1,29 @@
const bcrypt = require('bcryptjs');
const { User, PasswordResetToken } = require('../models');
const jwt = require('jsonwebtoken');
const { addTokenToBlacklist } = require('../utils/blacklist');
const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
const crypto = require('crypto');
const AuthService = require('../services/AuthService');
const ErrorHandler = require('../utils/ErrorHandler');
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
exports.register = async (req, res) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ message: 'Alle Felder sind erforderlich' });
}
try {
const hashedPassword = await bcrypt.hash(password, 10);
console.log('Register: creating user', { email });
const maxAttempts = 3;
let attempt = 0;
let createdUser = null;
let lastError = null;
while (attempt < maxAttempts && !createdUser) {
try {
createdUser = await User.create({ name, email, password: hashedPassword, active: false });
} catch (err) {
lastError = err;
// Spezifisch auf Lock-Timeout reagieren und erneut versuchen
if ((err.code === 'ER_LOCK_WAIT_TIMEOUT' || err?.parent?.code === 'ER_LOCK_WAIT_TIMEOUT') && attempt < maxAttempts - 1) {
const backoffMs = 300 * (attempt + 1);
console.warn(`Register: ER_LOCK_WAIT_TIMEOUT, retry in ${backoffMs}ms (attempt ${attempt + 1}/${maxAttempts})`);
await delay(backoffMs);
attempt++;
continue;
}
throw err;
}
}
if (!createdUser && lastError) {
console.error('Register error (after retries):', lastError);
return res.status(503).json({ message: 'Zeitüberschreitung beim Zugriff auf die Datenbank. Bitte erneut versuchen.' });
}
console.log('Register: user created', { id: createdUser.id });
const safeUser = {
id: createdUser.id,
name: createdUser.name,
email: createdUser.email,
active: createdUser.active,
created_at: createdUser.created_at
};
return res.status(201).json({ message: 'Benutzer erfolgreich registriert', user: safeUser });
} catch (error) {
if (error.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ message: 'Email-Adresse bereits in Verwendung' });
}
console.error('Register error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten', error: error.message });
}
};
exports.login = async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email und Passwort sind erforderlich' });
}
try {
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ message: 'Ungültige Anmeldedaten' });
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ message: 'Ungültige Anmeldedaten' });
}
if (!user.active) {
return res.status(403).json({ message: 'Benutzerkonto ist nicht aktiv' });
}
const token = jwt.sign({ id: user.id, name: user.name, email: user.email }, 'zTxVgptmPl9!_dr%xxx9999(dd)', { expiresIn: '1h' });
return res.status(200).json({ message: 'Login erfolgreich', token, 'user': user });
} catch (error) {
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.forgotPassword = async (req, res) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ message: 'E-Mail-Adresse ist erforderlich' });
}
try {
const user = await User.findOne({ where: { email } });
if (!user) {
// Aus Sicherheitsgründen immer Erfolg melden, auch wenn E-Mail nicht existiert
return res.status(200).json({ message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' });
}
// Alte Reset-Tokens für diesen User löschen
await PasswordResetToken.destroy({ where: { userId: user.id } });
// Neuen Reset-Token generieren
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde
await PasswordResetToken.create({
userId: user.id,
token,
expiresAt
exports.register = ErrorHandler.asyncHandler(async (req, res) => {
const result = await AuthService.register(req.body);
ErrorHandler.successResponse(res, result, 'Benutzer erfolgreich registriert', 201);
});
// Reset-URL generieren
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`;
// E-Mail versenden
const emailTemplate = getPasswordResetEmailTemplate(resetUrl, user.name);
const mailOptions = {
from: process.env.SMTP_FROM || 'noreply@miriamgemeinde.de',
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text
};
console.log('=== EMAIL SENDING DEBUG ===');
console.log('From:', mailOptions.from);
console.log('To:', mailOptions.to);
console.log('Subject:', mailOptions.subject);
console.log('Reset URL:', resetUrl);
console.log('===========================');
await transporter.sendMail(mailOptions);
console.log('Password reset email sent to:', email);
return res.status(200).json({ message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' });
} catch (error) {
console.error('Forgot password error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.resetPassword = async (req, res) => {
const { token, password } = req.body;
if (!token || !password) {
return res.status(400).json({ message: 'Token und neues Passwort sind erforderlich' });
}
if (password.length < 6) {
return res.status(400).json({ message: 'Passwort muss mindestens 6 Zeichen lang sein' });
}
try {
// Token validieren
const resetToken = await PasswordResetToken.findOne({
where: {
token,
used: false,
expiresAt: {
[require('sequelize').Op.gt]: new Date()
}
},
include: [{ model: User, as: 'user' }]
exports.login = ErrorHandler.asyncHandler(async (req, res) => {
const result = await AuthService.login(req.body);
ErrorHandler.successResponse(res, result, result.message);
});
if (!resetToken) {
return res.status(400).json({ message: 'Ungültiger oder abgelaufener Token' });
}
exports.forgotPassword = ErrorHandler.asyncHandler(async (req, res) => {
const result = await AuthService.forgotPassword(req.body.email);
ErrorHandler.successResponse(res, result, result.message);
});
// Passwort hashen und aktualisieren
const hashedPassword = await bcrypt.hash(password, 10);
await User.update(
{ password: hashedPassword },
{ where: { id: resetToken.userId } }
);
exports.resetPassword = ErrorHandler.asyncHandler(async (req, res) => {
const result = await AuthService.resetPassword(req.body.token, req.body.password);
ErrorHandler.successResponse(res, result, result.message);
});
// Token als verwendet markieren
await resetToken.update({ used: true });
console.log('Password reset successful for user:', resetToken.userId);
return res.status(200).json({ message: 'Passwort erfolgreich zurückgesetzt' });
} catch (error) {
console.error('Reset password error:', error);
return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
}
};
exports.logout = async (req, res) => {
exports.logout = ErrorHandler.asyncHandler(async (req, res) => {
const authHeader = req.header('Authorization');
if (!authHeader) {
return res.status(400).json({ message: 'Kein Token bereitgestellt' });
}
const token = authHeader.replace('Bearer ', '');
try {
addTokenToBlacklist(token);
return res.status(200).json({ message: 'Logout erfolgreich' });
} catch (error) {
console.log(error);
return res.status(500).json({ message: 'Ein Fehler ist beim Logout aufgetreten' });
}
};
const token = authHeader ? authHeader.replace('Bearer ', '') : null;
const result = await AuthService.logout(token);
ErrorHandler.successResponse(res, result, result.message);
});

View File

@@ -3,16 +3,7 @@ const { Op } = require('sequelize');
const getAllContactPersons = async (req, res) => {
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const contactPersons = await ContactPerson.findAll({
where: {
[Op.or]: [
{ expiryDate: null },
{ expiryDate: { [Op.gte]: today } }
]
},
include: [
{
model: Position,
@@ -88,14 +79,6 @@ const filterContactPersons = async (req, res) => {
const where = {};
const having = [];
// Filter für nicht abgelaufene Kontaktpersonen
const today = new Date();
today.setHours(0, 0, 0, 0);
where[Op.or] = [
{ expiryDate: null },
{ expiryDate: { [Op.gte]: today } }
];
if (config.selection.id && config.selection.id === 'all') {
// No additional filter needed for "all"
} else if (config.selection.id) {

View File

@@ -1,188 +1,32 @@
const { Event, Institution, EventPlace, ContactPerson, EventType } = require('../models');
const { Op } = require('sequelize');
const moment = require('moment'); // Import von Moment.js
const EventService = require('../services/EventService');
const ErrorHandler = require('../utils/ErrorHandler');
const getAllEvents = async (req, res) => {
try {
const events = await Event.findAll({
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: ['name', 'date', 'time']
});
res.json(events);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch events' });
console.error(error);
}
};
const filterEvents = async (req, res) => {
try {
const request = req.body;
const where = {
[Op.or]: [
{
date: {
[Op.or]: [
{ [Op.gte]: moment().startOf('day').toDate() },
{ [Op.eq]: null }
]
}
},
{ dayOfWeek: { [Op.gte]: 0 } }
]
};
const order = [
['date', 'ASC'],
['time', 'ASC']
];
if (request.id === 'all') {
const events = await Event.findAll({
where,
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
logging: console.log // Log the generated SQL query
});
return res.json({ events });
}
if (request.id === 'home') {
const events = await Event.findAll({
where: {
alsoOnHomepage: 1,
date: { [Op.gte]: moment().startOf('day').toDate() }
},
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } },
],
order: order,
});
return res.json({ events });
}
if (!request.id && !request.places && !request.types) {
return res.json({ events: [], places: [], types: [], contactPersons: [] });
}
if (request.id) {
where.id = request.id;
}
if (request.places && request.places.length > 0) {
where.event_place_id = {
[Op.in]: request.places.map(id => parseInt(id))
};
}
if (request.types && request.types.length > 0) {
where.eventTypeId = {
[Op.in]: request.types.map(id => parseInt(id))
};
}
const events = await Event.findAll({
where,
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
});
const displayFields = request.display ? request.display : [];
const filteredEvents = events.map(event => {
const filteredEvent = { ...event.toJSON() };
if (!displayFields.includes('name')) delete filteredEvent.name;
if (!displayFields.includes('type')) delete filteredEvent.eventType;
if (!displayFields.includes('place')) delete filteredEvent.eventPlace;
if (!displayFields.includes('description')) delete filteredEvent.description;
if (!displayFields.includes('time')) delete filteredEvent.time;
if (!displayFields.includes('time')) delete filteredEvent.endTime;
if (!displayFields.includes('contactPerson')) delete filteredEvent.contactPersons;
if (!displayFields.includes('day')) delete filteredEvent.dayOfWeek;
if (!displayFields.includes('institution')) delete filteredEvent.institution;
return filteredEvent;
exports.getAllEvents = ErrorHandler.asyncHandler(async (req, res) => {
const events = await EventService.getAllEvents();
ErrorHandler.successResponse(res, events, 'Events erfolgreich abgerufen');
});
res.json({ events: filteredEvents });
} catch (error) {
res.status(500).json({ error: 'Failed to filter events' });
console.error(error);
}
};
const createEvent = async (req, res) => {
try {
const { contactPersonIds, ...eventData } = req.body;
eventData.alsoOnHomepage = eventData.alsoOnHomepage ?? 0;
const event = await Event.create(eventData);
if (contactPersonIds) {
await event.setContactPersons(contactPersonIds);
}
res.status(201).json(event);
} catch (error) {
res.status(500).json({ error: 'Failed to create event' });
console.error(error);
}
};
const updateEvent = async (req, res) => {
try {
const { id } = req.params;
const { contactPersonIds, ...eventData } = req.body;
const event = await Event.findByPk(id);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
await event.update(eventData);
if (contactPersonIds) {
await event.setContactPersons(contactPersonIds);
}
res.status(200).json(event);
} catch (error) {
res.status(500).json({ error: 'Failed to update event' });
console.error(error);
}
};
const deleteEvent = async (req, res) => {
try {
const { id } = req.params;
const deleted = await Event.destroy({
where: { id: id }
exports.getEventById = ErrorHandler.asyncHandler(async (req, res) => {
const event = await EventService.getEventById(req.params.id);
ErrorHandler.successResponse(res, event, 'Event erfolgreich abgerufen');
});
if (deleted) {
res.status(204).json();
} else {
res.status(404).json({ error: 'Event not found' });
}
} catch (error) {
res.status(500).json({ error: 'Failed to delete event' });
console.error(error);
}
};
module.exports = {
getAllEvents,
createEvent,
updateEvent,
deleteEvent,
filterEvents
};
exports.filterEvents = ErrorHandler.asyncHandler(async (req, res) => {
const result = await EventService.filterEvents(req.body);
ErrorHandler.successResponse(res, result, 'Events erfolgreich gefiltert');
});
exports.createEvent = ErrorHandler.asyncHandler(async (req, res) => {
const event = await EventService.createEvent(req.body);
ErrorHandler.successResponse(res, event, 'Event erfolgreich erstellt', 201);
});
exports.updateEvent = ErrorHandler.asyncHandler(async (req, res) => {
const event = await EventService.updateEvent(req.params.id, req.body);
ErrorHandler.successResponse(res, event, 'Event erfolgreich aktualisiert');
});
exports.deleteEvent = ErrorHandler.asyncHandler(async (req, res) => {
const result = await EventService.deleteEvent(req.params.id);
ErrorHandler.successResponse(res, result, result.message);
});

View File

@@ -1,154 +0,0 @@
const { LiturgicalDay } = require('../models');
const { Op } = require('sequelize');
const axios = require('axios');
// Alle liturgischen Tage abrufen
const getAllLiturgicalDays = async (req, res) => {
try {
const days = await LiturgicalDay.findAll({
order: [['date', 'ASC']]
});
res.status(200).json(days);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Abrufen der liturgischen Tage' });
}
};
// Eindeutige Namen für Multiselect abrufen
const getLiturgicalDayNames = async (req, res) => {
try {
const days = await LiturgicalDay.findAll({
attributes: ['dayName'],
group: ['dayName'],
order: [['dayName', 'ASC']]
});
const names = days.map(day => day.dayName);
res.status(200).json(names);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Abrufen der Tag-Namen' });
}
};
// HTML von liturgischem Kalender parsen und in DB speichern
const loadLiturgicalYear = async (req, res) => {
const { year } = req.body;
if (!year) {
return res.status(400).json({ message: 'Jahr ist erforderlich' });
}
const currentYear = new Date().getFullYear();
if (year < currentYear || year > currentYear + 2) {
return res.status(400).json({ message: 'Jahr muss zwischen aktuellem Jahr und 2 Jahren in der Zukunft liegen' });
}
try {
const url = `https://www.eike-fleer.de/liturgischer-kalender/${year}.htm`;
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
const html = response.data;
// Parse HTML - suche nach Tabellenzeilen mit Datum und Name
// Format: "DD.MM.YYYY &nbsp; &nbsp; &nbsp; DayName"
const regex = /(\d{2}\.\d{2}\.\d{4})\s*(?:&nbsp;|\s)+(.+?)(?:<\/|$)/gi;
const matches = [...html.matchAll(regex)];
const liturgicalDays = [];
for (const match of matches) {
const dateStr = match[1]; // DD.MM.YYYY
let dayName = match[2];
// Bereinige den Tag-Namen von HTML-Tags und Entities
dayName = dayName
.replace(/<[^>]*>/g, '') // Entferne HTML-Tags
.replace(/&nbsp;/g, ' ') // Ersetze &nbsp;
.replace(/&auml;/g, 'ä')
.replace(/&ouml;/g, 'ö')
.replace(/&uuml;/g, 'ü')
.replace(/&Auml;/g, 'Ä')
.replace(/&Ouml;/g, 'Ö')
.replace(/&Uuml;/g, 'Ü')
.replace(/&szlig;/g, 'ß')
.trim();
// Konvertiere Datum von DD.MM.YYYY zu YYYY-MM-DD
const [day, month, yearPart] = dateStr.split('.');
const isoDate = `${yearPart}-${month}-${day}`;
if (dayName && dayName.length > 0) {
liturgicalDays.push({
date: isoDate,
dayName: dayName
});
}
}
if (liturgicalDays.length === 0) {
return res.status(500).json({ message: 'Keine liturgischen Tage gefunden. Möglicherweise hat sich das HTML-Format geändert.' });
}
// Speichere oder aktualisiere die Einträge
for (const day of liturgicalDays) {
await LiturgicalDay.upsert({
date: day.date,
dayName: day.dayName
});
}
res.status(200).json({
message: `${liturgicalDays.length} liturgische Tage für ${year} erfolgreich geladen`,
count: liturgicalDays.length
});
} catch (error) {
console.error('Fehler beim Laden der liturgischen Tage:', error);
if (error.response && error.response.status === 404) {
return res.status(404).json({ message: `Liturgischer Kalender für ${year} nicht gefunden` });
}
res.status(500).json({ message: 'Fehler beim Laden der liturgischen Tage', error: error.message });
}
};
// Einzelnen Tag erstellen
const createLiturgicalDay = async (req, res) => {
try {
const day = await LiturgicalDay.create(req.body);
res.status(201).json(day);
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Erstellen des liturgischen Tags' });
}
};
// Tag löschen
const deleteLiturgicalDay = async (req, res) => {
try {
const { id } = req.params;
const deleted = await LiturgicalDay.destroy({
where: { id }
});
if (deleted) {
res.status(200).json({ message: 'Liturgischer Tag erfolgreich gelöscht' });
} else {
res.status(404).json({ message: 'Liturgischer Tag nicht gefunden' });
}
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Fehler beim Löschen des liturgischen Tags' });
}
};
module.exports = {
getAllLiturgicalDays,
getLiturgicalDayNames,
loadLiturgicalYear,
createLiturgicalDay,
deleteLiturgicalDay
};

View File

@@ -1,30 +1,12 @@
const { MenuItem } = require('../models');
const fetchMenuData = require('../utils/fetchMenuData');
const MenuDataService = require('../services/MenuDataService');
const ErrorHandler = require('../utils/ErrorHandler');
exports.getMenuData = async (req, res) => {
try {
const menuData = await fetchMenuData();
res.json(menuData);
} catch (error) {
res.status(500).send('Error fetching menu data');
}
};
exports.getMenuData = ErrorHandler.asyncHandler(async (req, res) => {
const menuData = await MenuDataService.getMenuData();
ErrorHandler.successResponse(res, menuData, 'Menü-Daten erfolgreich abgerufen');
});
exports.saveMenuData = async (req, res) => {
try {
const menuData = req.body;
const adjustedMenuData = menuData.map(item => {
item.parent_id = item.parent_id < 0 ? null : item.parent_id;
return item;
})
.sort((a, b) => (a.parent_id === null ? -1 : 1) - (b.parent_id === null ? -1 : 1));
await MenuItem.destroy({ where: {} });
for (const item of adjustedMenuData) {
await MenuItem.create(item);
}
res.status(200).send('Menü-Daten erfolgreich gespeichert');
} catch (error) {
console.error('Fehler beim Speichern der Menü-Daten:', error);
res.status(500).send('Fehler beim Speichern der Menü-Daten');
}
};
exports.saveMenuData = ErrorHandler.asyncHandler(async (req, res) => {
const result = await MenuDataService.saveMenuData(req.body);
ErrorHandler.successResponse(res, result, result.message);
});

View File

@@ -1,48 +1,27 @@
// controllers/pageController.js
const { Page } = require('../models');
const PageService = require('../services/PageService');
const ErrorHandler = require('../utils/ErrorHandler');
exports.getMenuData = async (req, res) => {
try {
const pages = await Page.findAll({
attributes: ['link', 'name']
exports.getMenuData = ErrorHandler.asyncHandler(async (req, res) => {
const pages = await PageService.getAllPages();
ErrorHandler.successResponse(res, pages, 'Seiten erfolgreich abgerufen');
});
res.json(pages);
} catch (error) {
console.error('Fehler beim Abrufen der Seiten:', error);
res.status(500).json({ message: 'Fehler beim Abrufen der Seiten' });
}
};
exports.getPageContent = async (req, res) => {
try {
const page = await Page.findOne({
where: { link: req.query.link }
exports.getPageContent = ErrorHandler.asyncHandler(async (req, res) => {
const result = await PageService.getPageContent(req.query.link);
ErrorHandler.successResponse(res, result, 'Seiteninhalt erfolgreich abgerufen');
});
if (page) {
res.json({ content: page.content });
} else {
res.json({ content: "" });
}
} catch (error) {
console.error('Fehler beim Laden des Seiteninhalts:', error);
res.status(500).json({ message: 'Fehler beim Laden des Seiteninhalts' });
}
};
exports.savePageContent = async (req, res) => {
try {
const { link, name, content } = req.body;
let page = await Page.findOne({ where: { link } });
if (page) {
page.content = content;
page.name = name;
} else {
page = await Page.create({ link, name, content });
}
await page.save();
res.json({ message: 'Seiteninhalt gespeichert', page });
} catch (error) {
console.error('Fehler beim Speichern des Seiteninhalts:', error);
res.status(500).json({ message: 'Fehler beim Speichern des Seiteninhalts' });
}
};
exports.savePageContent = ErrorHandler.asyncHandler(async (req, res) => {
const result = await PageService.savePageContent(req.body);
ErrorHandler.successResponse(res, result, result.message);
});
exports.getPageById = ErrorHandler.asyncHandler(async (req, res) => {
const page = await PageService.getPageById(req.params.id);
ErrorHandler.successResponse(res, page, 'Seite erfolgreich abgerufen');
});
exports.deletePage = ErrorHandler.asyncHandler(async (req, res) => {
const result = await PageService.deletePage(req.params.id);
ErrorHandler.successResponse(res, result, result.message);
});

View File

@@ -1,104 +1,42 @@
const { User } = require('../models');
const UserService = require('../services/UserService');
const UserValidator = require('../validators/UserValidator');
const ErrorHandler = require('../utils/ErrorHandler');
exports.getAllUsers = async (req, res) => {
try {
const users = await User.findAll({
order: [['name', 'ASC']],
attributes: ['id', 'name', 'email', 'active', 'created_at'] // Passwort ausschließen
exports.getAllUsers = ErrorHandler.asyncHandler(async (req, res) => {
const users = await UserService.getAllUsers();
ErrorHandler.successResponse(res, users, 'Benutzer erfolgreich abgerufen');
});
res.status(200).json(users);
} catch (error) {
console.error('Error fetching users:', error);
res.status(500).json({ message: 'Error fetching users' });
}
};
exports.getUserById = async (req, res) => {
try {
const user = await User.findByPk(req.params.id, {
attributes: ['id', 'name', 'email', 'active', 'created_at'] // Passwort ausschließen
exports.getUserById = ErrorHandler.asyncHandler(async (req, res) => {
UserValidator.validateId(req.params.id);
const user = await UserService.getUserById(req.params.id);
ErrorHandler.successResponse(res, user, 'Benutzer erfolgreich abgerufen');
});
if (user) {
res.status(200).json(user);
} else {
res.status(404).json({ message: 'User not found' });
}
} catch (error) {
console.error('Error fetching user:', error);
res.status(500).json({ message: 'Error fetching user' });
}
};
exports.createUser = async (req, res) => {
try {
const user = await User.create(req.body);
exports.createUser = ErrorHandler.asyncHandler(async (req, res) => {
UserValidator.validateCreateUser(req.body);
const user = await UserService.createUser(req.body);
ErrorHandler.successResponse(res, user, 'Benutzer erfolgreich erstellt', 201);
});
// Sichere User-Daten zurückgeben (ohne Passwort)
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at
};
exports.updateUser = ErrorHandler.asyncHandler(async (req, res) => {
UserValidator.validateId(req.params.id);
UserValidator.validateUpdateUser(req.body);
const user = await UserService.updateUser(req.params.id, req.body);
ErrorHandler.successResponse(res, user, 'Benutzer erfolgreich aktualisiert');
});
res.status(201).json(safeUser);
} catch (error) {
console.error('Error creating user:', error);
res.status(500).json({ message: 'Error creating user' });
}
};
exports.deleteUser = ErrorHandler.asyncHandler(async (req, res) => {
UserValidator.validateId(req.params.id);
await UserService.deleteUser(req.params.id);
ErrorHandler.successResponse(res, null, 'Benutzer erfolgreich gelöscht');
});
exports.updateUser = async (req, res) => {
try {
const user = await User.findByPk(req.params.id);
if (user) {
// Erstelle eine Kopie der Request-Daten ohne sensible Felder
const updateData = { ...req.body };
// Entferne sensible Felder, die niemals über diese Route geändert werden dürfen
delete updateData.password;
delete updateData.id;
delete updateData.created_at;
// Setze updated_at auf aktuelle Zeit
updateData.updated_at = new Date();
// Logging für Debugging
console.log('Updating user:', req.params.id, 'with data:', updateData);
await user.update(updateData);
// Sichere User-Daten zurückgeben (ohne Passwort)
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at
};
res.status(200).json(safeUser);
} else {
res.status(404).json({ message: 'User not found' });
}
} catch (error) {
console.error('Error updating user:', error);
res.status(500).json({ message: 'Error updating user' });
}
};
exports.deleteUser = async (req, res) => {
try {
const user = await User.findByPk(req.params.id);
if (user) {
await user.destroy();
res.status(200).json({ message: 'User deleted successfully' });
} else {
res.status(404).json({ message: 'User not found' });
}
} catch (error) {
console.error('Error deleting user:', error);
res.status(500).json({ message: 'Error deleting user' });
}
};
// Neue Route für Passwort-Änderung
exports.changePassword = ErrorHandler.asyncHandler(async (req, res) => {
const { currentPassword, newPassword } = req.body;
UserValidator.validateId(req.params.id);
UserValidator.validatePasswordChange(currentPassword, newPassword);
await UserService.changePassword(req.params.id, currentPassword, newPassword);
ErrorHandler.successResponse(res, null, 'Passwort erfolgreich geändert');
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Farben
GREEN="\033[0;32m"
RED="\033[0;31m"
NC="\033[0m"
log() { echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] $*${NC}"; }
err() { echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] $*${NC}" 1>&2; }
log "Fetching latest changes..."
git fetch --all --prune || { err "git fetch failed"; exit 1; }
log "Pulling latest changes..."
git pull --ff-only || { err "git pull failed"; exit 1; }
log "Installing dependencies..."
npm ci || npm install || { err "npm install failed"; exit 1; }
log "Building frontend..."
npm run build || { err "build failed"; exit 1; }
log "Copying dist -> public..."
mkdir -p public || true
cp -R dist/* public/ || { err "copy dist failed"; exit 1; }
log "Restarting service miriamgemeinde..."
sudo systemctl restart miriamgemeinde || { err "service restart failed"; exit 1; }
log "Deployment completed successfully."

View File

@@ -1,28 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.createTable('liturgical_days', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
date: {
type: Sequelize.DATEONLY,
allowNull: false,
unique: true
},
dayName: {
type: Sequelize.STRING,
allowNull: false
}
});
},
async down (queryInterface, Sequelize) {
await queryInterface.dropTable('liturgical_days');
}
};

View File

@@ -1,16 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('worships', 'organ_playing', {
type: Sequelize.STRING,
allowNull: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('worships', 'organ_playing');
}
};

View File

@@ -1,17 +0,0 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('worships', 'approved', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('worships', 'approved');
}
};

View File

@@ -25,10 +25,6 @@ module.exports = (sequelize) => {
email: {
type: DataTypes.STRING,
allowNull: true
},
expiryDate: {
type: DataTypes.DATEONLY,
allowNull: true
}
}, {
tableName: 'contact_persons',

View File

@@ -1,24 +0,0 @@
module.exports = (sequelize, DataTypes) => {
const LiturgicalDay = sequelize.define('LiturgicalDay', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
date: {
type: DataTypes.DATEONLY,
allowNull: false,
unique: true
},
dayName: {
type: DataTypes.STRING,
allowNull: false
}
}, {
tableName: 'liturgical_days',
timestamps: false
});
return LiturgicalDay;
};

View File

@@ -61,17 +61,6 @@ module.exports = (sequelize) => {
allowNull: true,
field: 'sacristan_service'
},
organPlaying: {
type: DataTypes.STRING,
allowNull: true,
field: 'organ_playing'
},
approved: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
field: 'approved'
},
}, {
tableName: 'worships',
timestamps: true

27353
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",
"@vue/cli": "^4.2.2",
"@vue/cli": "^5.0.8",
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
@@ -34,12 +34,10 @@
"cors": "^2.8.5",
"crypto": "^1.0.1",
"date-fns": "^3.6.0",
"docx": "^9.5.1",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"file-saver": "^2.0.5",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.11.0",
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1",

View File

@@ -1,6 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
]
}

View File

@@ -1,19 +0,0 @@
const express = require('express');
const router = express.Router();
const {
getAllLiturgicalDays,
getLiturgicalDayNames,
loadLiturgicalYear,
createLiturgicalDay,
deleteLiturgicalDay
} = require('../controllers/liturgicalDayController');
const authMiddleware = require('../middleware/authMiddleware');
router.get('/', getAllLiturgicalDays);
router.get('/names', getLiturgicalDayNames);
router.post('/load-year', authMiddleware, loadLiturgicalYear);
router.post('/', authMiddleware, createLiturgicalDay);
router.delete('/:id', authMiddleware, deleteLiturgicalDay);
module.exports = router;

View File

@@ -1,12 +1,13 @@
const express = require('express');
const router = express.Router();
const { getAllUsers, createUser, updateUser, deleteUser, getUserById } = require('../controllers/userController');
const { getAllUsers, createUser, updateUser, deleteUser, getUserById, changePassword } = require('../controllers/userController');
const authMiddleware = require('../middleware/authMiddleware');
router.get('/', authMiddleware, getAllUsers);
router.get('/:id', authMiddleware, getUserById);
router.post('/', authMiddleware, createUser);
router.put('/:id', authMiddleware, updateUser);
router.put('/:id/change-password', authMiddleware, changePassword);
router.delete('/:id', authMiddleware, deleteUser);
module.exports = router;

View File

@@ -1,16 +1,12 @@
const express = require('express');
const router = express.Router();
const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships, getWorshipOptions, importWorships, uploadImportFile, exportWorships, saveImportedWorships } = require('../controllers/worshipController');
const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships } = require('../controllers/worshipController');
const authMiddleware = require('../middleware/authMiddleware');
router.get('/', getAllWorships);
router.get('/options', getWorshipOptions);
router.post('/', authMiddleware, createWorship);
router.post('/import', authMiddleware, uploadImportFile, importWorships);
router.post('/import/save', authMiddleware, saveImportedWorships);
router.put('/:id', authMiddleware, updateWorship);
router.delete('/:id', authMiddleware, deleteWorship);
router.get('/filtered', getFilteredWorships);
router.get('/export', authMiddleware, exportWorships);
module.exports = router;

View File

@@ -2,14 +2,7 @@ const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const https = require('https');
const http = require('http');
const fs = require('fs');
require('dotenv').config();
// Erhöhe maxHttpHeaderSize für Node.js (Standard ist 8KB, erhöhe auf 16KB)
if (http.maxHeaderSize !== undefined) {
http.maxHeaderSize = 16384;
}
const sequelize = require('./config/database');
const authRouter = require('./routes/auth');
const eventTypesRouter = require('./routes/eventtypes');
@@ -24,79 +17,12 @@ const pageRouter = require('./routes/pages');
const userRouter = require('./routes/users');
const imageRouter = require('./routes/image');
const filesRouter = require('./routes/files');
const liturgicalDaysRouter = require('./routes/liturgicalDays');
const app = express();
const PORT = parseInt(process.env.PORT, 10) || 3000;
const PORT = 3002;
// CORS mit Whitelist und tolerantem Fallback für fehlende Origin-Header
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
if (!origin) {
return callback(null, true); // z.B. Healthchecks/curl/Server-zu-Server
}
if (allowedOrigins.length === 0) {
return callback(null, true); // Fallback: alles erlauben
}
// Prüfe exakte Übereinstimmung
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
// Für Entwicklung: Erlaube localhost und torstens auf jedem Port
try {
const originUrl = new URL(origin);
const hostname = originUrl.hostname.toLowerCase();
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
const isTorstens = hostname === 'torstens' || hostname.includes('torstens');
if (isLocalhost || isTorstens) {
return callback(null, true);
}
} catch (e) {
// Falls URL-Parsing fehlschlägt, prüfe mit Regex
const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|::1)(:\d+)?$/.test(origin);
const isTorstens = /^https?:\/\/torstens(:\d+)?/.test(origin);
if (isLocalhost || isTorstens) {
return callback(null, true);
}
}
// Prüfe auch ohne Port (für Flexibilität)
const originWithoutPort = origin.replace(/:\d+$/, '');
const allowedWithoutPort = allowedOrigins.some(allowed => {
const allowedWithoutPort = allowed.replace(/:\d+$/, '');
return originWithoutPort === allowedWithoutPort;
});
if (allowedWithoutPort) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'), false);
},
credentials: true,
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
allowedHeaders: ['Content-Type','Authorization']
}));
app.options('*', cors());
// Erhöhe Header-Limits für große Requests
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
// Erhöhe maxHttpHeaderSize (Node.js 18.3.0+)
if (process.versions.node.split('.')[0] >= 18) {
require('http').maxHeaderSize = 16384; // 16KB (Standard ist 8KB)
}
app.use(cors());
app.use(bodyParser.json());
app.use('/api/auth', authRouter);
app.use('/api/event-types', eventTypesRouter);
@@ -111,7 +37,6 @@ app.use('/api/page-content', pageRouter);
app.use('/api/users', userRouter);
app.use('/api/image', imageRouter);
app.use('/api/files', filesRouter);
app.use('/api/liturgical-days', liturgicalDaysRouter);
const options = {
key: fs.readFileSync('server.key'),
@@ -123,7 +48,7 @@ sequelize.sync().then(() => {
/* https.createServer(options, app).listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
});*/
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server läuft auf Port ${PORT} (IPv4 und IPv6)`);
app.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
});
});

186
services/AuthService.js Normal file
View File

@@ -0,0 +1,186 @@
const bcrypt = require('bcryptjs');
const { User, PasswordResetToken } = require('../models');
const jwt = require('jsonwebtoken');
const { addTokenToBlacklist } = require('../utils/blacklist');
const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
const crypto = require('crypto');
class AuthService {
/**
* User registrieren
*/
async register(userData) {
const { name, email, password } = userData;
if (!name || !email || !password) {
throw new Error('VALIDATION_ERROR: Alle Felder sind erforderlich');
}
// Prüfen ob E-Mail bereits existiert
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
throw new Error('EMAIL_ALREADY_EXISTS');
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({
name,
email,
password: hashedPassword,
active: true
});
return this.getSafeUserData(user);
}
/**
* User einloggen
*/
async login(credentials) {
const { email, password } = credentials;
if (!email || !password) {
throw new Error('VALIDATION_ERROR: Email und Passwort sind erforderlich');
}
const user = await User.findOne({ where: { email } });
if (!user) {
throw new Error('INVALID_CREDENTIALS');
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
throw new Error('INVALID_CREDENTIALS');
}
if (!user.active) {
throw new Error('ACCOUNT_INACTIVE');
}
const token = jwt.sign(
{ id: user.id, name: user.name, email: user.email },
'zTxVgptmPl9!_dr%xxx9999(dd)',
{ expiresIn: '1h' }
);
return {
message: 'Login erfolgreich',
token,
user: this.getSafeUserData(user)
};
}
/**
* User ausloggen
*/
async logout(token) {
if (!token) {
throw new Error('VALIDATION_ERROR: Kein Token bereitgestellt');
}
addTokenToBlacklist(token);
return { message: 'Logout erfolgreich' };
}
/**
* Passwort vergessen - E-Mail senden
*/
async forgotPassword(email) {
if (!email) {
throw new Error('VALIDATION_ERROR: E-Mail-Adresse ist erforderlich');
}
const user = await User.findOne({ where: { email } });
if (!user) {
// Aus Sicherheitsgründen immer Erfolg melden
return { message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' };
}
// Alte Reset-Tokens für diesen User löschen
await PasswordResetToken.destroy({ where: { userId: user.id } });
// Neuen Reset-Token generieren
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde
await PasswordResetToken.create({
userId: user.id,
token,
expiresAt
});
// Reset-URL generieren
const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`;
// E-Mail versenden
const emailTemplate = getPasswordResetEmailTemplate(resetUrl, user.name);
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@miriamgemeinde.de',
to: email,
subject: emailTemplate.subject,
html: emailTemplate.html,
text: emailTemplate.text
});
console.log('Password reset email sent to:', email);
return { message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' };
}
/**
* Passwort zurücksetzen
*/
async resetPassword(token, newPassword) {
if (!token || !newPassword) {
throw new Error('VALIDATION_ERROR: Token und neues Passwort sind erforderlich');
}
if (newPassword.length < 6) {
throw new Error('VALIDATION_ERROR: Passwort muss mindestens 6 Zeichen lang sein');
}
// Token validieren
const resetToken = await PasswordResetToken.findOne({
where: {
token,
used: false,
expiresAt: {
[require('sequelize').Op.gt]: new Date()
}
},
include: [{ model: User, as: 'user' }]
});
if (!resetToken) {
throw new Error('INVALID_RESET_TOKEN');
}
// Passwort hashen und aktualisieren
const hashedPassword = await bcrypt.hash(newPassword, 10);
await User.update(
{ password: hashedPassword },
{ where: { id: resetToken.userId } }
);
// Token als verwendet markieren
await resetToken.update({ used: true });
console.log('Password reset successful for user:', resetToken.userId);
return { message: 'Passwort erfolgreich zurückgesetzt' };
}
/**
* Sichere User-Daten extrahieren (ohne Passwort)
*/
getSafeUserData(user) {
return {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at
};
}
}
module.exports = new AuthService();

276
services/EventService.js Normal file
View File

@@ -0,0 +1,276 @@
const { Event, Institution, EventPlace, ContactPerson, EventType } = require('../models');
const { Op } = require('sequelize');
const moment = require('moment');
class EventService {
/**
* Alle Events abrufen
*/
async getAllEvents() {
try {
const events = await Event.findAll({
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: ['name', 'date', 'time']
});
return events;
} catch (error) {
console.error('Error fetching all events:', error);
throw new Error('EVENTS_FETCH_ERROR');
}
}
/**
* Event anhand ID abrufen
*/
async getEventById(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const event = await Event.findByPk(id, {
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
]
});
if (!event) {
throw new Error('EVENT_NOT_FOUND');
}
return event;
} catch (error) {
console.error('Error fetching event by ID:', error);
throw new Error('EVENT_FETCH_ERROR');
}
}
/**
* Events filtern
*/
async filterEvents(filterData) {
try {
const { id, places, types, display } = filterData;
// Basis-Where-Klausel für zukünftige Events
const where = {
[Op.or]: [
{
date: {
[Op.or]: [
{ [Op.gte]: moment().startOf('day').toDate() },
{ [Op.eq]: null }
]
}
},
{ dayOfWeek: { [Op.gte]: 0 } }
]
};
const order = [
['date', 'ASC'],
['time', 'ASC']
];
// Spezielle Filter
if (id === 'all') {
return await this._getAllFutureEvents(where, order);
}
if (id === 'home') {
return await this._getHomepageEvents(where, order);
}
if (!id && !places && !types) {
return { events: [], places: [], types: [], contactPersons: [] };
}
// Weitere Filter anwenden
if (id) {
where.id = id;
}
if (places && places.length > 0) {
where.event_place_id = {
[Op.in]: places.map(id => parseInt(id))
};
}
if (types && types.length > 0) {
where.eventTypeId = {
[Op.in]: types.map(id => parseInt(id))
};
}
const events = await Event.findAll({
where,
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
});
// Events basierend auf Display-Feldern filtern
const displayFields = display || [];
const filteredEvents = this._filterEventFields(events, displayFields);
return { events: filteredEvents };
} catch (error) {
console.error('Error filtering events:', error);
throw new Error('EVENT_FILTER_ERROR');
}
}
/**
* Event erstellen
*/
async createEvent(eventData) {
try {
const { contactPersonIds, ...eventDataWithoutContacts } = eventData;
// Validierung
if (!eventDataWithoutContacts.name) {
throw new Error('VALIDATION_ERROR: Event-Name ist erforderlich');
}
eventDataWithoutContacts.alsoOnHomepage = eventDataWithoutContacts.alsoOnHomepage ?? 0;
const event = await Event.create(eventDataWithoutContacts);
if (contactPersonIds && contactPersonIds.length > 0) {
await event.setContactPersons(contactPersonIds);
}
return event;
} catch (error) {
console.error('Error creating event:', error);
throw new Error('EVENT_CREATE_ERROR');
}
}
/**
* Event aktualisieren
*/
async updateEvent(id, eventData) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const { contactPersonIds, ...eventDataWithoutContacts } = eventData;
const event = await Event.findByPk(id);
if (!event) {
throw new Error('EVENT_NOT_FOUND');
}
await event.update(eventDataWithoutContacts);
if (contactPersonIds !== undefined) {
await event.setContactPersons(contactPersonIds || []);
}
return event;
} catch (error) {
console.error('Error updating event:', error);
throw new Error('EVENT_UPDATE_ERROR');
}
}
/**
* Event löschen
*/
async deleteEvent(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const event = await Event.findByPk(id);
if (!event) {
throw new Error('EVENT_NOT_FOUND');
}
await event.destroy();
return { message: 'Event erfolgreich gelöscht' };
} catch (error) {
console.error('Error deleting event:', error);
throw new Error('EVENT_DELETE_ERROR');
}
}
/**
* Alle zukünftigen Events abrufen
*/
async _getAllFutureEvents(where, order) {
const events = await Event.findAll({
where,
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } }
],
order: order,
logging: console.log
});
return { events };
}
/**
* Homepage Events abrufen
*/
async _getHomepageEvents(where, order) {
const events = await Event.findAll({
where: {
alsoOnHomepage: 1,
date: { [Op.gte]: moment().startOf('day').toDate() }
},
include: [
{ model: Institution, as: 'institution' },
{ model: EventPlace, as: 'eventPlace' },
{ model: EventType, as: 'eventType' },
{ model: ContactPerson, as: 'contactPersons', through: { attributes: [] } },
],
order: order,
});
return { events };
}
/**
* Event-Felder basierend auf Display-Feldern filtern
*/
_filterEventFields(events, displayFields) {
return events.map(event => {
const filteredEvent = { ...event.toJSON() };
if (!displayFields.includes('name')) delete filteredEvent.name;
if (!displayFields.includes('type')) delete filteredEvent.eventType;
if (!displayFields.includes('place')) delete filteredEvent.eventPlace;
if (!displayFields.includes('description')) delete filteredEvent.description;
if (!displayFields.includes('time')) delete filteredEvent.time;
if (!displayFields.includes('time')) delete filteredEvent.endTime;
if (!displayFields.includes('contactPerson')) delete filteredEvent.contactPersons;
if (!displayFields.includes('day')) delete filteredEvent.dayOfWeek;
if (!displayFields.includes('institution')) delete filteredEvent.institution;
return filteredEvent;
});
}
}
module.exports = new EventService();

101
services/MenuDataService.js Normal file
View File

@@ -0,0 +1,101 @@
const { MenuItem } = require('../models');
class MenuDataService {
/**
* Alle Menü-Daten abrufen
*/
async getMenuData() {
try {
const menuItems = await MenuItem.findAll({
order: [['order_id', 'ASC']],
include: [{
model: MenuItem,
as: 'submenu',
required: false,
order: [['order_id', 'ASC']]
}]
});
const menuData = this.buildMenuStructure(menuItems);
return menuData;
} catch (error) {
console.error('Error fetching menu data:', error);
throw new Error('MENU_DATA_FETCH_ERROR');
}
}
/**
* Menü-Daten speichern
*/
async saveMenuData(menuData) {
try {
if (!Array.isArray(menuData)) {
throw new Error('VALIDATION_ERROR: Menü-Daten müssen ein Array sein');
}
// Menü-Daten anpassen
const adjustedMenuData = menuData.map(item => {
item.parent_id = item.parent_id < 0 ? null : item.parent_id;
return item;
}).sort((a, b) => (a.parent_id === null ? -1 : 1) - (b.parent_id === null ? -1 : 1));
// Alle bestehenden Menü-Items löschen
await MenuItem.destroy({ where: {} });
// Neue Menü-Items erstellen
for (const item of adjustedMenuData) {
await MenuItem.create(item);
}
return { message: 'Menü-Daten erfolgreich gespeichert' };
} catch (error) {
console.error('Error saving menu data:', error);
throw new Error('MENU_DATA_SAVE_ERROR');
}
}
/**
* Menü-Struktur aufbauen
*/
buildMenuStructure(menuItems) {
const menu = [];
const itemMap = {};
// Alle Items in Map speichern
menuItems.forEach(item => {
itemMap[item.id] = {
id: item.id,
name: item.name,
link: item.link,
component: item.component,
showInMenu: item.show_in_menu,
requiresAuth: item.requires_auth,
order_id: item.order_id,
pageTitle: item.page_title,
image: item.image,
submenu: []
};
});
// Hierarchie aufbauen
menuItems.forEach(item => {
if (item.parent_id) {
if (itemMap[item.parent_id]) {
itemMap[item.parent_id].submenu.push(itemMap[item.id]);
}
} else {
menu.push(itemMap[item.id]);
}
});
// Sortierung anwenden
menu.sort((a, b) => a.order_id - b.order_id);
menu.forEach(item => {
item.submenu.sort((a, b) => a.order_id - b.order_id);
});
return menu;
}
}
module.exports = new MenuDataService();

132
services/PageService.js Normal file
View File

@@ -0,0 +1,132 @@
const { Page } = require('../models');
class PageService {
/**
* Seiteninhalt anhand Link abrufen
*/
async getPageContent(link) {
try {
if (!link) {
throw new Error('VALIDATION_ERROR: Link ist erforderlich');
}
const page = await Page.findOne({ where: { link } });
if (!page) {
throw new Error('PAGE_NOT_FOUND');
}
return {
content: page.content || '',
title: page.title || '',
link: page.link
};
} catch (error) {
console.error('Error fetching page content:', error);
throw new Error('PAGE_CONTENT_FETCH_ERROR');
}
}
/**
* Seiteninhalt speichern
*/
async savePageContent(pageData) {
try {
const { link, name, content } = pageData;
if (!link || !name) {
throw new Error('VALIDATION_ERROR: Link und Name sind erforderlich');
}
// Prüfen ob Seite bereits existiert
const existingPage = await Page.findOne({ where: { link } });
if (existingPage) {
// Seite aktualisieren
await existingPage.update({
content: content || '',
title: name,
updated_at: new Date()
});
} else {
// Neue Seite erstellen
await Page.create({
link,
title: name,
content: content || '',
created_at: new Date(),
updated_at: new Date()
});
}
return { message: 'Seiteninhalt erfolgreich gespeichert' };
} catch (error) {
console.error('Error saving page content:', error);
throw new Error('PAGE_CONTENT_SAVE_ERROR');
}
}
/**
* Alle Seiten abrufen
*/
async getAllPages() {
try {
const pages = await Page.findAll({
order: [['title', 'ASC']],
attributes: ['id', 'link', 'title', 'created_at', 'updated_at']
});
return pages;
} catch (error) {
console.error('Error fetching all pages:', error);
throw new Error('PAGES_FETCH_ERROR');
}
}
/**
* Seite anhand ID abrufen
*/
async getPageById(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const page = await Page.findByPk(id);
if (!page) {
throw new Error('PAGE_NOT_FOUND');
}
return page;
} catch (error) {
console.error('Error fetching page by ID:', error);
throw new Error('PAGE_FETCH_ERROR');
}
}
/**
* Seite löschen
*/
async deletePage(id) {
try {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
const page = await Page.findByPk(id);
if (!page) {
throw new Error('PAGE_NOT_FOUND');
}
await page.destroy();
return { message: 'Seite erfolgreich gelöscht' };
} catch (error) {
console.error('Error deleting page:', error);
throw new Error('PAGE_DELETE_ERROR');
}
}
}
module.exports = new PageService();

140
services/UserService.js Normal file
View File

@@ -0,0 +1,140 @@
const { User } = require('../models');
const bcrypt = require('bcryptjs');
class UserService {
/**
* Alle User abrufen (ohne sensible Daten)
*/
async getAllUsers() {
const users = await User.findAll({
order: [['name', 'ASC']],
attributes: ['id', 'name', 'email', 'active', 'created_at', 'updated_at']
});
return users;
}
/**
* User anhand ID abrufen (ohne sensible Daten)
*/
async getUserById(id) {
const user = await User.findByPk(id, {
attributes: ['id', 'name', 'email', 'active', 'created_at', 'updated_at']
});
if (!user) {
throw new Error('USER_NOT_FOUND');
}
return user;
}
/**
* Neuen User erstellen
*/
async createUser(userData) {
// Passwort hashen falls vorhanden
if (userData.password) {
userData.password = await bcrypt.hash(userData.password, 10);
}
const user = await User.create(userData);
// Sichere User-Daten zurückgeben
return this.getSafeUserData(user);
}
/**
* User aktualisieren (ohne sensible Felder)
*/
async updateUser(id, updateData) {
const user = await User.findByPk(id);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
// Erstelle sichere Update-Daten
const safeUpdateData = this.getSafeUpdateData(updateData);
await user.update(safeUpdateData);
return this.getSafeUserData(user);
}
/**
* User löschen
*/
async deleteUser(id) {
const user = await User.findByPk(id);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
await user.destroy();
return true;
}
/**
* User anhand E-Mail abrufen (für interne Verwendung)
*/
async getUserByEmail(email) {
return await User.findOne({ where: { email } });
}
/**
* Passwort ändern (separate Methode für sichere Passwort-Änderung)
*/
async changePassword(id, currentPassword, newPassword) {
const user = await User.findByPk(id);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
// Aktuelles Passwort prüfen
const isValidPassword = await bcrypt.compare(currentPassword, user.password);
if (!isValidPassword) {
throw new Error('INVALID_CURRENT_PASSWORD');
}
// Neues Passwort hashen und speichern
const hashedPassword = await bcrypt.hash(newPassword, 10);
await user.update({ password: hashedPassword });
return true;
}
/**
* Sichere User-Daten extrahieren (ohne Passwort)
*/
getSafeUserData(user) {
return {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
created_at: user.created_at,
updated_at: user.updated_at
};
}
/**
* Sichere Update-Daten erstellen (ohne sensible Felder)
*/
getSafeUpdateData(updateData) {
const safeData = { ...updateData };
// Entferne sensible Felder
delete safeData.password;
delete safeData.id;
delete safeData.created_at;
// Setze updated_at
safeData.updated_at = new Date();
return safeData;
}
}
module.exports = new UserService();

View File

@@ -1,4 +0,0 @@
-- Ablaufdatum zu Kontaktpersonen hinzufügen
ALTER TABLE `contact_persons`
ADD COLUMN `expiryDate` DATE NULL AFTER `email`;

View File

@@ -1,12 +0,0 @@
-- Tabelle für liturgische Kalendertage erstellen
CREATE TABLE IF NOT EXISTS `liturgical_days` (
`id` INT NOT NULL AUTO_INCREMENT,
`date` DATE NOT NULL,
`dayName` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Index für schnellere Suche nach dayName
CREATE INDEX `idx_dayName` ON `liturgical_days` (`dayName`);

View File

@@ -2,11 +2,8 @@ import axios from 'axios';
import store from './store';
import router from './router';
// Einheitliche Basis-URL:
// - immer relativ zur aktuellen Origin
// - kein absoluter http/https-Host → verhindert Mixed-Content-Probleme
axios.defaults.baseURL = '/api';
console.log('Axios baseURL:', axios.defaults.baseURL);
axios.defaults.baseURL = process.env.VUE_APP_BACKEND_URL;
console.log(process.env.VUE_APP_BACKEND_URL);
axios.interceptors.request.use(
config => {
@@ -27,11 +24,8 @@ axios.interceptors.response.use(
},
error => {
if (error.response && error.response.status === 401) {
store.dispatch('logout').then(() => {
if (router.currentRoute.value.path !== '/auth/login') {
router.replace('/auth/login');
}
});
store.dispatch('logout');
router.push('/auth/login');
}
return Promise.reject(error);
}

View File

@@ -36,53 +36,27 @@ export default {
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
top: calc(50% - 25em);
left: 5%;
width: 90%;
height: 50em;
background: rgba(0, 0, 0, .5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
overflow: auto;
}
.dialog {
background: white;
padding: 30px;
border-radius: 8px;
padding: 20px;
border-radius: 5px;
max-width: 400px;
width: 90%;
width: 100%;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.dialog h2 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
font-size: 20px;
}
.dialog p {
margin: 15px 0;
color: #666;
line-height: 1.5;
}
.dialog button {
button {
margin-top: 20px;
padding: 10px 20px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.dialog button:hover {
background-color: #0056b3;
}
</style>

View File

@@ -2,7 +2,7 @@
<footer class="footer">
<div class="left-links">
<router-link class="login-link" to="/auth/login" v-if="!isLoggedIn">Login</router-link>
<a v-if="isLoggedIn" @click="handleLogout" class="logout-link">Logout</a>
<a v-if="isLoggedIn" @click="logout" class="logout-link">Logout</a>
</div>
<div class="right-links">
<router-link to="/terms">Impressum</router-link>

View File

@@ -20,9 +20,6 @@
<label for="email">Email:</label>
<input type="email" id="email" v-model="localContactPerson.email">
<label for="expiryDate">Ablaufdatum (optional):</label>
<input type="date" id="expiryDate" v-model="localContactPerson.expiryDate">
<label for="positions">Positionen:</label>
<multiselect
v-model="selectedPositions"
@@ -60,7 +57,6 @@ export default {
zipcode: '',
city: '',
email: '',
expiryDate: null,
positions: []
})
},
@@ -115,7 +111,6 @@ export default {
zipcode: '',
city: '',
email: '',
expiryDate: null,
positions: []
};
this.selectedPositions = [];

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="config && config.style === 'box' && contacts && contacts.length && contacts.length > 0">
<div v-for="contact in contacts" :key="contact.id" class="contact-box bottom-margin">
<p>{{ contact.name }} <span v-if="contact.expiryDate" class="expiry-date">(bis {{ formatDate(contact.expiryDate) }})</span></p>
<p>{{ contact.name }}</p>
<p v-if="displayOptions.includes('phone')">Telefon: {{ contact.phone }}</p>
<p v-if="displayOptions.includes('street')">Straße: {{ contact.street }}</p>
<p v-if="displayOptions.includes('zipcode')">Postleitzahl: {{ contact.zipcode }}</p>
@@ -13,7 +13,7 @@
</div>
<span v-else-if="config.style === 'float' && contacts && contacts.length && contacts.length > 0">
<span v-for="contact in contacts" :key="contact.id" class="bottom-margin">
{{ contact.name }}<span v-if="contact.expiryDate" class="expiry-date"> (bis {{ formatDate(contact.expiryDate) }})</span>
{{ contact.name }}
<span v-if="displayOptions.includes('phone')">, Telefon: {{ contact.phone }}</span>
<span v-if="displayOptions.includes('street')">, Straße: {{ contact.street }}</span>
<span v-if="displayOptions.includes('zipcode')">, Postleitzahl: {{ contact.zipcode }}</span>
@@ -58,17 +58,6 @@ export default {
this.loading = false;
}
},
methods: {
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
}
};
</script>
@@ -79,9 +68,4 @@ export default {
.bottom-margin {
margin-bottom: 1rem;
}
.expiry-date {
font-size: 0.9em;
color: #666;
font-style: italic;
}
</style>

View File

@@ -312,15 +312,6 @@ export default {
this.assignedImage = null;
this.imageFilename = '';
},
focusFirstField() {
// Fokussiert das erste Eingabefeld (Name)
this.$nextTick(() => {
const nameInput = document.getElementById('name');
if (nameInput) {
nameInput.focus();
}
});
}
}
};
</script>

View File

@@ -11,7 +11,7 @@
formatTime(event.endTime) }}</span> Uhr</div>
<div v-if="shouldDisplay('place')">{{ event.eventPlace?.name }}</div>
<div v-if="shouldDisplay('description')" class="description">{{ event.description }}</div>
<div v-if="shouldDisplay('contactPerson')">{{event.contactPersons.map(cp => formatContactPerson(cp)).join(', ')}}
<div v-if="shouldDisplay('contactPerson')">{{event.contactPersons.map(cp => cp.name).join(', ')}}
</div>
<div v-if="shouldDisplay('institution')">{{ event.institution?.name }}</div>
<div v-if="shouldDisplay('type')">{{ event.eventType?.caption }}</div>
@@ -28,7 +28,7 @@
formatTime(events[0].endTime) }}</span> Uhr</div>
<div v-if="shouldDisplay('place')">{{ events[0].eventPlace?.name }}</div>
<div v-if="shouldDisplay('description')" class="description">{{ events[0].description }}</div>
<div v-if="shouldDisplay('contactPerson')">{{events[0].contactPersons.map(cp => formatContactPerson(cp)).join(', ')}}
<div v-if="shouldDisplay('contactPerson')">{{events[0].contactPersons.map(cp => cp.name).join(', ')}}
</div>
<div v-if="shouldDisplay('institution')">{{ events[0].institution?.name }}</div>
<div v-if="shouldDisplay('type')">{{ events[0].eventType?.caption }}</div>
@@ -102,18 +102,6 @@ export default {
const path = '/images/uploads/' + response.data.filename;
console.log(path);
return path;
},
formatContactPerson(contactPerson) {
if (!contactPerson.expiryDate) {
return contactPerson.name;
}
const date = new Date(contactPerson.expiryDate);
const formattedDate = date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
return `${contactPerson.name} (bis ${formattedDate})`;
}
}
};

View File

@@ -11,35 +11,21 @@
<div v-if="worship.neighborInvitation" class="neighborhood-invitation">Einladung zum Gottesdienst im
Nachbarschaftsraum:</div>
<h3>
<span
:class="worship.highlightTime ? 'highlight-time' : ''"
>{{ formatTime(worship.time) }}</span>&nbsp;-&nbsp;
{{
worship.title
? worship.title
: (worship.eventPlace && worship.eventPlace.name
? `Gottesdienst in ${worship.eventPlace.name}`
: 'Gottesdienst')
}}
<span :class="worship.highlightTime ? 'highlight-time' : ''">{{ formatTime(worship.time)
}}</span>&nbsp;-&nbsp;
{{ worship.title ? worship.title : `Gottesdienst in ${worship.eventPlace.name}` }}
</h3>
<div v-if="worship.organizer">Gestaltung: {{ worship.organizer }}</div>
<div v-if="worship.sacristanService" class="internal-information">Küsterdienst: {{ worship.sacristanService }}</div>
<div v-if="worship.collection">Kollekte: {{ worship.collection }}</div>
<div v-if="worship.organPlaying" class="internal-information">Orgelspiel: {{ worship.organPlaying }}</div>
<div v-if="worship.address">{{ worship.address }}</div>
<div
v-if="!worship.address && worship.eventPlace && worship.eventPlace.id"
>
Adresse: {{ worship.eventPlace.name }}, {{ worship.eventPlace.street }}, {{ worship.eventPlace.city }}
<div v-if="!worship.address && worship.eventPlace.id && worship.eventPlace.id">
Adresse: {{ worship.eventPlace.name }}, {{ worship.eventPlace.street }}, {{
worship.eventPlace.city }}
</div>
<div v-if="worship.selfInformation" class="selfinformation">
Bitte informieren Sie sich auch auf den
<a
v-if="worship.eventPlace && worship.eventPlace.website"
:href="worship.eventPlace.website"
target="_blank"
>Internetseiten dieser Gemeinde!</a>
<span v-else>Internetseiten dieser Gemeinde!</span>
<div v-if="worship.selfInformation" class="selfinformation">Bitte informieren Sie sich auch auf den
<a v-if="worship.eventPlace.website" :href="worship.eventPlace.website" target="_blank">Internetseiten dieser Gemeinde!</a><span
v-else>Internetseiten dieser Gemeinde!</span>
</div>
</td>
</tr>

View File

@@ -33,7 +33,6 @@ export default {
zipcode: '',
city: '',
email: '',
expiryDate: null,
positions: []
},
positions: []

View File

@@ -8,29 +8,29 @@
</select>
</div>
<div class="toolbar">
<button @click="toggleHeading(3)" :class="{ 'is-active': editor && editor.isActive('heading', { level: 3 }) }">H3</button>
<button @click="toggleHeading(4)" :class="{ 'is-active': editor && editor.isActive('heading', { level: 4 }) }">H4</button>
<button @click="toggleHeading(5)" :class="{ 'is-active': editor && editor.isActive('heading', { level: 5 }) }">H5</button>
<button @click="toggleHeading(6)" :class="{ 'is-active': editor && editor.isActive('heading', { level: 6 }) }">H6</button>
<button @click="toggleBold()" :class="{ 'is-active': editor && editor.isActive('bold') }" width="24" height="24">
<button @click="toggleHeading(3)">H3</button>
<button @click="toggleHeading(4)">H4</button>
<button @click="toggleHeading(5)">H5</button>
<button @click="toggleHeading(6)">H6</button>
<button @click="toggleBold()" width="24" height="24">
<BoldIcon width="24" height="24" />
</button>
<button @click="toggleItalic()" :class="{ 'is-active': editor && editor.isActive('italic') }">
<button @click="toggleItalic()">
<ItalicIcon width="24" height="24" />
</button>
<button @click="toggleUnderline()" :class="{ 'is-active': editor && editor.isActive('underline') }">
<button @click="toggleUnderline()">
<UnderlineIcon width="24" height="24" />
</button>
<button @click="toggleStrike()" :class="{ 'is-active': editor && editor.isActive('strike') }">
<button @click="toggleStrike()">
<StrikethroughIcon width="24" height="24" />
</button>
<button @click="insertTable()">
<TableIcon width="24" height="24" />
</button>
<button @click="toggleBulletList()" :class="{ 'is-active': editor && editor.isActive('bulletList') }">
<button @click="toggleBulletList()">
<ListIcon width="24" height="24" />
</button>
<button @click="toggleOrderedList()" :class="{ 'is-active': editor && editor.isActive('orderedList') }">
<button @click="toggleOrderedList()">
<NumberedListLeftIcon width="24" height="24" />
</button>
<button @click="openAddImageDialog">
@@ -511,15 +511,4 @@ export default {
.align-top {
vertical-align: top;
}
.toolbar button.is-active,
.table-toolbar button.is-active {
background-color: #333;
color: white;
}
.toolbar button.is-active svg,
.table-toolbar button.is-active svg {
fill: white;
}
</style>

View File

@@ -3,30 +3,12 @@
<h2>Veranstaltungen</h2>
<button @click="createEvent">Neue Veranstaltung</button>
<EventForm v-if="showForm"
ref="eventForm"
:event="selectedEvent"
:institutions="institutions"
:eventPlaces="eventPlaces"
:contactPersons="contactPersons"
@saved="handleEventSaved"
@cancelled="handleEventCancelled" />
<div class="filter-section">
<input
v-model="searchQuery"
type="text"
placeholder="Suche nach Name, Typ, Beschreibung..."
class="search-input"
/>
<label class="checkbox-label">
<input
v-model="showPastEvents"
type="checkbox"
/>
Vergangene Events anzeigen
</label>
</div>
<table>
<thead>
<tr>
@@ -40,7 +22,7 @@
</tr>
</thead>
<tbody>
<tr v-for="event in filteredEvents" :key="event.id">
<tr v-for="event in events" :key="event.id">
<td>{{ event.name }}</td>
<td>{{ getEventTypeCaption(event.eventTypeId) }}</td>
<td>{{ event.date }}</td>
@@ -73,52 +55,8 @@ export default {
eventTypes: [],
selectedEvent: null,
showForm: false,
searchQuery: '',
showPastEvents: false,
};
},
computed: {
filteredEvents() {
let filtered = this.events;
// Filter vergangene Events aus
if (!this.showPastEvents) {
const today = new Date();
today.setHours(0, 0, 0, 0);
filtered = filtered.filter(event => {
// Events mit Wochentag (ohne festes Datum) immer anzeigen
if (event.dayOfWeek !== null && event.dayOfWeek !== undefined && !event.date) {
return true;
}
// Events mit Datum: nur zukünftige oder heutige anzeigen
if (event.date) {
const eventDate = new Date(event.date);
eventDate.setHours(0, 0, 0, 0);
return eventDate >= today;
}
// Events ohne Datum und ohne Wochentag anzeigen
return true;
});
}
// Suchfilter anwenden
if (this.searchQuery.trim()) {
const query = this.searchQuery.toLowerCase();
filtered = filtered.filter(event => {
const name = event.name ? event.name.toLowerCase() : '';
const description = event.description ? event.description.toLowerCase() : '';
const eventType = this.getEventTypeCaption(event.eventTypeId).toLowerCase();
return name.includes(query) ||
description.includes(query) ||
eventType.includes(query);
});
}
return filtered;
}
},
async created() {
await this.fetchData();
},
@@ -146,24 +84,10 @@ export default {
createEvent() {
this.selectedEvent = {};
this.showForm = true;
this.scrollToFormAndFocus();
},
editEvent(event) {
this.selectedEvent = { ...event };
this.showForm = true;
this.scrollToFormAndFocus();
},
scrollToFormAndFocus() {
// Wartet auf das Rendern des Formulars und scrollt dann nach oben
this.$nextTick(() => {
// Nach oben scrollen
window.scrollTo({ top: 0, behavior: 'smooth' });
// Das erste Feld fokussieren
if (this.$refs.eventForm) {
this.$refs.eventForm.focusFirstField();
}
});
},
async deleteEvent(id) {
try {
@@ -186,7 +110,7 @@ export default {
},
getWeekdayName(dayOfWeek) {
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return weekdays[dayOfWeek];
return weekdays[dayOfWeek - 1];
},
}
};
@@ -198,40 +122,6 @@ export default {
margin: 0 auto;
}
.filter-section {
margin: 20px 0;
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 250px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #4CAF50;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
table {
width: 100%;
border-collapse: collapse;

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,14 @@
<div class="privacy-policy">
<h1>Datenschutzerklärung der Miriamgemeinde Frankfurt am Main</h1>
<p>
Die Miriamgemeinde Frankfurt am Main nimmt den Schutz Ihrer persönlichen Daten sehr ernst und behandelt Ihre personenbezogenen Daten vertraulich und entsprechend dem Datenschutzgesetz der Evangelischen Kirche in Deutschland (DSG-EKD) sowie dieser Datenschutzerklärung. Die Sicherheit Ihrer Daten steht für uns an erster Stelle.
Die Miriamgemeinde Frankfurt am Main nimmt den Schutz Ihrer persönlichen Daten sehr ernst und behandelt Ihre personenbezogenen Daten vertraulich und entsprechend der kirchlichen Datenschutzgesetze sowie dieser Datenschutzerklärung. Die Sicherheit Ihrer Daten steht für uns an erster Stelle.
</p>
<h2>Anbieter:</h2>
<p>
Miriamgemeinde Frankfurt am Main, Gemeindebüro Bonames<br />
Kirchhofsweg 5, 60437 Frankfurt, Tel.: 50 14 17, Fax: 50 93 0148,<br />
Email: <a href="mailto:Ev.Kirche-Bonames@t-online.de">Ev.Kirche-Bonames@t-online.de</a><br />
Inhaltlich Verantwortlicher gemäß § 6 DDG: Torsten Schulz
Inhaltlich Verantwortlicher gemäß § 6 MDStV: Torsten Schulz
</p>
<p>
Die Nutzung der Webseite der Miriamgemeinde Frankfurt am Main ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf unseren Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder E-Mail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Ihre ausdrückliche Zustimmung nicht an Dritte weitergegeben. Die nachfolgende Erklärung gibt Ihnen einen Überblick darüber, wie dieser Schutz gewährleistet werden soll und welche Art von Daten zu welchem Zweck von Ihnen erhoben werden.
@@ -63,23 +63,11 @@
</p>
<h3>Ihr Recht auf Auskunft, Löschung, Sperrung</h3>
<p>
Sie haben als Nutzer nach dem Datenschutzgesetz der Evangelischen Kirche in Deutschland (DSG-EKD) folgende Rechte:
Sie haben als Nutzer das Recht, Auskunft darüber zu verlangen, welche Daten über Sie bei uns gespeichert sind und zu welchem Zweck diese Speicherung erfolgt. Darüber hinaus können Sie unrichtige Daten berichtigen oder solche Daten löschen lassen, deren Speicherung unzulässig oder nicht mehr erforderlich ist. Sie haben die Rechte auf Datenübertragbarkeit, Einschränkung der Verarbeitung und Widerspruch. Außerdem haben Sie das Recht, sich bei der Aufsichtsbehörde über die stattfindende Datenverarbeitung zu beschweren. Zuständige Aufsichtsbehörde ist Der Beauftragte für den Datenschutz der EKD Adresse siehe unten.
</p>
<ul>
<li><strong>Recht auf Auskunft:</strong> Sie haben das Recht, Auskunft darüber zu verlangen, welche Daten über Sie bei uns gespeichert sind und zu welchem Zweck diese Speicherung erfolgt.</li>
<li><strong>Recht auf Berichtigung:</strong> Sie können unrichtige oder unvollständige Daten berichtigen lassen.</li>
<li><strong>Recht auf Löschung:</strong> Sie können die Löschung Ihrer Daten verlangen, wenn diese nicht mehr erforderlich sind oder wenn die Verarbeitung rechtswidrig war.</li>
<li><strong>Recht auf Einschränkung der Verarbeitung:</strong> Sie können eine Einschränkung der Verarbeitung Ihrer Daten verlangen.</li>
<li><strong>Recht auf Widerspruch:</strong> Sie haben das Recht, der Verarbeitung Ihrer personenbezogenen Daten zu widersprechen, wenn diese auf Grund von berechtigtem Interesse erfolgt.</li>
<li><strong>Recht auf Datenübertragbarkeit:</strong> Sie können verlangen, dass die Daten, die Sie bereitgestellt haben, in einem strukturierten, gängigen und maschinenlesbaren Format ausgehändigt werden.</li>
<li><strong>Recht auf Widerruf:</strong> Sie können jederzeit die Einwilligung zur Datenverarbeitung widerrufen, ohne dass die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten Verarbeitung berührt wird.</li>
</ul>
<p>
Sie erhalten jederzeit ohne Angabe von Gründen kostenfrei Auskunft über Ihre bei uns gespeicherten Daten. Sie können jederzeit Ihre bei uns erhobenen Daten sperren, berichtigen oder löschen lassen. Auch können Sie jederzeit die uns erteilte Einwilligung zur Datenerhebung und Verwendung ohne Angaben von Gründen widerrufen. Wenden Sie sich hierzu bitte an die auf dieser Seite angegebene Kontaktadresse des Datenschutzbeauftragten. Wir stehen Ihnen jederzeit gern für weitergehende Fragen zu unserem Hinweisen zum Datenschutz und zur Verarbeitung Ihrer persönlichen Daten zur Verfügung.
</p>
<p>
Außerdem haben Sie das Recht, sich bei der Aufsichtsbehörde über die stattfindende Datenverarbeitung zu beschweren. Zuständige Aufsichtsbehörde ist der Beauftragte für den Datenschutz der EKD Adresse siehe unten.
</p>
<h3>Der Datenschutzbeauftragte für den Datenschutz der Evangelischen Kirchen in Deutschland</h3>
<p>
Die Aufsicht über die Einhaltung der Vorschriften zum Datenschutz obliegt im kirchlichen Bereich dem Beauftragten für den Datenschutz der EKD. Für den Bereich der Evangelischen Kirche in Hessen und Nassau (EKHN) ist zuständig die Außenstelle Dortmund für die Datenschutzregion Mitte-West
@@ -108,7 +96,7 @@
<p>
Die Nutzer werden gebeten, sich regelmäßig über den Inhalt der Datenschutzerklärung zu informieren.
</p>
<p>Stand: Januar 2025</p>
<p>Stand: 24. Mai 2018</p>
</div>
</template>

View File

@@ -24,7 +24,7 @@
</p>
</section>
<section>
<h3>Inhaltlich Verantwortlicher gemäß § 6 DDG:</h3>
<h3>Inhaltlich Verantwortlicher gemäß § 6 MDStV:</h3>
<p>Torsten Schulz</p>
</section>
<section>

View File

@@ -5,18 +5,13 @@ import store from './store';
import axios from './axios';
import './assets/css/editor.css';
// Menü-Daten über das konfigurierte Axios-Backend laden
async function fetchMenuData() {
const response = await axios.get('/menu-data');
return response.data;
const response = await fetch(process.env.VUE_APP_BACKEND_URL + '/menu-data');
return await response.json();
}
fetchMenuData()
.then(menuData => {
fetchMenuData().then(menuData => {
store.commit('setMenuData', menuData);
})
.catch(err => {
console.error('Fehler beim Laden der Menü-Daten:', err);
});
const app = createApp(AppComponent);

View File

@@ -45,13 +45,8 @@ const router = createRouter({
routes: []
});
// Verhindert endlose Wiederholungen von fehlgeschlagenen Menü-Ladeversuchen
let menuDataInitialized = false;
router.beforeEach(async (to, from, next) => {
if (!menuDataInitialized) {
menuDataInitialized = true;
if (!store.state.menuData.length) {
await store.dispatch('loadMenuData');
const routes = generateRoutesFromMenu(store.state.menuData);
routes.forEach(route => router.addRoute(route));
@@ -70,12 +65,8 @@ router.beforeEach(async (to, from, next) => {
next({ ...to, replace: true });
} else {
// Sicherstellen, dass die Login-Route immer verfügbar ist
if (!router.hasRoute('auth-login')) {
addAuthLoginRoute();
}
if (to.matched.some(record => record.meta.requiresAuth) && !store.getters.isLoggedIn) {
next('/auth/login');
next('/login');
} else {
next();
}

View File

@@ -1,5 +1,5 @@
import { createStore } from 'vuex';
import axios from '../axios';
import axios from 'axios';
import router from '../router';
let user = [];
@@ -35,6 +35,7 @@ export default createStore({
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('user');
localStorage.removeItem('token');
router.push('/auth/login');
},
setMenuData(state, menuData) {
state.menuData = menuData;
@@ -100,10 +101,6 @@ export default createStore({
console.error('Fehler beim Logout:', error);
} finally {
commit('logout');
// Navigation nach Logout mit replace, damit die Login-Seite direkt erreichbar ist
if (router.currentRoute.value.path !== '/auth/login') {
router.replace('/auth/login');
}
}
}
},

71
utils/ErrorHandler.js Normal file
View File

@@ -0,0 +1,71 @@
class ErrorHandler {
/**
* Error in HTTP Response umwandeln
*/
handleError(error, res) {
console.error('Error:', error);
// Validation Errors
if (error.message.startsWith('VALIDATION_ERROR:')) {
const message = error.message.replace('VALIDATION_ERROR: ', '');
return res.status(400).json({
success: false,
message: message,
type: 'VALIDATION_ERROR'
});
}
// Business Logic Errors
switch (error.message) {
case 'USER_NOT_FOUND':
return res.status(404).json({
success: false,
message: 'Benutzer nicht gefunden',
type: 'NOT_FOUND'
});
case 'INVALID_CURRENT_PASSWORD':
return res.status(400).json({
success: false,
message: 'Aktuelles Passwort ist falsch',
type: 'INVALID_PASSWORD'
});
case 'EMAIL_ALREADY_EXISTS':
return res.status(409).json({
success: false,
message: 'E-Mail-Adresse bereits vorhanden',
type: 'DUPLICATE_EMAIL'
});
default:
return res.status(500).json({
success: false,
message: 'Ein interner Fehler ist aufgetreten',
type: 'INTERNAL_ERROR'
});
}
}
/**
* Success Response erstellen
*/
successResponse(res, data, message = 'Erfolgreich', statusCode = 200) {
return res.status(statusCode).json({
success: true,
message: message,
data: data
});
}
/**
* Async Error Wrapper für Controller
*/
asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
}
module.exports = new ErrorHandler();

109
validators/UserValidator.js Normal file
View File

@@ -0,0 +1,109 @@
class UserValidator {
/**
* User-Erstellungsdaten validieren
*/
validateCreateUser(userData) {
const errors = [];
if (!userData.name || userData.name.trim().length < 2) {
errors.push('Name muss mindestens 2 Zeichen lang sein');
}
if (!userData.email || !this.isValidEmail(userData.email)) {
errors.push('Gültige E-Mail-Adresse ist erforderlich');
}
if (!userData.password || userData.password.length < 6) {
errors.push('Passwort muss mindestens 6 Zeichen lang sein');
}
if (errors.length > 0) {
throw new Error(`VALIDATION_ERROR: ${errors.join(', ')}`);
}
return true;
}
/**
* User-Update-Daten validieren
*/
validateUpdateUser(updateData) {
const errors = [];
if (updateData.name !== undefined && (updateData.name.trim().length < 2)) {
errors.push('Name muss mindestens 2 Zeichen lang sein');
}
if (updateData.email !== undefined && !this.isValidEmail(updateData.email)) {
errors.push('Gültige E-Mail-Adresse ist erforderlich');
}
if (updateData.active !== undefined && typeof updateData.active !== 'boolean') {
errors.push('Active muss ein Boolean-Wert sein');
}
// Warnung für sensible Felder
if (updateData.password !== undefined) {
throw new Error('VALIDATION_ERROR: Passwort kann nicht über diese Route geändert werden');
}
if (updateData.id !== undefined) {
throw new Error('VALIDATION_ERROR: ID kann nicht geändert werden');
}
if (updateData.created_at !== undefined) {
throw new Error('VALIDATION_ERROR: Erstellungsdatum kann nicht geändert werden');
}
if (errors.length > 0) {
throw new Error(`VALIDATION_ERROR: ${errors.join(', ')}`);
}
return true;
}
/**
* Passwort-Änderung validieren
*/
validatePasswordChange(currentPassword, newPassword) {
const errors = [];
if (!currentPassword) {
errors.push('Aktuelles Passwort ist erforderlich');
}
if (!newPassword || newPassword.length < 6) {
errors.push('Neues Passwort muss mindestens 6 Zeichen lang sein');
}
if (currentPassword === newPassword) {
errors.push('Neues Passwort muss sich vom aktuellen unterscheiden');
}
if (errors.length > 0) {
throw new Error(`VALIDATION_ERROR: ${errors.join(', ')}`);
}
return true;
}
/**
* E-Mail-Format validieren
*/
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* ID validieren
*/
validateId(id) {
if (!id || isNaN(parseInt(id))) {
throw new Error('VALIDATION_ERROR: Ungültige ID');
}
return true;
}
}
module.exports = new UserValidator();

View File

@@ -2,31 +2,12 @@ const { defineConfig } = require('@vue/cli-service');
const webpack = require('webpack');
module.exports = defineConfig({
transpileDependencies: [],
transpileDependencies: true,
devServer: {
host: 'localhost',
// Port kann über VUE_APP_FRONTEND_PORT oder FRONTEND_PORT in .env gesetzt werden
port: parseInt(process.env.VUE_APP_FRONTEND_PORT || process.env.FRONTEND_PORT || '8080', 10),
// Proxy für API-Requests zum Backend-Server
// Backend sollte auf einem anderen Port laufen (z.B. 3010)
proxy: {
'/api': {
target: process.env.VUE_APP_BACKEND_PROXY || 'http://torstens:3010',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// Erhöhe Header-Limits für Proxy
headers: {
'Connection': 'keep-alive'
},
// Erhöhe Timeout für große Requests
timeout: 60000
}
}
port: 8080
},
configureWebpack: {
output: { clean: true },
cache: false,
resolve: {
fallback: {
"path": require.resolve("path-browserify"),
@@ -42,11 +23,4 @@ module.exports = defineConfig({
})
],
},
chainWebpack: config => {
const rules = ['vue','js','ts','tsx','css','scss','sass','less','stylus'];
rules.forEach(rule => {
try { config.module.rule(rule).uses.delete('cache-loader'); } catch (e) {}
try { config.module.rule(rule).uses.delete('thread-loader'); } catch (e) {}
});
}
});