feat: implement guest user registration and company settings management
This commit is contained in:
parent
2c239ad331
commit
f85d01af8d
26
controller/admin/CompanySettingsController.js
Normal file
26
controller/admin/CompanySettingsController.js
Normal file
@ -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;
|
||||||
77
controller/register/GuestRegisterController.js
Normal file
77
controller/register/GuestRegisterController.js
Normal file
@ -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;
|
||||||
@ -168,7 +168,7 @@ const createDatabase = async () => {
|
|||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
password VARCHAR(255) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
user_type ENUM('personal', 'company') 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),
|
iban VARCHAR(34),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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');
|
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
|
// 2. personal_profiles table: Details specific to personal users
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS personal_profiles (
|
CREATE TABLE IF NOT EXISTS personal_profiles (
|
||||||
@ -705,6 +716,24 @@ const createDatabase = async () => {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ User settings table created/verified');
|
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 ---
|
// --- Rate Limiting Table ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS rate_limit (
|
CREATE TABLE IF NOT EXISTS rate_limit (
|
||||||
|
|||||||
@ -77,6 +77,12 @@ async function authMiddleware(req, res, next) {
|
|||||||
return res.status(500).json({ success: false, message: 'Internal server error' });
|
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();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('authMiddleware:tokenInvalid', {
|
logger.warn('authMiddleware:tokenInvalid', {
|
||||||
|
|||||||
53
middleware/guestRestriction.js
Normal file
53
middleware/guestRestriction.js
Normal file
@ -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;
|
||||||
@ -197,7 +197,7 @@ class ReferralTokenRepository {
|
|||||||
u.email AS referrer_email,
|
u.email AS referrer_email,
|
||||||
u.user_type AS referrer_user_type
|
u.user_type AS referrer_user_type
|
||||||
FROM referral_tokens rt
|
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 = ?
|
WHERE rt.token = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
@ -209,7 +209,8 @@ class ReferralTokenRepository {
|
|||||||
token: r.token,
|
token: r.token,
|
||||||
max_uses: r.max_uses,
|
max_uses: r.max_uses,
|
||||||
uses_remaining: r.uses_remaining,
|
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 });
|
logger.info('ReferralTokenRepository.getReferrerInfoByToken:success', { token });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
24
repositories/settings/CompanySettingsRepository.js
Normal file
24
repositories/settings/CompanySettingsRepository.js
Normal file
@ -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;
|
||||||
@ -27,6 +27,7 @@ const AbonemmentController = require('../controller/abonemments/AbonemmentContro
|
|||||||
const NewsController = require('../controller/news/NewsController');
|
const NewsController = require('../controller/news/NewsController');
|
||||||
const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW
|
const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW
|
||||||
const DevManagementController = require('../controller/dev/DevManagementController');
|
const DevManagementController = require('../controller/dev/DevManagementController');
|
||||||
|
const CompanySettingsController = require('../controller/admin/CompanySettingsController');
|
||||||
|
|
||||||
// small helpers copied from original files
|
// 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('/users/:id/permissions', authMiddleware, PermissionController.getUserPermissions);
|
||||||
router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails);
|
router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails);
|
||||||
router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo);
|
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('/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
|
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
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const PermissionController = require('../controller/permissions/PermissionContro
|
|||||||
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
||||||
const PersonalRegisterController = require('../controller/register/PersonalRegisterController');
|
const PersonalRegisterController = require('../controller/register/PersonalRegisterController');
|
||||||
const CompanyRegisterController = require('../controller/register/CompanyRegisterController');
|
const CompanyRegisterController = require('../controller/register/CompanyRegisterController');
|
||||||
|
const GuestRegisterController = require('../controller/register/GuestRegisterController');
|
||||||
const PersonalDocumentController = require('../controller/documents/PersonalDocumentController');
|
const PersonalDocumentController = require('../controller/documents/PersonalDocumentController');
|
||||||
const CompanyDocumentController = require('../controller/documents/CompanyDocumentController');
|
const CompanyDocumentController = require('../controller/documents/CompanyDocumentController');
|
||||||
const ContractUploadController = require('../controller/documents/ContractUploadController');
|
const ContractUploadController = require('../controller/documents/ContractUploadController');
|
||||||
@ -192,6 +193,10 @@ router.post('/register/company', (req, res) => {
|
|||||||
console.log('🔗 POST /register/company route accessed');
|
console.log('🔗 POST /register/company route accessed');
|
||||||
CompanyRegisterController.register(req, res);
|
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');
|
console.log('✅ POST routes configured successfully');
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ const adminOnly = require('../middleware/adminOnly');
|
|||||||
const AdminUserController = require('../controller/admin/AdminUserController');
|
const AdminUserController = require('../controller/admin/AdminUserController');
|
||||||
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
||||||
const CoffeeController = require('../controller/admin/CoffeeController');
|
const CoffeeController = require('../controller/admin/CoffeeController');
|
||||||
|
const CompanySettingsController = require('../controller/admin/CompanySettingsController');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
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)
|
// Admin: update coffee product (supports picture file replacement)
|
||||||
router.put('/admin/coffee/:id', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.update);
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@ -97,14 +97,17 @@ class AbonemmentService {
|
|||||||
const product = await this.getCoffeeProduct(coffeeId);
|
const product = await this.getCoffeeProduct(coffeeId);
|
||||||
if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`);
|
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;
|
totalPacks += packs;
|
||||||
totalPrice += packs * Number(product.price);
|
totalPrice += packs * packPrice;
|
||||||
|
|
||||||
breakdown.push({
|
breakdown.push({
|
||||||
coffee_table_id: coffeeId,
|
coffee_table_id: coffeeId,
|
||||||
coffee_title: product.title || null,
|
coffee_title: product.title || null,
|
||||||
packs,
|
packs,
|
||||||
price_per_pack: Number(product.price),
|
price_per_pack: packPrice,
|
||||||
currency: product.currency,
|
currency: product.currency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -195,9 +198,11 @@ class AbonemmentService {
|
|||||||
|
|
||||||
if (!forSelf && effectiveEmail) {
|
if (!forSelf && effectiveEmail) {
|
||||||
const existingUser = await this.findUserByEmail(effectiveEmail);
|
const existingUser = await this.findUserByEmail(effectiveEmail);
|
||||||
|
console.log('[SUBSCRIBE ORDER] Gift flow:', { forSelf, effectiveEmail, existingUser: existingUser?.id || null });
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null);
|
await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null);
|
||||||
const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null);
|
const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null);
|
||||||
|
console.log('[SUBSCRIBE ORDER] Referral link generated:', referralLink);
|
||||||
if (referralLink) {
|
if (referralLink) {
|
||||||
try {
|
try {
|
||||||
await MailService.sendSubscriptionInvitationEmail({
|
await MailService.sendSubscriptionInvitationEmail({
|
||||||
@ -231,37 +236,21 @@ class AbonemmentService {
|
|||||||
const unitOfWork = new UnitOfWork();
|
const unitOfWork = new UnitOfWork();
|
||||||
await unitOfWork.start();
|
await unitOfWork.start();
|
||||||
try {
|
try {
|
||||||
const repo = new ReferralTokenRepository(unitOfWork);
|
// Always create a fresh single-use token for each gift invitation
|
||||||
const tokens = await repo.getTokensByUser(userId);
|
const created = await ReferralService.createReferralToken({
|
||||||
const now = Date.now();
|
userId,
|
||||||
const active = (tokens || []).find((t) => {
|
expiresInDays: 7,
|
||||||
const statusOk = t.status === 'active';
|
maxUses: 1,
|
||||||
const remaining = Number(t.uses_remaining);
|
unitOfWork,
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
const tokenValue = created?.token;
|
||||||
let tokenValue;
|
console.log('[REFERRAL LINK] Created new gift token:', tokenValue);
|
||||||
if (active?.token) {
|
|
||||||
tokenValue = active.token;
|
|
||||||
} else {
|
|
||||||
const created = await ReferralService.createReferralToken({
|
|
||||||
userId,
|
|
||||||
expiresInDays: 7,
|
|
||||||
maxUses: 1,
|
|
||||||
unitOfWork,
|
|
||||||
});
|
|
||||||
tokenValue = created?.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
await unitOfWork.commit();
|
await unitOfWork.commit();
|
||||||
if (!tokenValue) return null;
|
if (!tokenValue) return null;
|
||||||
|
|
||||||
const base = (process.env.FRONTEND_URL || 'https://profit-planet.partners').replace(/\/$/, '');
|
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) {
|
} catch (error) {
|
||||||
await unitOfWork.rollback(error);
|
await unitOfWork.rollback(error);
|
||||||
console.error('[ABONEMENT] getOrCreateReferralLink failed:', error);
|
console.error('[ABONEMENT] getOrCreateReferralLink failed:', error);
|
||||||
@ -340,7 +329,7 @@ class AbonemmentService {
|
|||||||
coffee_table_id: product.id,
|
coffee_table_id: product.id,
|
||||||
coffee_title: product.title || null,
|
coffee_title: product.title || null,
|
||||||
packs: 1,
|
packs: 1,
|
||||||
price_per_pack: Number(product.price),
|
price_per_pack: Number(product.price) * 10, // 10 capsules per pack
|
||||||
currency: product.currency,
|
currency: product.currency,
|
||||||
}],
|
}],
|
||||||
purchaser_user_id, // NEW
|
purchaser_user_id, // NEW
|
||||||
@ -476,13 +465,16 @@ class AbonemmentService {
|
|||||||
const product = await this.getCoffeeProduct(coffeeId);
|
const product = await this.getCoffeeProduct(coffeeId);
|
||||||
if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`);
|
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;
|
totalPacks += packs;
|
||||||
totalPrice += packs * Number(product.price);
|
totalPrice += packs * packPrice;
|
||||||
breakdown.push({
|
breakdown.push({
|
||||||
coffee_table_id: coffeeId,
|
coffee_table_id: coffeeId,
|
||||||
coffee_title: product.title || null,
|
coffee_title: product.title || null,
|
||||||
packs,
|
packs,
|
||||||
price_per_pack: Number(product.price),
|
price_per_pack: packPrice,
|
||||||
currency: product.currency,
|
currency: product.currency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -269,12 +269,12 @@ class MailService {
|
|||||||
logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang });
|
logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang });
|
||||||
const isDe = lang === 'de';
|
const isDe = lang === 'de';
|
||||||
const subject = isDe
|
const subject = isDe
|
||||||
? 'ProfitPlanet: Einladung zur Registrierung'
|
? 'ProfitPlanet: Einladung zum Kaffee-Abonnement'
|
||||||
: 'ProfitPlanet: Invitation to register';
|
: 'ProfitPlanet: Invite for Coffee Abonnement';
|
||||||
|
|
||||||
const text = isDe
|
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`
|
? `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 subscription for you.\nPlease register here: ${referralLink}\n\nBest regards\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 = `<!doctype html>
|
const html = `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
@ -285,21 +285,31 @@ class MailService {
|
|||||||
<td align="center">
|
<td align="center">
|
||||||
<table role="presentation" width="620" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
|
<table role="presentation" width="620" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 24px;background:#111827;color:#ffffff;">
|
<td style="padding:20px 24px;background:#1C2B4A;color:#ffffff;">
|
||||||
<h2 style="margin:0;font-size:22px;">${isDe ? 'Einladung zur Registrierung' : 'Invitation to register'}</h2>
|
<h2 style="margin:0;font-size:22px;">${isDe ? 'Einladung zum Kaffee-Abonnement' : 'Invite for Coffee Abonnement'}</h2>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:22px 24px;">
|
<td style="padding:22px 24px;">
|
||||||
<p style="margin:0 0 12px 0;line-height:1.6;">${isDe
|
<p style="margin:0 0 12px 0;line-height:1.6;">${isDe
|
||||||
? `${this._escapeForHtml(inviterName || 'Ein Benutzer')} hat ein Kaffee-Abonnement für Sie erstellt.`
|
? `${this._escapeForHtml(inviterName || 'Ein Benutzer')} hat ein Kaffee-Abonnement für Sie erstellt.`
|
||||||
: `${this._escapeForHtml(inviterName || 'A user')} created a coffee subscription for you.`}</p>
|
: `${this._escapeForHtml(inviterName || 'A user')} created a coffee abonnement for you.`}</p>
|
||||||
<p style="margin:0 0 18px 0;line-height:1.6;">${isDe ? 'Bitte schließen Sie Ihre Registrierung über den folgenden Link ab:' : 'Please complete your registration using the link below:'}</p>
|
<p style="margin:0 0 12px 0;line-height:1.6;">${isDe
|
||||||
|
? 'Als Gastkunde können Sie Ihr Abonnement einsehen und verwalten.'
|
||||||
|
: 'As a guest customer, you can view and manage your subscription.'}</p>
|
||||||
|
<p style="margin:0 0 18px 0;line-height:1.6;">${isDe ? 'Bitte registrieren Sie sich über den folgenden Link:' : 'Please register using the link below:'}</p>
|
||||||
<p style="margin:0;">
|
<p style="margin:0;">
|
||||||
<a href="${this._escapeForHtml(referralLink)}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;padding:10px 16px;border-radius:8px;font-weight:700;">${isDe ? 'Jetzt registrieren' : 'Register now'}</a>
|
<a href="${this._escapeForHtml(referralLink)}" style="display:inline-block;background:#1C2B4A;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:700;font-size:15px;">${isDe ? 'Jetzt als Gastkunde registrieren' : 'Register as Guest Customer'}</a>
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 24px;background:#f9fafb;border-top:1px solid #e5e7eb;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#6b7280;line-height:1.5;">${isDe
|
||||||
|
? 'Sie erhalten als Gastkunde Zugang zu Ihrem Abonnement. Weitere Funktionen der Plattform stehen Ihnen nach einem Upgrade zur Verfügung.'
|
||||||
|
: 'As a guest customer you will have access to your subscription. Other platform features are available after upgrading your account.'}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -8,6 +8,10 @@ const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|||||||
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
|
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
const puppeteer = require('puppeteer');
|
const puppeteer = require('puppeteer');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const CompanySettingsRepository = require('../../repositories/settings/CompanySettingsRepository');
|
||||||
|
|
||||||
class InvoiceService {
|
class InvoiceService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -148,20 +152,138 @@ class InvoiceService {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) {
|
_buildItemsTableRows(items, currency) {
|
||||||
|
if (!Array.isArray(items) || !items.length) {
|
||||||
|
return `<tr><td>1</td><td>Subscription item</td><td>1</td><td>-</td><td>-</td></tr>`;
|
||||||
|
}
|
||||||
|
return items.map((item, i) => {
|
||||||
|
const desc = this._escapeHtml(item.description || 'Coffee');
|
||||||
|
const qty = Number(item.quantity || 0);
|
||||||
|
const unit = this._formatAmount(item.unit_price || 0, currency);
|
||||||
|
const total = this._formatAmount(item.line_gross || 0, currency);
|
||||||
|
return `<tr><td>${i + 1}</td><td>${desc}</td><td>${qty}</td><td>${unit}</td><td>${total}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadInvoiceHtmlTemplate() {
|
||||||
|
try {
|
||||||
|
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice', 'invoiceTemplate.html');
|
||||||
|
return fs.readFileSync(templatePath, 'utf8');
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('InvoiceService._loadInvoiceHtmlTemplate:error', { message: e?.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _buildInvoiceTemplateVariables({ invoice, items, abonement, lang }) {
|
||||||
const isDe = lang === 'de';
|
const isDe = lang === 'de';
|
||||||
const customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
|
const isGift = abonement?.details?.is_for_self === false;
|
||||||
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||||
|
const dueAt = invoice.due_at ? new Date(invoice.due_at).toISOString().slice(0, 10) : '-';
|
||||||
|
const vatRate = invoice.vat_rate != null ? Number(invoice.vat_rate) : 0;
|
||||||
|
|
||||||
|
// Load company info from DB
|
||||||
|
let companyInfo = { company_name: 'ProfitPlanet GmbH', company_street: '', company_postal_city: '', company_country: 'Germany' };
|
||||||
|
try {
|
||||||
|
const repo = new CompanySettingsRepository();
|
||||||
|
const row = await repo.get();
|
||||||
|
if (row) companyInfo = row;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('InvoiceService._buildInvoiceTemplateVariables:company_settings_error', { message: e?.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For gift subscriptions: "Bill To" = recipient, "Ordered by" = purchaser
|
||||||
|
// For self subscriptions: "Bill To" = the subscriber
|
||||||
|
let customerName;
|
||||||
|
let customerEmail = '';
|
||||||
|
let orderedByBlock = '';
|
||||||
|
|
||||||
|
if (isGift) {
|
||||||
|
// Recipient info for "Bill To"
|
||||||
|
const recipientName = abonement?.details?.recipient_name || '';
|
||||||
|
const recipientEmail = abonement?.email || invoice.buyer_email || '';
|
||||||
|
customerName = recipientName || recipientEmail || '-';
|
||||||
|
customerEmail = recipientName ? recipientEmail : '';
|
||||||
|
|
||||||
|
// Purchaser info for "Ordered by"
|
||||||
|
const purchaserName = invoice.buyer_name || [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || '';
|
||||||
|
if (purchaserName) {
|
||||||
|
const orderedByLabel = isDe ? 'Bestellt von' : 'Ordered By';
|
||||||
|
orderedByBlock = `<div class="meta-block"><h3>${this._escapeHtml(orderedByLabel)}</h3><p><span class="highlight">${this._escapeHtml(purchaserName)}</span></p></div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
customerName = [abonement?.first_name, abonement?.last_name].filter(Boolean).join(' ') || invoice.buyer_name || '-';
|
||||||
|
customerEmail = abonement?.email || invoice.buyer_email || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lang: isDe ? 'de' : 'en',
|
||||||
|
documentTitle: isDe ? 'Rechnung' : 'Invoice',
|
||||||
|
invoiceNumber: this._escapeHtml(invoice.invoice_number || ''),
|
||||||
|
invoiceNumberLabel: isDe ? 'Rechnungsnummer' : 'Invoice Number',
|
||||||
|
fromLabel: isDe ? 'Von' : 'From',
|
||||||
|
toLabel: isDe ? 'An' : 'Bill To',
|
||||||
|
detailsLabel: isDe ? 'Details' : 'Details',
|
||||||
|
dateLabel: isDe ? 'Datum' : 'Date',
|
||||||
|
dueDateLabel: isDe ? 'Fällig am' : 'Due Date',
|
||||||
|
statusLabel: 'Status',
|
||||||
|
invoiceStatus: this._escapeHtml((invoice.status || 'issued').toUpperCase()),
|
||||||
|
companyName: this._escapeHtml(companyInfo.company_name || 'ProfitPlanet GmbH'),
|
||||||
|
companyStreet: this._escapeHtml(companyInfo.company_street || ''),
|
||||||
|
companyPostalCity: this._escapeHtml(companyInfo.company_postal_city || ''),
|
||||||
|
companyCountry: this._escapeHtml(companyInfo.company_country || 'Germany'),
|
||||||
|
customerName: this._escapeHtml(customerName),
|
||||||
|
customerEmail: this._escapeHtml(customerEmail),
|
||||||
|
customerStreet: this._escapeHtml(invoice.buyer_street || ''),
|
||||||
|
customerPostalCity: this._escapeHtml([invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')),
|
||||||
|
customerCountry: this._escapeHtml(invoice.buyer_country || ''),
|
||||||
|
orderedByBlock,
|
||||||
|
issuedAt: this._escapeHtml(issuedAt),
|
||||||
|
dueAt: this._escapeHtml(dueAt),
|
||||||
|
descriptionHeader: isDe ? 'Beschreibung' : 'Description',
|
||||||
|
qtyHeader: isDe ? 'Menge' : 'Qty',
|
||||||
|
unitPriceHeader: isDe ? 'Stückpreis' : 'Unit Price',
|
||||||
|
totalHeader: isDe ? 'Gesamt' : 'Total',
|
||||||
|
itemsRows: this._buildItemsTableRows(items, invoice.currency),
|
||||||
|
subtotalLabel: isDe ? 'Nettobetrag' : 'Subtotal (net)',
|
||||||
|
taxLabel: isDe ? 'MwSt.' : 'Tax',
|
||||||
|
vatRateDisplay: vatRate ? `${vatRate}%` : '0%',
|
||||||
|
totalNet: this._escapeHtml(this._formatAmount(invoice.total_net, invoice.currency)),
|
||||||
|
totalTax: this._escapeHtml(this._formatAmount(invoice.total_tax, invoice.currency)),
|
||||||
|
totalLabel: isDe ? 'Gesamtbetrag (brutto)' : 'Total (gross)',
|
||||||
|
totalGross: this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency)),
|
||||||
|
paymentInfoTitle: isDe ? 'Zahlungsinformationen' : 'Payment Information',
|
||||||
|
paymentInfoText: isDe
|
||||||
|
? 'Bitte überweisen Sie den Gesamtbetrag unter Angabe der Rechnungsnummer.'
|
||||||
|
: 'Please transfer the total amount stating the invoice number as reference.',
|
||||||
|
footerText: isDe
|
||||||
|
? 'Vielen Dank für Ihr Vertrauen.'
|
||||||
|
: 'Thank you for your business.',
|
||||||
|
// Legacy key used by S3-stored templates
|
||||||
|
itemsHtml: this._buildItemsHtml(items, invoice.currency),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _buildFallbackInvoiceHtml({ invoice, items, abonement, lang }) {
|
||||||
|
const variables = await this._buildInvoiceTemplateVariables({ invoice, items, abonement, lang });
|
||||||
|
|
||||||
|
const template = this._loadInvoiceHtmlTemplate();
|
||||||
|
if (template) {
|
||||||
|
return this._renderTemplate(template, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute fallback if template file is missing
|
||||||
|
const isDe = lang === 'de';
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head><meta charset="utf-8"><title>${this._escapeHtml(invoice.invoice_number)}</title></head>
|
<head><meta charset="utf-8"><title>${this._escapeHtml(invoice.invoice_number)}</title></head>
|
||||||
<body>
|
<body>
|
||||||
<h2>${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}</h2>
|
<h2>${isDe ? 'Rechnung' : 'Invoice'} ${this._escapeHtml(invoice.invoice_number)}</h2>
|
||||||
<p>${isDe ? 'Kunde' : 'Customer'}: ${this._escapeHtml(customerName)}</p>
|
<p>${isDe ? 'Kunde' : 'Customer'}: ${variables.customerName}</p>
|
||||||
<p>${isDe ? 'Datum' : 'Date'}: ${this._escapeHtml(issuedAt)}</p>
|
<p>${isDe ? 'Datum' : 'Date'}: ${variables.issuedAt}</p>
|
||||||
<h3>${isDe ? 'Positionen' : 'Items'}</h3>
|
<h3>${isDe ? 'Positionen' : 'Items'}</h3>
|
||||||
<ul>${this._buildItemsHtml(items, invoice.currency)}</ul>
|
<ul>${variables.itemsHtml}</ul>
|
||||||
<p><strong>${isDe ? 'Gesamtbetrag' : 'Total'}: ${this._escapeHtml(this._formatAmount(invoice.total_gross, invoice.currency))}</strong></p>
|
<p><strong>${isDe ? 'Gesamtbetrag' : 'Total'}: ${variables.totalGross}</strong></p>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@ -236,29 +358,17 @@ class InvoiceService {
|
|||||||
const text = this._buildInvoiceMailText({ invoice, items, abonement, lang });
|
const text = this._buildInvoiceMailText({ invoice, items, abonement, lang });
|
||||||
const subject = this._getEmailSubject(lang, invoice.invoice_number);
|
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 });
|
const templateHtml = await this._loadInvoiceTemplateHtml({ userType, lang });
|
||||||
let html = null;
|
let html = null;
|
||||||
|
|
||||||
if (templateHtml) {
|
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);
|
html = this._renderTemplate(templateHtml, variables);
|
||||||
|
|
||||||
if (html && !html.includes('<li>')) {
|
|
||||||
html += `<hr><h3>${lang === 'de' ? 'Positionen' : 'Items'}</h3><ul>${variables.itemsHtml}</ul>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlForPdf = html || this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });
|
const htmlForPdf = html || await this._buildFallbackInvoiceHtml({ invoice, items, abonement, lang });
|
||||||
const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf);
|
const pdfBuffer = await this._renderPdfFromHtml(htmlForPdf);
|
||||||
await this._storeInvoicePdf(invoice, pdfBuffer);
|
await this._storeInvoicePdf(invoice, pdfBuffer);
|
||||||
|
|
||||||
|
|||||||
@ -382,7 +382,24 @@ class ReferralService {
|
|||||||
const repo = new ReferralTokenRepository(unitOfWork);
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
const raw = await repo.getReferrerInfoByToken(token);
|
const raw = await repo.getReferrerInfoByToken(token);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
logger.warn('ReferralService:getReferrerInfo:not_found', { token });
|
// Diagnostic: check if token exists at all (without JOIN)
|
||||||
|
try {
|
||||||
|
const conn = unitOfWork.connection;
|
||||||
|
const [diag] = await conn.query(
|
||||||
|
'SELECT id, token, status, created_by_user_id, expires_at, max_uses, uses_remaining FROM referral_tokens WHERE token = ? LIMIT 1',
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
if (diag.length) {
|
||||||
|
logger.warn('ReferralService:getReferrerInfo:token_exists_but_join_failed', {
|
||||||
|
token, tokenId: diag[0].id, created_by_user_id: diag[0].created_by_user_id,
|
||||||
|
status: diag[0].status, expires_at: diag[0].expires_at
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn('ReferralService:getReferrerInfo:token_truly_not_in_db', { token });
|
||||||
|
}
|
||||||
|
} catch (diagErr) {
|
||||||
|
logger.warn('ReferralService:getReferrerInfo:diagnostic_query_failed', { error: diagErr.message });
|
||||||
|
}
|
||||||
return { valid: false, reason: 'not_found' };
|
return { valid: false, reason: 'not_found' };
|
||||||
}
|
}
|
||||||
const evalResult = this.evaluateTokenRecord(raw);
|
const evalResult = this.evaluateTokenRecord(raw);
|
||||||
|
|||||||
112
services/user/guest/GuestUserService.js
Normal file
112
services/user/guest/GuestUserService.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
const PersonalUserRepository = require('../../../repositories/user/personal/PersonalUserRepository');
|
||||||
|
const UserStatusService = require('../../status/UserStatusService');
|
||||||
|
const UnitOfWork = require('../../../database/UnitOfWork');
|
||||||
|
const MailService = require('../../email/MailService');
|
||||||
|
const AbonemmentService = require('../../abonemments/AbonemmentService');
|
||||||
|
const User = require('../../../models/User');
|
||||||
|
const { logger } = require('../../../middleware/logger');
|
||||||
|
|
||||||
|
const abonemmentService = new AbonemmentService();
|
||||||
|
|
||||||
|
class GuestUserService {
|
||||||
|
/**
|
||||||
|
* Create a guest user account with role='guest'.
|
||||||
|
* Guest users only have access to subscriptions, nothing else.
|
||||||
|
*/
|
||||||
|
static async createGuestUser({ email, password, firstName, lastName, referralEmail, lang }) {
|
||||||
|
logger.info('GuestUserService.createGuestUser:start', { email, firstName, lastName });
|
||||||
|
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('personalUser', new PersonalUserRepository(unitOfWork));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const personalRepo = unitOfWork.getRepository('personalUser');
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existing = await personalRepo.findByEmail(email);
|
||||||
|
if (existing) {
|
||||||
|
await unitOfWork.rollback();
|
||||||
|
throw new Error('User already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user in DB with role='guest'
|
||||||
|
const conn = unitOfWork.connection;
|
||||||
|
const hashedPassword = await User.hashPassword(password);
|
||||||
|
|
||||||
|
const [userResult] = await conn.query(
|
||||||
|
`INSERT INTO users (email, password, user_type, role) VALUES (?, ?, 'personal', 'guest')`,
|
||||||
|
[email, hashedPassword]
|
||||||
|
);
|
||||||
|
const userId = userResult.insertId;
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO personal_profiles (user_id, first_name, last_name) VALUES (?, ?, ?)`,
|
||||||
|
[userId, firstName, lastName]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize user status as active (skip full registration flow for guests)
|
||||||
|
await UserStatusService.initializeUserStatus(userId, 'personal', unitOfWork, 'active');
|
||||||
|
|
||||||
|
// Mark email as verified and profile as completed for guests
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE user_status SET email_verified = TRUE, profile_completed = TRUE, registration_completed = TRUE WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle referral if provided
|
||||||
|
if (referralEmail) {
|
||||||
|
try {
|
||||||
|
const ReferralService = require('../../referral/ReferralService');
|
||||||
|
await ReferralService.processReferral(userId, referralEmail, unitOfWork);
|
||||||
|
} catch (refErr) {
|
||||||
|
logger.warn('GuestUserService.createGuestUser:referral_failed', { userId, error: refErr?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link any pending gift subscriptions to this user
|
||||||
|
await abonemmentService.linkGiftFlagsToUser(email, userId);
|
||||||
|
|
||||||
|
// Send a welcome email
|
||||||
|
const chosenLang = lang || 'en';
|
||||||
|
await MailService.sendRegistrationEmail({
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
userType: 'personal',
|
||||||
|
lang: chosenLang,
|
||||||
|
});
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
|
||||||
|
logger.info('GuestUserService.createGuestUser:success', { userId, email });
|
||||||
|
return { id: userId, email, firstName, lastName, role: 'guest' };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('GuestUserService.createGuestUser:error', { email, error: error.message });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findGuestByEmail(email) {
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const conn = unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT u.id, u.email, u.role, pp.first_name, pp.last_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
WHERE u.email = ? AND u.role = 'guest' LIMIT 1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
return rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GuestUserService;
|
||||||
Loading…
Reference in New Issue
Block a user