From 7f01c004c8af1f2c1f42dc93ec97175caf2675bd Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 29 Apr 2026 18:04:05 +0200 Subject: [PATCH] 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. --- controllers/worshipController.js | 292 +++++++++++++++++- controllers/worshipLeaderController.js | 88 ++++++ .../20260429150000-create-worship-leaders.js | 47 +++ models/WorshipLeader.js | 32 ++ package.json | 1 + routes/worshipLeaders.js | 17 + routes/worships.js | 3 +- server.js | 2 + .../admin/WorshipLeaderAdministration.vue | 126 ++++++++ src/content/admin/WorshipManagement.vue | 17 +- src/router.js | 18 ++ 11 files changed, 635 insertions(+), 8 deletions(-) create mode 100644 controllers/worshipLeaderController.js create mode 100644 migrations/20260429150000-create-worship-leaders.js create mode 100644 models/WorshipLeader.js create mode 100644 routes/worshipLeaders.js create mode 100644 src/content/admin/WorshipLeaderAdministration.vue 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 @@ + + + + + diff --git a/src/content/admin/WorshipManagement.vue b/src/content/admin/WorshipManagement.vue index 90191a3..72fad46 100644 --- a/src/content/admin/WorshipManagement.vue +++ b/src/content/admin/WorshipManagement.vue @@ -15,13 +15,13 @@

Gottesdienste importieren

- +
Ausgewählte Datei: {{ selectedFile.name }} @@ -730,13 +730,13 @@ export default { handleFileSelect(event) { const file = event.target.files[0]; if (file) { - // Validierung: Nur .doc und .docx Dateien erlauben - const allowedExtensions = ['.doc', '.docx']; + // Validierung: .docx (alt) oder .csv (neu) erlauben + const allowedExtensions = ['.doc', '.docx', '.csv']; const fileName = file.name.toLowerCase(); const isValidFile = allowedExtensions.some(ext => fileName.endsWith(ext)); if (!isValidFile) { - alert('Bitte wählen Sie nur .doc oder .docx Dateien aus.'); + alert('Bitte wählen Sie eine .doc/.docx oder .csv Datei aus.'); event.target.value = ''; this.selectedFile = null; return; @@ -758,7 +758,12 @@ export default { formData.append('file', this.selectedFile); try { - const response = await axios.post('/worships/import', formData, { + const fileName = this.selectedFile.name.toLowerCase(); + const endpoint = fileName.endsWith('.csv') + ? '/worships/import/nbr-csv' + : '/worships/import'; + + const response = await axios.post(endpoint, formData, { headers: { 'Content-Type': 'multipart/form-data', }, diff --git a/src/router.js b/src/router.js index 18d3bbe..ea51d1a 100644 --- a/src/router.js +++ b/src/router.js @@ -5,6 +5,7 @@ const ROUTE_NAMES = { ADMIN_EDIT_PAGES: 'admin-edit-pages', ADMIN_FILE_UPLOAD: 'admin-file-upload', ADMIN_NEWSLETTER_IMPORT: 'admin-newsletter-import', + ADMIN_WORSHIP_LEADERS: 'admin-worship-leaders', REGISTER: 'register', FORGOT_PASSWORD: 'forgot-password', RESET_PASSWORD: 'reset-password', @@ -219,6 +220,21 @@ function addNewsletterImportRoute() { }); } +function addWorshipLeadersRoute() { + if (router.hasRoute(ROUTE_NAMES.ADMIN_WORSHIP_LEADERS)) { + router.removeRoute(ROUTE_NAMES.ADMIN_WORSHIP_LEADERS); + } + router.addRoute({ + path: '/admin/worship-leaders', + meta: { requiresAuth: true }, + components: { + default: loadComponent('admin/WorshipLeaderAdministration'), + rightColumn: loadComponent('ImageContent') + }, + name: ROUTE_NAMES.ADMIN_WORSHIP_LEADERS + }); +} + function addRegisterRoute() { if (router.hasRoute(ROUTE_NAMES.REGISTER)) { router.removeRoute(ROUTE_NAMES.REGISTER); @@ -335,6 +351,7 @@ function ensureCoreRoutes() { if (!router.hasRoute(ROUTE_NAMES.ADMIN_EDIT_PAGES)) addEditPagesRoute(); if (!router.hasRoute(ROUTE_NAMES.ADMIN_FILE_UPLOAD)) addFileUploadRoute(); if (!router.hasRoute(ROUTE_NAMES.ADMIN_NEWSLETTER_IMPORT)) addNewsletterImportRoute(); + if (!router.hasRoute(ROUTE_NAMES.ADMIN_WORSHIP_LEADERS)) addWorshipLeadersRoute(); if (!router.hasRoute(ROUTE_NAMES.REGISTER)) addRegisterRoute(); if (!router.hasRoute(ROUTE_NAMES.FORGOT_PASSWORD)) addForgotPasswordRoute(); if (!router.hasRoute(ROUTE_NAMES.RESET_PASSWORD)) addResetPasswordRoute(); @@ -348,6 +365,7 @@ function ensureCoreRoutes() { addEditPagesRoute(); addFileUploadRoute(); addNewsletterImportRoute(); +addWorshipLeadersRoute(); addRegisterRoute(); addForgotPasswordRoute(); addResetPasswordRoute();