Add worship leaders functionality: Introduce worship leaders management by adding routes, controllers, and CSV import capabilities. Update worship management UI to support .csv file uploads for worship services, enhancing data handling and user experience.
All checks were successful
Deploy miriamgemeinde / deploy (push) Successful in 7s
All checks were successful
Deploy miriamgemeinde / deploy (push) Successful in 7s
This commit is contained in:
@@ -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 {
|
||||
|
||||
88
controllers/worshipLeaderController.js
Normal file
88
controllers/worshipLeaderController.js
Normal file
@@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user