diff --git a/controller/admin/CompanySettingsController.js b/controller/admin/CompanySettingsController.js new file mode 100644 index 0000000..b6b88eb --- /dev/null +++ b/controller/admin/CompanySettingsController.js @@ -0,0 +1,26 @@ +const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository'); + +const repo = new CompanySettingsRepository(); + +class CompanySettingsController { + static async get(req, res) { + try { + const settings = await repo.get(); + return res.json(settings || { company_name: '', company_street: '', company_postal_city: '', company_country: '' }); + } catch (err) { + return res.status(500).json({ message: 'Failed to load company settings' }); + } + } + + static async update(req, res) { + try { + const { company_name, company_street, company_postal_city, company_country } = req.body; + const updated = await repo.update({ company_name, company_street, company_postal_city, company_country }); + return res.json(updated); + } catch (err) { + return res.status(500).json({ message: 'Failed to update company settings' }); + } + } +} + +module.exports = CompanySettingsController; diff --git a/controller/register/GuestRegisterController.js b/controller/register/GuestRegisterController.js new file mode 100644 index 0000000..ad9174d --- /dev/null +++ b/controller/register/GuestRegisterController.js @@ -0,0 +1,77 @@ +const GuestUserService = require('../../services/user/guest/GuestUserService'); +const AbonemmentService = require('../../services/abonemments/AbonemmentService'); +const { logger } = require('../../middleware/logger'); + +const abonemmentService = new AbonemmentService(); + +class GuestRegisterController { + static async register(req, res) { + logger.info('GuestRegisterController.register:start'); + try { + const { + firstName, + lastName, + email, + confirmEmail, + password, + confirmPassword, + referralEmail, + lang, + } = req.body; + + if (!email || !password || !firstName || !lastName) { + return res.status(400).json({ + success: false, + message: 'firstName, lastName, email, and password are required', + }); + } + + if (email !== confirmEmail) { + return res.status(400).json({ + success: false, + message: 'Email and confirm email do not match', + }); + } + + if (password !== confirmPassword) { + return res.status(400).json({ + success: false, + message: 'Password and confirm password do not match', + }); + } + + const newUser = await GuestUserService.createGuestUser({ + email, + password, + firstName, + lastName, + referralEmail, + lang, + }); + + logger.info('GuestRegisterController.register:success', { userId: newUser.id }); + + res.status(201).json({ + success: true, + message: 'Guest user registered successfully', + userId: newUser.id, + }); + } catch (error) { + logger.error('GuestRegisterController.register:error', { error: error.message }); + + if (error.message === 'User already exists') { + return res.status(400).json({ + success: false, + message: 'User already exists', + }); + } + + res.status(500).json({ + success: false, + message: 'Internal server error', + }); + } + } +} + +module.exports = GuestRegisterController; diff --git a/database/createDb.js b/database/createDb.js index 4998b6b..eae39d3 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -168,7 +168,7 @@ const createDatabase = async () => { email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, user_type ENUM('personal', 'company') NOT NULL, - role ENUM('user', 'admin', 'super_admin') DEFAULT 'user', + role ENUM('user', 'admin', 'super_admin', 'guest') DEFAULT 'user', iban VARCHAR(34), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -180,6 +180,17 @@ const createDatabase = async () => { `); console.log('✅ Users table created/verified'); + // Migrate existing role ENUM to include 'guest' + try { + await connection.query(` + ALTER TABLE users + MODIFY COLUMN role ENUM('user', 'admin', 'super_admin', 'guest') DEFAULT 'user' + `); + console.log('✅ Updated users.role column to include guest'); + } catch (err) { + console.warn('⚠️ Could not modify users.role column:', err.message); + } + // 2. personal_profiles table: Details specific to personal users await connection.query(` CREATE TABLE IF NOT EXISTS personal_profiles ( @@ -705,6 +716,24 @@ const createDatabase = async () => { `); console.log('✅ User settings table created/verified'); + // --- Company Settings (single-row, global invoice / company info) --- + await connection.query(` + CREATE TABLE IF NOT EXISTS company_settings ( + id INT PRIMARY KEY DEFAULT 1, + company_name VARCHAR(200) NOT NULL DEFAULT 'ProfitPlanet GmbH', + company_street VARCHAR(255) NOT NULL DEFAULT '', + company_postal_city VARCHAR(255) NOT NULL DEFAULT '', + company_country VARCHAR(100) NOT NULL DEFAULT 'Germany', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CHECK (id = 1) + ); + `); + // Seed default row if missing + await connection.query(` + INSERT IGNORE INTO company_settings (id) VALUES (1); + `); + console.log('✅ Company settings table created/verified'); + // --- Rate Limiting Table --- await connection.query(` CREATE TABLE IF NOT EXISTS rate_limit ( diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js index 7f4ed0b..3ab00fb 100644 --- a/middleware/authMiddleware.js +++ b/middleware/authMiddleware.js @@ -77,6 +77,12 @@ async function authMiddleware(req, res, next) { return res.status(500).json({ success: false, message: 'Internal server error' }); } + // Guest restriction: guest users can only access abo-related routes + if (normalizedRole === 'guest') { + const guestRestriction = require('./guestRestriction'); + return guestRestriction(req, res, next); + } + next(); } catch (error) { logger.warn('authMiddleware:tokenInvalid', { diff --git a/middleware/guestRestriction.js b/middleware/guestRestriction.js new file mode 100644 index 0000000..00873a4 --- /dev/null +++ b/middleware/guestRestriction.js @@ -0,0 +1,53 @@ +const { logger } = require('./logger'); + +/** + * Middleware that blocks guest users from accessing non-abonnement routes. + * Guest users (role='guest') can ONLY access: + * - /abonements/* + * - /invoices/mine + * - /me + * - /user/settings + * - /logout + * - /refresh + * + * Place this AFTER authMiddleware in the app-level middleware chain. + */ +const GUEST_ALLOWED_PREFIXES = [ + '/abonements', + '/invoices/mine', + '/me', + '/user/settings', + '/user/status', + '/logout', + '/refresh', + '/coffee/active', + '/tax/vat-rates', +]; + +function guestRestriction(req, res, next) { + const user = req.user; + if (!user || user.role !== 'guest') { + return next(); + } + + const urlPath = req.originalUrl.split('?')[0]; + + const isAllowed = GUEST_ALLOWED_PREFIXES.some((prefix) => urlPath.startsWith(prefix)); + + if (isAllowed) { + return next(); + } + + logger.warn('guestRestriction:blocked', { + userId: user.userId || user.id, + route: urlPath, + method: req.method, + }); + + return res.status(403).json({ + success: false, + message: 'Guest accounts can only access subscription features. Please upgrade your account for full access.', + }); +} + +module.exports = guestRestriction; diff --git a/repositories/referral/ReferralTokenRepository.js b/repositories/referral/ReferralTokenRepository.js index 21ebf51..8bdfdf1 100644 --- a/repositories/referral/ReferralTokenRepository.js +++ b/repositories/referral/ReferralTokenRepository.js @@ -197,7 +197,7 @@ class ReferralTokenRepository { u.email AS referrer_email, u.user_type AS referrer_user_type FROM referral_tokens rt - JOIN users u ON rt.created_by_user_id = u.id + LEFT JOIN users u ON rt.created_by_user_id = u.id WHERE rt.token = ? LIMIT 1 `; @@ -209,7 +209,8 @@ class ReferralTokenRepository { token: r.token, max_uses: r.max_uses, uses_remaining: r.uses_remaining, - used_count: r.used_count + used_count: r.used_count, + referrer_id: r.referrer_id }); logger.info('ReferralTokenRepository.getReferrerInfoByToken:success', { token }); } else { diff --git a/repositories/settings/CompanySettingsRepository.js b/repositories/settings/CompanySettingsRepository.js new file mode 100644 index 0000000..a0f9f13 --- /dev/null +++ b/repositories/settings/CompanySettingsRepository.js @@ -0,0 +1,24 @@ +const pool = require('../../database/database'); + +class CompanySettingsRepository { + async get() { + const [rows] = await pool.query('SELECT * FROM company_settings WHERE id = 1'); + return rows[0] || null; + } + + async update({ company_name, company_street, company_postal_city, company_country }) { + await pool.query( + `INSERT INTO company_settings (id, company_name, company_street, company_postal_city, company_country) + VALUES (1, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + company_name = VALUES(company_name), + company_street = VALUES(company_street), + company_postal_city = VALUES(company_postal_city), + company_country = VALUES(company_country)`, + [company_name || '', company_street || '', company_postal_city || '', company_country || ''] + ); + return this.get(); + } +} + +module.exports = CompanySettingsRepository; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index dfe66a3..dc60a58 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -27,6 +27,7 @@ const AbonemmentController = require('../controller/abonemments/AbonemmentContro const NewsController = require('../controller/news/NewsController'); const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW const DevManagementController = require('../controller/dev/DevManagementController'); +const CompanySettingsController = require('../controller/admin/CompanySettingsController'); // small helpers copied from original files @@ -49,6 +50,7 @@ router.get('/user/settings', authMiddleware, UserSettingsController.getSettings) router.get('/users/:id/permissions', authMiddleware, PermissionController.getUserPermissions); router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails); router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo); +router.get('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.get); router.get('/users/:id/documents', authMiddleware, UserController.getUserDocumentsAndContracts); router.get('/verify-password-reset', (req, res) => { /* Note: was moved from PasswordResetController.verifyPasswordResetToken */ res.status(204).end(); }); // keep placeholder if controller already registered via other verb diff --git a/routes/postRoutes.js b/routes/postRoutes.js index 4f9b9d6..52c640b 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -14,6 +14,7 @@ const PermissionController = require('../controller/permissions/PermissionContro const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const PersonalRegisterController = require('../controller/register/PersonalRegisterController'); const CompanyRegisterController = require('../controller/register/CompanyRegisterController'); +const GuestRegisterController = require('../controller/register/GuestRegisterController'); const PersonalDocumentController = require('../controller/documents/PersonalDocumentController'); const CompanyDocumentController = require('../controller/documents/CompanyDocumentController'); const ContractUploadController = require('../controller/documents/ContractUploadController'); @@ -192,6 +193,10 @@ router.post('/register/company', (req, res) => { console.log('🔗 POST /register/company route accessed'); CompanyRegisterController.register(req, res); }); +router.post('/register/guest', (req, res) => { + console.log('🔗 POST /register/guest route accessed'); + GuestRegisterController.register(req, res); +}); console.log('✅ POST routes configured successfully'); diff --git a/routes/putRoutes.js b/routes/putRoutes.js index d219cc1..b0f4af3 100644 --- a/routes/putRoutes.js +++ b/routes/putRoutes.js @@ -6,6 +6,7 @@ const adminOnly = require('../middleware/adminOnly'); const AdminUserController = require('../controller/admin/AdminUserController'); const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const CoffeeController = require('../controller/admin/CoffeeController'); +const CompanySettingsController = require('../controller/admin/CompanySettingsController'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -17,4 +18,7 @@ router.put('/document-templates/:id', authMiddleware, upload.single('file'), Doc // Admin: update coffee product (supports picture file replacement) router.put('/admin/coffee/:id', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.update); +// Admin: update company settings (invoice address etc.) +router.put('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.update); + module.exports = router; diff --git a/services/abonemments/AbonemmentService.js b/services/abonemments/AbonemmentService.js index 2a4c479..b95d5d9 100644 --- a/services/abonemments/AbonemmentService.js +++ b/services/abonemments/AbonemmentService.js @@ -97,14 +97,17 @@ class AbonemmentService { const product = await this.getCoffeeProduct(coffeeId); if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`); + const capsulePrice = Number(product.price); + const packPrice = capsulePrice * 10; // 10 capsules per pack + totalPacks += packs; - totalPrice += packs * Number(product.price); + totalPrice += packs * packPrice; breakdown.push({ coffee_table_id: coffeeId, coffee_title: product.title || null, packs, - price_per_pack: Number(product.price), + price_per_pack: packPrice, currency: product.currency, }); } @@ -195,9 +198,11 @@ class AbonemmentService { if (!forSelf && effectiveEmail) { const existingUser = await this.findUserByEmail(effectiveEmail); + console.log('[SUBSCRIBE ORDER] Gift flow:', { forSelf, effectiveEmail, existingUser: existingUser?.id || null }); if (!existingUser) { await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null); const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null); + console.log('[SUBSCRIBE ORDER] Referral link generated:', referralLink); if (referralLink) { try { await MailService.sendSubscriptionInvitationEmail({ @@ -231,37 +236,21 @@ class AbonemmentService { const unitOfWork = new UnitOfWork(); await unitOfWork.start(); try { - const repo = new ReferralTokenRepository(unitOfWork); - const tokens = await repo.getTokensByUser(userId); - const now = Date.now(); - const active = (tokens || []).find((t) => { - const statusOk = t.status === 'active'; - const remaining = Number(t.uses_remaining); - const unlimited = Number(t.max_uses) === -1 || remaining === -1; - const remainingOk = unlimited || remaining > 0; - const exp = t.expires_at ? new Date(t.expires_at).getTime() : NaN; - const expiryOk = Number.isNaN(exp) ? true : exp > now; - return statusOk && remainingOk && expiryOk; + // Always create a fresh single-use token for each gift invitation + const created = await ReferralService.createReferralToken({ + userId, + expiresInDays: 7, + maxUses: 1, + unitOfWork, }); - - let tokenValue; - if (active?.token) { - tokenValue = active.token; - } else { - const created = await ReferralService.createReferralToken({ - userId, - expiresInDays: 7, - maxUses: 1, - unitOfWork, - }); - tokenValue = created?.token; - } + const tokenValue = created?.token; + console.log('[REFERRAL LINK] Created new gift token:', tokenValue); await unitOfWork.commit(); if (!tokenValue) return null; const base = (process.env.FRONTEND_URL || 'https://profit-planet.partners').replace(/\/$/, ''); - return `${base}/register?ref=${tokenValue}`; + return `${base}/register?ref=${tokenValue}&guest=true`; } catch (error) { await unitOfWork.rollback(error); console.error('[ABONEMENT] getOrCreateReferralLink failed:', error); @@ -340,7 +329,7 @@ class AbonemmentService { coffee_table_id: product.id, coffee_title: product.title || null, packs: 1, - price_per_pack: Number(product.price), + price_per_pack: Number(product.price) * 10, // 10 capsules per pack currency: product.currency, }], purchaser_user_id, // NEW @@ -476,13 +465,16 @@ class AbonemmentService { const product = await this.getCoffeeProduct(coffeeId); if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`); + const capsulePrice = Number(product.price); + const packPrice = capsulePrice * 10; // 10 capsules per pack + totalPacks += packs; - totalPrice += packs * Number(product.price); + totalPrice += packs * packPrice; breakdown.push({ coffee_table_id: coffeeId, coffee_title: product.title || null, packs, - price_per_pack: Number(product.price), + price_per_pack: packPrice, currency: product.currency, }); } diff --git a/services/email/MailService.js b/services/email/MailService.js index d737d16..6f23e8a 100644 --- a/services/email/MailService.js +++ b/services/email/MailService.js @@ -269,12 +269,12 @@ class MailService { logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang }); const isDe = lang === 'de'; const subject = isDe - ? 'ProfitPlanet: Einladung zur Registrierung' - : 'ProfitPlanet: Invitation to register'; + ? 'ProfitPlanet: Einladung zum Kaffee-Abonnement' + : 'ProfitPlanet: Invite for Coffee Abonnement'; const text = isDe - ? `Hallo,\n\n${inviterName || 'Ein Benutzer'} hat ein Kaffee-Abonnement für Sie erstellt.\nBitte registrieren Sie sich hier: ${referralLink}\n\nViele Grüße\nProfitPlanet Team` - : `Hi,\n\n${inviterName || 'A user'} created a coffee subscription for you.\nPlease register here: ${referralLink}\n\nBest regards\nProfitPlanet Team`; + ? `Hallo,\n\n${inviterName || 'Ein Benutzer'} hat ein Kaffee-Abonnement für Sie erstellt.\nBitte registrieren Sie sich als Gastkunde hier: ${referralLink}\n\nSobald Sie sich registriert haben, können Sie Ihr Abonnement einsehen und verwalten.\n\nViele Grüße\nProfitPlanet Team` + : `Hi,\n\n${inviterName || 'A user'} created a coffee abonnement for you.\nPlease register as a guest customer here: ${referralLink}\n\nOnce registered, you can view and manage your subscription.\n\nBest regards\nProfitPlanet Team`; const html = ` @@ -285,21 +285,31 @@ class MailService {
${isDe ? 'Kunde' : 'Customer'}: ${this._escapeHtml(customerName)}
-${isDe ? 'Datum' : 'Date'}: ${this._escapeHtml(issuedAt)}
+${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}
+${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}
${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}
+${isDe ? 'Gesamtbetrag' : 'Total'}: ${variables.totalGross}
`; } @@ -236,29 +358,17 @@ class InvoiceService { const text = this._buildInvoiceMailText({ invoice, items, abonement, lang }); const subject = this._getEmailSubject(lang, invoice.invoice_number); + // Build the full set of template variables once – used by both S3 and local paths + const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang }); + const templateHtml = await this._loadInvoiceTemplateHtml({ userType, lang }); let html = null; if (templateHtml) { - const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || ''; - const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10); - const variables = { - invoiceNumber: this._escapeHtml(invoice.invoice_number || ''), - customerName: this._escapeHtml(customerName), - issuedAt: this._escapeHtml(issuedAt), - totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)), - totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)), - totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)), - itemsHtml: this._buildItemsHtml(items, invoice.currency), - }; html = this._renderTemplate(templateHtml, variables); - - if (html && !html.includes('