diff --git a/controllers/worshipController.js b/controllers/worshipController.js
index ae3eb01..58ebff3 100644
--- a/controllers/worshipController.js
+++ b/controllers/worshipController.js
@@ -1,4 +1,4 @@
-const { Worship, EventPlace, LiturgicalDay, Sequelize, sequelize } = require('../models');
+const { Worship, EventPlace, LiturgicalDay, WorshipLeader, Sequelize, sequelize } = require('../models');
const { Op, fn, literal } = require('sequelize');
const jwt = require('jsonwebtoken');
const { isTokenBlacklisted, addTokenToBlacklist } = require('../utils/blacklist');
@@ -6,6 +6,7 @@ const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
const mammoth = require('mammoth');
const pdfParse = require('pdf-parse');
+const { parse: parseCsv } = require('csv-parse/sync');
const { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, WidthType, AlignmentType, VerticalAlign, ShadingType, VerticalMerge, VerticalMergeType, FontFamily, HeadingLevel, PageMargin, SectionType, BorderStyle, HeightRule } = require('docx');
function isAuthorized(req) {
@@ -235,6 +236,119 @@ function parseTime(timeString) {
return null;
}
+function parseGermanDateString(dateString) {
+ const value = String(dateString || '').trim();
+ const match = value.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
+ if (!match) return null;
+ const day = parseInt(match[1], 10);
+ const month = parseInt(match[2], 10) - 1;
+ const year = parseInt(match[3], 10);
+ return new Date(Date.UTC(year, month, day));
+}
+
+function normalizeText(value) {
+ return String(value || '')
+ .replace(/\u00a0/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function buildLeaderMaps(leaders) {
+ const codeToName = new Map();
+ const normalizedToName = new Map();
+ for (const leader of leaders || []) {
+ if (!leader?.active) continue;
+ const code = normalizeText(leader.code);
+ const name = normalizeText(leader.name);
+ if (code) {
+ codeToName.set(code, name);
+ normalizedToName.set(code.toLowerCase(), name);
+ }
+ const aliases = String(leader.aliases || '')
+ .split(',')
+ .map((x) => normalizeText(x))
+ .filter(Boolean);
+ for (const alias of aliases) {
+ normalizedToName.set(alias.toLowerCase(), name);
+ }
+ }
+ return { codeToName, normalizedToName };
+}
+
+function resolveEventPlaceIdFromHeader(eventPlaces, headerCell) {
+ const raw = normalizeText(headerCell);
+ if (!raw) return null;
+ const nameOnly = normalizeText(raw.split('(')[0]);
+ const normalized = nameOnly.toLowerCase();
+
+ // Prefer exact name match.
+ const exact = (eventPlaces || []).find((p) => normalizeText(p.name).toLowerCase() === normalized);
+ if (exact) return exact.id;
+
+ // Fallback: contains.
+ const contains = (eventPlaces || []).find((p) => normalizeText(p.name).toLowerCase().includes(normalized));
+ if (contains) return contains.id;
+
+ // Hardcoded fallbacks for known CSV headers.
+ if (/am b[üu]gel/i.test(raw)) return 12;
+ if (/bonames/i.test(raw)) return 7;
+ if (/kalbach/i.test(raw)) return 2;
+ if (/nieder-eschbach/i.test(raw)) return 14;
+ if (/harheim/i.test(raw)) return 15;
+ if (/nieder-erlenbach/i.test(raw)) return 13;
+ return null;
+}
+
+function splitNbrCellToSegments(cellText) {
+ const text = normalizeText(cellText);
+ if (!text) return [];
+ // Often multiple items are separated by newlines or commas; keep it conservative.
+ return text
+ .split(/\n+|,\s*(?=(?:[A-ZÄÖÜa-zäöü]{1,3}\.)?\s*\d{1,2}[:.]\d{2}|Sa\.)/g)
+ .map((s) => normalizeText(s))
+ .filter(Boolean);
+}
+
+function parseNbrSegment(segment, baseDateUtc, leaderNormalizedMap) {
+ const raw = normalizeText(segment);
+ if (!raw) return null;
+
+ let dateUtc = baseDateUtc;
+ let text = raw;
+ if (/^(sa|samstag)\.?/i.test(text)) {
+ const d = new Date(dateUtc.getTime());
+ d.setUTCDate(d.getUTCDate() - 1);
+ dateUtc = d;
+ text = normalizeText(text.replace(/^(sa|samstag)\.?\s*/i, ''));
+ }
+
+ // Time: allow 10.30, 10:30, 10 h, 10h, 10.30 Uhr, etc.
+ let time = null;
+ const timeMatch = text.match(/(\d{1,2})\s*(?:[:.]\s*(\d{2})|h)\b/i);
+ if (timeMatch) {
+ const hours = parseInt(timeMatch[1], 10);
+ const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
+ time = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
+ text = normalizeText(text.replace(timeMatch[0], ''));
+ }
+
+ // Officiant: pick the last token that matches a configured leader code/alias.
+ let officiant = '';
+ const tokens = text.split(' ').map((t) => t.trim()).filter(Boolean);
+ for (let i = tokens.length - 1; i >= 0; i--) {
+ const token = tokens[i].replace(/[()]/g, '');
+ const resolved = leaderNormalizedMap.get(token.toLowerCase());
+ if (resolved) {
+ officiant = resolved;
+ tokens.splice(i, 1);
+ break;
+ }
+ }
+ const title = tokens.join(' ').trim() || 'Gottesdienst';
+
+ return { dateUtc, time, title, officiant };
+}
+
// Hilfsfunktion zum Parsen eines Gottesdienstes aus der zweiten Spalte
function parseWorshipFromCell(cellText, date, dayName) {
// Zuerst in Zeilen aufteilen (falls Zeilenumbrüche vorhanden)
@@ -1213,6 +1327,182 @@ exports.importWorships = async (req, res) => {
}
};
+// Import-Funktion für Gottesdienste aus dem neuen NBR-CSV Format (2026+)
+exports.importWorshipsNbrCsv = async (req, res) => {
+ try {
+ if (!req.file) {
+ return res.status(400).json({ message: 'Keine Datei hochgeladen.' });
+ }
+
+ const fileName = req.file.originalname.toLowerCase();
+ if (!fileName.endsWith('.csv')) {
+ return res.status(400).json({ message: 'Nur .csv Dateien sind erlaubt.' });
+ }
+
+ const csvText = req.file.buffer.toString('utf8');
+ const records = parseCsv(csvText, {
+ relax_quotes: true,
+ relax_column_count: true,
+ skip_empty_lines: false,
+ });
+
+ if (!Array.isArray(records) || records.length < 3) {
+ return res.status(400).json({ message: 'CSV hat zu wenig Zeilen.' });
+ }
+
+ const header = records[0] || [];
+ const datumCol = 1;
+ const groups = [];
+ for (let idx = 2; idx < header.length; idx += 3) {
+ const placeHeader = header[idx];
+ if (!normalizeText(placeHeader)) continue;
+ groups.push({
+ idx,
+ placeHeader,
+ musicIdx: idx + 1,
+ serviceIdx: idx + 2,
+ });
+ }
+
+ const eventPlaces = await EventPlace.findAll();
+ const leaders = await WorshipLeader.findAll();
+ const { normalizedToName } = buildLeaderMaps(leaders);
+
+ // existing worships for change detection
+ const existingWorships = await Worship.findAll({
+ where: {
+ date: {
+ [Op.gte]: literal('DATE_SUB(CURDATE(), INTERVAL 1 DAY)'),
+ },
+ },
+ });
+
+ const today = new Date();
+ today.setUTCHours(0, 0, 0, 0);
+
+ const imported = [];
+ const errors = [];
+
+ const findExisting = (dateUtc, time, eventPlaceId) => {
+ const y = dateUtc.getUTCFullYear();
+ const m = String(dateUtc.getUTCMonth() + 1).padStart(2, '0');
+ const d = String(dateUtc.getUTCDate()).padStart(2, '0');
+ const dateKey = `${y}-${m}-${d}`;
+ const timeKey = time ? String(time).substring(0, 5) : '';
+ return existingWorships.find((w) => {
+ const wDate = w.date instanceof Date ? w.date : new Date(w.date);
+ const wy = wDate.getUTCFullYear();
+ const wm = String(wDate.getUTCMonth() + 1).padStart(2, '0');
+ const wd = String(wDate.getUTCDate()).padStart(2, '0');
+ const wKey = `${wy}-${wm}-${wd}`;
+ const wTime = w.time ? String(w.time).substring(0, 5) : '';
+ return wKey === dateKey && wTime === timeKey && String(w.eventPlaceId || '') === String(eventPlaceId || '');
+ });
+ };
+
+ const hasChanges = (newW, existing) => {
+ if (!existing) return true;
+ const fields = ['title', 'organizer', 'sacristanService', 'collection', 'organPlaying', 'eventPlaceId'];
+ for (const field of fields) {
+ const a = normalizeText(newW[field]);
+ const b = normalizeText(existing[field]);
+ if (a !== b) return true;
+ }
+ return false;
+ };
+
+ for (let r = 1; r < records.length; r++) {
+ const row = records[r] || [];
+ const dateUtc = parseGermanDateString(row[datumCol]);
+ if (!dateUtc) continue;
+
+ const baseDateUtc = dateUtc;
+ const dayName = normalizeText(row[0]).replace(/\s*\(\s*/g, ' (').trim();
+
+ // Skip past days for preview (same behavior as docx import)
+ const compare = new Date(baseDateUtc.getTime());
+ compare.setUTCHours(0, 0, 0, 0);
+ if (compare < today) continue;
+
+ for (const group of groups) {
+ const placeHeader = group.placeHeader;
+ const eventPlaceId = resolveEventPlaceIdFromHeader(eventPlaces, placeHeader);
+
+ const worshipCell = row[group.idx];
+ const music = row[group.musicIdx];
+ const service = row[group.serviceIdx];
+ const segments = splitNbrCellToSegments(worshipCell);
+ if (segments.length === 0) continue;
+
+ for (const seg of segments) {
+ const parsed = parseNbrSegment(seg, baseDateUtc, normalizedToName);
+ if (!parsed || !parsed.time) {
+ continue;
+ }
+ const worshipData = {
+ date: parsed.dateUtc,
+ dayName,
+ time: parsed.time,
+ title: parsed.title,
+ // "Gottesdienst haltend" ist bei uns der "Gestalter" (organizer).
+ organizer: parsed.officiant || '',
+ collection: '',
+ sacristanService: normalizeText(service),
+ organPlaying: normalizeText(music),
+ eventPlaceId,
+ };
+
+ const existing = findExisting(worshipData.date, worshipData.time, worshipData.eventPlaceId);
+ if (!hasChanges(worshipData, existing)) {
+ continue;
+ }
+
+ if (existing) {
+ worshipData._isUpdate = true;
+ worshipData._existingId = existing.id;
+ worshipData._oldValues = {
+ title: existing.title,
+ organizer: existing.organizer,
+ sacristanService: existing.sacristanService,
+ collection: existing.collection,
+ organPlaying: existing.organPlaying,
+ eventPlaceId: existing.eventPlaceId,
+ };
+ worshipData._changedFields = Object.keys(worshipData._oldValues).filter((f) => normalizeText(worshipData[f]) !== normalizeText(existing[f]));
+ } else {
+ worshipData._isNew = true;
+ }
+
+ imported.push(worshipData);
+ }
+ }
+ }
+
+ const worshipsForFrontend = imported.map((w) => {
+ const copy = { ...w };
+ if (copy.date instanceof Date) {
+ const year = copy.date.getUTCFullYear();
+ const month = String(copy.date.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(copy.date.getUTCDate()).padStart(2, '0');
+ copy.date = `${year}-${month}-${day}`;
+ }
+ if (copy.time && typeof copy.time === 'string' && copy.time.length > 5) {
+ copy.time = copy.time.substring(0, 5);
+ }
+ return copy;
+ });
+
+ res.status(200).json({
+ message: `CSV geparst. ${worshipsForFrontend.length} Einträge mit Änderungen gefunden.`,
+ worships: worshipsForFrontend,
+ errors: errors.length ? errors : undefined,
+ });
+ } catch (error) {
+ console.error('Fehler beim Importieren (NBR CSV):', error);
+ res.status(500).json({ message: 'Fehler beim Importieren der CSV', error: error.message });
+ }
+};
+
// Funktion zum Speichern der bearbeiteten Gottesdienste
exports.saveImportedWorships = async (req, res) => {
try {
diff --git a/controllers/worshipLeaderController.js b/controllers/worshipLeaderController.js
new file mode 100644
index 0000000..281a87f
--- /dev/null
+++ b/controllers/worshipLeaderController.js
@@ -0,0 +1,88 @@
+const { WorshipLeader } = require('../models');
+const { Op } = require('sequelize');
+
+function normalizeLeaderPayload(body) {
+ const code = String(body.code || '').trim();
+ const name = String(body.name || '').trim();
+ const aliases = String(body.aliases || '').trim();
+ const active = body.active === undefined ? true : !!body.active;
+ return { code, name, aliases, active };
+}
+
+exports.getAllWorshipLeaders = async (req, res) => {
+ try {
+ const includeInactive = String(req.query?.includeInactive || '').toLowerCase();
+ const wantsInactive = includeInactive === '1' || includeInactive === 'true' || includeInactive === 'yes';
+
+ const where = wantsInactive ? undefined : { active: true };
+ const leaders = await WorshipLeader.findAll({
+ where,
+ order: [['code', 'ASC']],
+ });
+ res.json(leaders);
+ } catch (error) {
+ console.error('getAllWorshipLeaders:', error);
+ res.status(500).json({ error: 'Failed to fetch worship leaders' });
+ }
+};
+
+exports.createWorshipLeader = async (req, res) => {
+ try {
+ const payload = normalizeLeaderPayload(req.body || {});
+ if (!payload.code || !payload.name) {
+ return res.status(400).json({ message: 'code und name sind Pflichtfelder.' });
+ }
+ const existing = await WorshipLeader.findOne({ where: { code: payload.code } });
+ if (existing) {
+ return res.status(409).json({ message: `Kürzel "${payload.code}" existiert bereits.` });
+ }
+ const created = await WorshipLeader.create(payload);
+ res.status(201).json(created);
+ } catch (error) {
+ console.error('createWorshipLeader:', error);
+ res.status(500).json({ error: 'Failed to create worship leader' });
+ }
+};
+
+exports.updateWorshipLeader = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const leader = await WorshipLeader.findByPk(id);
+ if (!leader) {
+ return res.status(404).json({ message: 'Worship leader not found' });
+ }
+
+ const payload = normalizeLeaderPayload(req.body || {});
+ if (!payload.code || !payload.name) {
+ return res.status(400).json({ message: 'code und name sind Pflichtfelder.' });
+ }
+
+ const codeClash = await WorshipLeader.findOne({
+ where: { code: payload.code, id: { [Op.ne]: id } },
+ });
+ if (codeClash) {
+ return res.status(409).json({ message: `Kürzel "${payload.code}" existiert bereits.` });
+ }
+
+ await leader.update(payload);
+ res.json(leader);
+ } catch (error) {
+ console.error('updateWorshipLeader:', error);
+ res.status(500).json({ error: 'Failed to update worship leader' });
+ }
+};
+
+exports.deleteWorshipLeader = async (req, res) => {
+ try {
+ const { id } = req.params;
+ const deleted = await WorshipLeader.destroy({ where: { id } });
+ if (!deleted) {
+ return res.status(404).json({ message: 'Worship leader not found' });
+ }
+ res.status(204).json();
+ } catch (error) {
+ console.error('deleteWorshipLeader:', error);
+ res.status(500).json({ error: 'Failed to delete worship leader' });
+ }
+};
+
diff --git a/migrations/20260429150000-create-worship-leaders.js b/migrations/20260429150000-create-worship-leaders.js
new file mode 100644
index 0000000..dc5972c
--- /dev/null
+++ b/migrations/20260429150000-create-worship-leaders.js
@@ -0,0 +1,47 @@
+'use strict';
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.createTable('worship_leaders', {
+ id: {
+ allowNull: false,
+ autoIncrement: true,
+ primaryKey: true,
+ type: Sequelize.INTEGER,
+ },
+ code: {
+ type: Sequelize.STRING(32),
+ allowNull: false,
+ unique: true,
+ },
+ name: {
+ type: Sequelize.STRING(255),
+ allowNull: false,
+ },
+ aliases: {
+ type: Sequelize.STRING(512),
+ allowNull: false,
+ defaultValue: '',
+ },
+ active: {
+ type: Sequelize.BOOLEAN,
+ allowNull: false,
+ defaultValue: true,
+ },
+ createdAt: {
+ allowNull: false,
+ type: Sequelize.DATE,
+ },
+ updatedAt: {
+ allowNull: false,
+ type: Sequelize.DATE,
+ },
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.dropTable('worship_leaders');
+ },
+};
+
diff --git a/models/WorshipLeader.js b/models/WorshipLeader.js
new file mode 100644
index 0000000..f9f8bcf
--- /dev/null
+++ b/models/WorshipLeader.js
@@ -0,0 +1,32 @@
+const { DataTypes } = require('sequelize');
+
+module.exports = (sequelize) => {
+ const WorshipLeader = sequelize.define('WorshipLeader', {
+ code: {
+ type: DataTypes.STRING(32),
+ allowNull: false,
+ unique: true,
+ },
+ name: {
+ type: DataTypes.STRING(255),
+ allowNull: false,
+ },
+ aliases: {
+ // Comma-separated list of alternative codes (kept simple to avoid join tables).
+ type: DataTypes.STRING(512),
+ allowNull: true,
+ defaultValue: '',
+ },
+ active: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: true,
+ },
+ }, {
+ tableName: 'worship_leaders',
+ timestamps: true,
+ });
+
+ return WorshipLeader;
+};
+
diff --git a/package.json b/package.json
index 16b5b78..c715a7c 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"mysql2": "^3.10.1",
"nodemailer": "^7.0.6",
"pdf-parse": "^1.1.1",
+ "csv-parse": "^5.6.0",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"uuid": "^10.0.0",
diff --git a/routes/worshipLeaders.js b/routes/worshipLeaders.js
new file mode 100644
index 0000000..35f394e
--- /dev/null
+++ b/routes/worshipLeaders.js
@@ -0,0 +1,17 @@
+const express = require('express');
+const router = express.Router();
+const authMiddleware = require('../middleware/authMiddleware');
+const {
+ getAllWorshipLeaders,
+ createWorshipLeader,
+ updateWorshipLeader,
+ deleteWorshipLeader,
+} = require('../controllers/worshipLeaderController');
+
+router.get('/', authMiddleware, getAllWorshipLeaders);
+router.post('/', authMiddleware, createWorshipLeader);
+router.put('/:id', authMiddleware, updateWorshipLeader);
+router.delete('/:id', authMiddleware, deleteWorshipLeader);
+
+module.exports = router;
+
diff --git a/routes/worships.js b/routes/worships.js
index 2c3b787..da78f49 100644
--- a/routes/worships.js
+++ b/routes/worships.js
@@ -1,12 +1,13 @@
const express = require('express');
const router = express.Router();
-const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships, getWorshipOptions, importWorships, uploadImportFile, exportWorships, saveImportedWorships, importNewsletterPdf } = require('../controllers/worshipController');
+const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships, getWorshipOptions, importWorships, importWorshipsNbrCsv, uploadImportFile, exportWorships, saveImportedWorships, importNewsletterPdf } = 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/nbr-csv', authMiddleware, uploadImportFile, importWorshipsNbrCsv);
router.post('/import/newsletter-pdf', authMiddleware, uploadImportFile, importNewsletterPdf);
router.post('/import/save', authMiddleware, saveImportedWorships);
router.put('/:id', authMiddleware, updateWorship);
diff --git a/server.js b/server.js
index efbaf4c..e517ea0 100644
--- a/server.js
+++ b/server.js
@@ -18,6 +18,7 @@ const institutionRouter = require('./routes/institutions');
const eventRouter = require('./routes/event');
const menuDataRouter = require('./routes/menuData');
const worshipRouter = require('./routes/worships');
+const worshipLeadersRouter = require('./routes/worshipLeaders');
const pageRouter = require('./routes/pages');
const userRouter = require('./routes/users');
const imageRouter = require('./routes/image');
@@ -106,6 +107,7 @@ app.use('/api/institutions', institutionRouter);
app.use('/api/events', eventRouter);
app.use('/api/menu-data', menuDataRouter);
app.use('/api/worships', worshipRouter);
+app.use('/api/worship-leaders', worshipLeadersRouter);
app.use('/api/page-content', pageRouter);
app.use('/api/users', userRouter);
app.use('/api/image', imageRouter);
diff --git a/src/content/admin/WorshipLeaderAdministration.vue b/src/content/admin/WorshipLeaderAdministration.vue
new file mode 100644
index 0000000..1de083b
--- /dev/null
+++ b/src/content/admin/WorshipLeaderAdministration.vue
@@ -0,0 +1,126 @@
+
+ Keine Einträge.Gestalter (Kürzel)
+
+ {{ formTitle }}
+
+
+ Vorhandene Kürzel
+
+
+