diff --git a/controller/admin/MailTemplatesController.js b/controller/admin/MailTemplatesController.js new file mode 100644 index 0000000..3e52d84 --- /dev/null +++ b/controller/admin/MailTemplatesController.js @@ -0,0 +1,87 @@ +const MailTemplateService = require('../../services/template/MailTemplateService'); + +class MailTemplatesController { + static _currentUserId(req) { + return req.user?.userId ?? req.user?.id ?? null; + } + + static async list(req, res) { + try { + const data = await MailTemplateService.list(req.query || {}); + return res.json({ success: true, data }); + } catch (error) { + return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to list mail templates' }); + } + } + + static async getById(req, res) { + try { + const data = await MailTemplateService.getById(req.params.id); + if (!data) { + return res.status(404).json({ success: false, message: 'Mail template not found' }); + } + return res.json({ success: true, data }); + } catch (error) { + return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to load mail template' }); + } + } + + static async create(req, res) { + try { + const data = await MailTemplateService.create(req.body || {}, MailTemplatesController._currentUserId(req)); + return res.status(201).json({ success: true, data }); + } catch (error) { + return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to create mail template' }); + } + } + + static async update(req, res) { + try { + const data = await MailTemplateService.update(req.params.id, req.body || {}, MailTemplatesController._currentUserId(req)); + if (!data) { + return res.status(404).json({ success: false, message: 'Mail template not found' }); + } + return res.json({ success: true, data }); + } catch (error) { + return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to update mail template' }); + } + } + + static async activate(req, res) { + try { + const data = await MailTemplateService.activate(req.params.id, MailTemplatesController._currentUserId(req)); + if (!data) { + return res.status(404).json({ success: false, message: 'Mail template not found' }); + } + return res.json({ success: true, data }); + } catch (error) { + return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to activate mail template' }); + } + } + + static async archive(req, res) { + try { + const data = await MailTemplateService.archive(req.params.id, MailTemplatesController._currentUserId(req)); + if (!data) { + return res.status(404).json({ success: false, message: 'Mail template not found' }); + } + return res.json({ success: true, data }); + } catch (error) { + return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to archive mail template' }); + } + } + + static async remove(req, res) { + try { + const deleted = await MailTemplateService.remove(req.params.id); + if (!deleted) { + return res.status(404).json({ success: false, message: 'Mail template not found' }); + } + return res.json({ success: true }); + } catch (error) { + return res.status(error?.status || 500).json({ success: false, message: error.message || 'Failed to delete mail template' }); + } + } +} + +module.exports = MailTemplatesController; diff --git a/database/createDb.js b/database/createDb.js index 0ad65a5..8f4fe07 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -451,6 +451,30 @@ const createDatabase = async () => { `); console.log('✅ Document templates table created/verified'); + await connection.query(` + CREATE TABLE IF NOT EXISTS mail_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + template_type VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + subject VARCHAR(255) NULL, + html_content LONGTEXT NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 0, + is_archived TINYINT(1) NOT NULL DEFAULT 0, + archived_at TIMESTAMP NULL, + created_by INT NULL, + updated_by INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mail_templates_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT fk_mail_templates_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, + INDEX idx_mail_templates_type (template_type), + INDEX idx_mail_templates_active (is_active), + INDEX idx_mail_templates_archived (is_archived), + INDEX idx_mail_templates_type_active (template_type, is_active) + ); + `); + console.log('✅ Mail templates table created/verified'); + // Ensure enum includes 'abo' on existing schemas try { await connection.query(` @@ -1806,6 +1830,10 @@ const createDatabase = async () => { await ensureIndex(connection, 'rate_limit', 'idx_rate_limit_rate_key', 'rate_key'); await ensureIndex(connection, 'document_templates', 'idx_document_templates_user_type', 'user_type'); await ensureIndex(connection, 'document_templates', 'idx_document_templates_state_user_type', 'state, user_type'); + await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_type', 'template_type'); + await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_active', 'is_active'); + await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_archived', 'is_archived'); + await ensureIndex(connection, 'mail_templates', 'idx_mail_templates_type_active', 'template_type, is_active'); await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_company', 'company_id'); await ensureIndex(connection, 'company_stamps', 'idx_company_stamps_active', 'is_active'); console.log('🚀 Performance indexes created/verified'); diff --git a/repositories/template/MailTemplateRepository.js b/repositories/template/MailTemplateRepository.js new file mode 100644 index 0000000..b1ddcb9 --- /dev/null +++ b/repositories/template/MailTemplateRepository.js @@ -0,0 +1,192 @@ +const db = require('../../database/database'); + +class MailTemplateRepository { + _mapRow(row) { + if (!row) return null; + return { + id: Number(row.id), + template_type: row.template_type, + name: row.name, + subject: row.subject, + html_content: row.html_content, + is_active: row.is_active === 1 || row.is_active === true, + is_archived: row.is_archived === 1 || row.is_archived === true, + archived_at: row.archived_at, + created_by: row.created_by, + updated_by: row.updated_by, + created_at: row.created_at, + updated_at: row.updated_at, + }; + } + + async list({ includeArchived = false, templateType } = {}) { + const where = []; + const params = []; + + if (!includeArchived) { + where.push('is_archived = 0'); + } + + if (templateType) { + where.push('template_type = ?'); + params.push(templateType); + } + + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + const [rows] = await db.query( + `SELECT * + FROM mail_templates + ${whereClause} + ORDER BY template_type ASC, is_active DESC, updated_at DESC`, + params + ); + + return (rows || []).map((row) => this._mapRow(row)); + } + + async getById(id) { + const [rows] = await db.query( + `SELECT * + FROM mail_templates + WHERE id = ? + LIMIT 1`, + [id] + ); + return this._mapRow(rows?.[0]); + } + + async create(payload) { + const [result] = await db.query( + `INSERT INTO mail_templates + (template_type, name, subject, html_content, is_active, is_archived, created_by, updated_by) + VALUES (?, ?, ?, ?, 0, 0, ?, ?)`, + [ + payload.template_type, + payload.name, + payload.subject || null, + payload.html_content, + payload.userId || null, + payload.userId || null, + ] + ); + + return this.getById(result.insertId); + } + + async update(id, payload) { + const fields = []; + const values = []; + + if (payload.template_type !== undefined) { + fields.push('template_type = ?'); + values.push(payload.template_type); + } + if (payload.name !== undefined) { + fields.push('name = ?'); + values.push(payload.name); + } + if (payload.subject !== undefined) { + fields.push('subject = ?'); + values.push(payload.subject || null); + } + if (payload.html_content !== undefined) { + fields.push('html_content = ?'); + values.push(payload.html_content); + } + + fields.push('updated_by = ?'); + values.push(payload.userId || null); + + if (!fields.length) return this.getById(id); + + values.push(id); + const [result] = await db.query( + `UPDATE mail_templates + SET ${fields.join(', ')}, updated_at = NOW() + WHERE id = ?`, + values + ); + + if (!result?.affectedRows) return null; + return this.getById(id); + } + + async activate(id, userId) { + const conn = await db.getConnection(); + + try { + await conn.beginTransaction(); + + const [targetRows] = await conn.query( + `SELECT id, template_type, is_archived + FROM mail_templates + WHERE id = ? + LIMIT 1 + FOR UPDATE`, + [id] + ); + + const target = targetRows?.[0]; + if (!target) { + await conn.rollback(); + return null; + } + + if (target.is_archived === 1 || target.is_archived === true) { + const error = new Error('Archived templates cannot be activated'); + error.status = 400; + throw error; + } + + await conn.query( + `UPDATE mail_templates + SET is_active = 0, updated_by = ?, updated_at = NOW() + WHERE template_type = ?`, + [userId || null, target.template_type] + ); + + await conn.query( + `UPDATE mail_templates + SET is_active = 1, + is_archived = 0, + archived_at = NULL, + updated_by = ?, + updated_at = NOW() + WHERE id = ?`, + [userId || null, id] + ); + + await conn.commit(); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + + return this.getById(id); + } + + async archive(id, userId) { + const [result] = await db.query( + `UPDATE mail_templates + SET is_archived = 1, + is_active = 0, + archived_at = NOW(), + updated_by = ?, + updated_at = NOW() + WHERE id = ?`, + [userId || null, id] + ); + + if (!result?.affectedRows) return null; + return this.getById(id); + } + + async delete(id) { + const [result] = await db.query('DELETE FROM mail_templates WHERE id = ?', [id]); + return Number(result?.affectedRows || 0) > 0; + } +} + +module.exports = new MailTemplateRepository(); diff --git a/routes/deleteRoutes.js b/routes/deleteRoutes.js index 1d98c3d..681266b 100644 --- a/routes/deleteRoutes.js +++ b/routes/deleteRoutes.js @@ -11,6 +11,7 @@ const AffiliateController = require('../controller/affiliate/AffiliateController const NewsController = require('../controller/news/NewsController'); const PoolController = require('../controller/pool/PoolController'); const I18nPreferencesController = require('../controller/admin/I18nPreferencesController'); +const MailTemplatesController = require('../controller/admin/MailTemplatesController'); // Helper middlewares for company-stamp function forceCompanyForAdmin(req, res, next) { @@ -22,6 +23,7 @@ function forceCompanyForAdmin(req, res, next) { // DELETE /admin/user/:id (moved from routes/admin.js) router.delete('/admin/user/:id', authMiddleware, adminOnly, AdminUserController.deleteUser); +router.delete('/admin/mail-templates/:id', authMiddleware, adminOnly, MailTemplatesController.remove); // DELETE /document-templates/:id (moved from routes/documentTemplates.js) router.delete('/document-templates/:id', authMiddleware, DocumentTemplateController.deleteTemplate); diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 766b0ca..f76d12f 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -32,6 +32,7 @@ const DashboardPlatformsController = require('../controller/admin/DashboardPlatf const ShippingFeesController = require('../controller/admin/ShippingFeesController'); const LoginController = require('../controller/login/LoginController'); const I18nPreferencesController = require('../controller/admin/I18nPreferencesController'); +const MailTemplatesController = require('../controller/admin/MailTemplatesController'); const AUTH_VALIDATE_ROUTE_PATH = '/auth/validate'; @@ -68,6 +69,8 @@ router.get('/verify-password-reset', (req, res) => { /* Note: was moved from Pas // admin.js GETs router.get('/admin/user-stats', authMiddleware, adminOnly, AdminUserController.getUserStats); router.get('/admin/user-list', authMiddleware, adminOnly, AdminUserController.getUserList); +router.get('/admin/mail-templates', authMiddleware, adminOnly, MailTemplatesController.list); +router.get('/admin/mail-templates/:id', authMiddleware, adminOnly, MailTemplatesController.getById); router.get('/admin/verification-pending-users', authMiddleware, adminOnly, AdminUserController.getVerificationPendingUsers); router.get('/admin/unverified-users', authMiddleware, adminOnly, AdminUserController.getUnverifiedUsers); router.get('/admin/user/:id/documents', authMiddleware, adminOnly, UserDocumentController.getAllDocumentsForUser); diff --git a/routes/patchRoutes.js b/routes/patchRoutes.js index ff78e8e..8469579 100644 --- a/routes/patchRoutes.js +++ b/routes/patchRoutes.js @@ -15,6 +15,7 @@ const NewsController = require('../controller/news/NewsController'); const AbonemmentController = require('../controller/abonemments/AbonemmentController'); const InvoiceController = require('../controller/invoice/InvoiceController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); +const MailTemplatesController = require('../controller/admin/MailTemplatesController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -36,6 +37,9 @@ router.patch('/company-stamps/:id/activate', authMiddleware, adminOnly, forceCom // Admin user management PATCH routes router.patch('/admin/archive-user/:id', authMiddleware, adminOnly, AdminUserController.archiveUser); router.patch('/admin/unarchive-user/:id', authMiddleware, adminOnly, AdminUserController.unarchiveUser); +router.patch('/admin/mail-templates/:id', authMiddleware, adminOnly, MailTemplatesController.update); +router.patch('/admin/mail-templates/:id/activate', authMiddleware, adminOnly, MailTemplatesController.activate); +router.patch('/admin/mail-templates/:id/archive', authMiddleware, adminOnly, MailTemplatesController.archive); router.patch('/admin/update-verification/:id', authMiddleware, adminOnly, AdminUserController.updateUserVerification); router.patch('/admin/update-user-profile/:id', authMiddleware, adminOnly, AdminUserController.updateUserProfile); router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUserController.updateUserStatus); diff --git a/routes/postRoutes.js b/routes/postRoutes.js index cec4238..3aaeb02 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -33,6 +33,7 @@ const InvoiceController = require('../controller/invoice/InvoiceController'); // const DevManagementController = require('../controller/dev/DevManagementController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); const I18nPreferencesController = require('../controller/admin/I18nPreferencesController'); +const MailTemplatesController = require('../controller/admin/MailTemplatesController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -83,6 +84,7 @@ router.post('/profile/company/complete', authMiddleware, CompanyProfileControlle // Admin POSTs (moved from routes/admin.js) router.post('/admin/verify-user/:id', authMiddleware, adminOnly, AdminUserController.verifyUser); +router.post('/admin/mail-templates', authMiddleware, adminOnly, MailTemplatesController.create); router.post('/admin/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.post); router.post('/i18n/preferences', authMiddleware, adminOnly, I18nPreferencesController.post); router.post('/i18n/translations', authMiddleware, adminOnly, I18nPreferencesController.upsertTranslations); diff --git a/services/template/MailTemplateService.js b/services/template/MailTemplateService.js new file mode 100644 index 0000000..46f5b3a --- /dev/null +++ b/services/template/MailTemplateService.js @@ -0,0 +1,122 @@ +const MailTemplateRepository = require('../../repositories/template/MailTemplateRepository'); + +class MailTemplateService { + _requiredString(value, fieldName) { + const parsed = String(value == null ? '' : value).trim(); + if (!parsed) { + const error = new Error(`${fieldName} is required`); + error.status = 400; + throw error; + } + return parsed; + } + + _optionalString(value) { + if (value === undefined || value === null) return null; + const parsed = String(value).trim(); + return parsed || null; + } + + _toBool(value, fallback = false) { + if (value === undefined || value === null) return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; + } + + async list(query = {}) { + return MailTemplateRepository.list({ + includeArchived: this._toBool(query.includeArchived, false), + templateType: this._optionalString(query.templateType), + }); + } + + async getById(id) { + const numericId = Number(id); + if (!Number.isFinite(numericId) || numericId <= 0) { + const error = new Error('Invalid id'); + error.status = 400; + throw error; + } + + return MailTemplateRepository.getById(numericId); + } + + async create(payload = {}, userId = null) { + const template_type = this._requiredString(payload.template_type, 'template_type'); + const name = this._requiredString(payload.name, 'name'); + const html_content = this._requiredString(payload.html_content, 'html_content'); + + return MailTemplateRepository.create({ + template_type, + name, + subject: this._optionalString(payload.subject), + html_content, + userId, + }); + } + + async update(id, payload = {}, userId = null) { + const numericId = Number(id); + if (!Number.isFinite(numericId) || numericId <= 0) { + const error = new Error('Invalid id'); + error.status = 400; + throw error; + } + + const updatePayload = {}; + + if (Object.prototype.hasOwnProperty.call(payload, 'template_type')) { + updatePayload.template_type = this._requiredString(payload.template_type, 'template_type'); + } + if (Object.prototype.hasOwnProperty.call(payload, 'name')) { + updatePayload.name = this._requiredString(payload.name, 'name'); + } + if (Object.prototype.hasOwnProperty.call(payload, 'subject')) { + updatePayload.subject = this._optionalString(payload.subject); + } + if (Object.prototype.hasOwnProperty.call(payload, 'html_content')) { + updatePayload.html_content = this._requiredString(payload.html_content, 'html_content'); + } + + updatePayload.userId = userId; + return MailTemplateRepository.update(numericId, updatePayload); + } + + async activate(id, userId = null) { + const numericId = Number(id); + if (!Number.isFinite(numericId) || numericId <= 0) { + const error = new Error('Invalid id'); + error.status = 400; + throw error; + } + + return MailTemplateRepository.activate(numericId, userId); + } + + async archive(id, userId = null) { + const numericId = Number(id); + if (!Number.isFinite(numericId) || numericId <= 0) { + const error = new Error('Invalid id'); + error.status = 400; + throw error; + } + + return MailTemplateRepository.archive(numericId, userId); + } + + async remove(id) { + const numericId = Number(id); + if (!Number.isFinite(numericId) || numericId <= 0) { + const error = new Error('Invalid id'); + error.status = 400; + throw error; + } + + return MailTemplateRepository.delete(numericId); + } +} + +module.exports = new MailTemplateService();