Initial Commit
This commit is contained in:
commit
e2e0204c12
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Optional: ignore database dumps/backups
|
||||||
|
*.sql
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Optional: ignore mailgun test files
|
||||||
|
mailgun.json
|
||||||
|
|
||||||
|
# Optional: ignore temp files
|
||||||
|
tmp/
|
||||||
|
*.tmp
|
||||||
|
ca.pem
|
||||||
115
controller/admin/AdminUserController.js
Normal file
115
controller/admin/AdminUserController.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const AdminService = require('../../services/AdminService');
|
||||||
|
|
||||||
|
class AdminUserController {
|
||||||
|
static async getUserStats(req, res) {
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const stats = await AdminService.getUserStats(unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, stats });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserList(req, res) {
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const users = await AdminService.getUserList(unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, users });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getVerificationPendingUsers(req, res) {
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const users = await AdminService.getVerificationPendingUsers(unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, users });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyUser(req, res) {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { permissions = [] } = req.body;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const result = await AdminService.verifyUser(unitOfWork, userId, permissions);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFullUserAccountDetails(req, res) {
|
||||||
|
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'super_admin')) {
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||||
|
}
|
||||||
|
const userId = req.params.id;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const details = await AdminService.getFullUserAccountDetails(unitOfWork, userId);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, ...details });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateUserPermissions(req, res) {
|
||||||
|
const userId = Number(req.params.id);
|
||||||
|
const permissions = req.body.permissions;
|
||||||
|
if (!Array.isArray(permissions) || permissions.some(p => typeof p !== 'string')) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid permissions format.' });
|
||||||
|
}
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden: Admins only.' });
|
||||||
|
}
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
await AdminService.updateUserPermissions(unitOfWork, userId, permissions);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
return res.json({ success: true, message: 'Permissions updated.' });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteUser(req, res) {
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden: Admins only.' });
|
||||||
|
}
|
||||||
|
const userId = req.params.id;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
await AdminService.deleteUser(unitOfWork, userId);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, message: 'User deleted.' });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AdminUserController;
|
||||||
16
controller/admin/ServerStatusController.js
Normal file
16
controller/admin/ServerStatusController.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const pidusage = require('pidusage');
|
||||||
|
const os = require('os');
|
||||||
|
const AdminService = require('../../services/AdminService');
|
||||||
|
|
||||||
|
class ServerStatusController {
|
||||||
|
static async getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const status = await AdminService.getServerStatus();
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ status: 'Offline', error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ServerStatusController;
|
||||||
53
controller/auth/EmailVerificationController.js
Normal file
53
controller/auth/EmailVerificationController.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const EmailVerificationRepository = require('../../repositories/EmailVerificationRepository');
|
||||||
|
const EmailVerificationService = require('../../services/EmailVerificationService');
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
class EmailVerificationController {
|
||||||
|
static async sendVerificationEmail(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const emailVerificationRepo = new EmailVerificationRepository(unitOfWork);
|
||||||
|
const userRecord = await emailVerificationRepo.getUserBasic(userId);
|
||||||
|
if (!userRecord) {
|
||||||
|
await unitOfWork.rollback();
|
||||||
|
logger.warn(`User not found for email verification: ${userId}`);
|
||||||
|
return res.status(404).json({ success: false, message: 'User not found' });
|
||||||
|
}
|
||||||
|
await EmailVerificationService.sendVerificationEmail(userRecord, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`Verification email sent to user ${userId}`);
|
||||||
|
res.json({ success: true, message: 'Verification email sent' });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('Error sending verification email:', error);
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyEmailCode(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { code } = req.body;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const result = await EmailVerificationService.verifyCode(userId, code, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`Email verified for user ${userId}`);
|
||||||
|
return res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
logger.warn(`Failed email verification for user ${userId}: ${result.error}`);
|
||||||
|
return res.status(400).json({ success: false, error: result.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('Error verifying email code:', error);
|
||||||
|
res.status(400).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = EmailVerificationController;
|
||||||
231
controller/auth/LoginController.js
Normal file
231
controller/auth/LoginController.js
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
const LoginService = require('../../services/LoginService');
|
||||||
|
const MailService = require('../../services/MailService');
|
||||||
|
const { checkAndIncrementRateLimit } = require('../../middleware/rateLimiter');
|
||||||
|
const { logger } = require('../../middleware/logger'); // <-- import logger
|
||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const db = require('../../database/database'); // Use singleton pool
|
||||||
|
|
||||||
|
class LoginController {
|
||||||
|
static async login(req, res) {
|
||||||
|
try {
|
||||||
|
const { email, password, lang } = req.body;
|
||||||
|
logger.info('[LOGIN][REQ] Received lang from frontend', { lang });
|
||||||
|
|
||||||
|
// Step 1: Check rate limit BEFORE processing credentials
|
||||||
|
logger.info('[LOGIN][RATE LIMIT] Checking rate limit for IP', { ip: req.ip });
|
||||||
|
const rateLimitExceeded = await checkAndIncrementRateLimit({
|
||||||
|
rateKey: `login:${req.ip}`,
|
||||||
|
max: 10,
|
||||||
|
windowSeconds: 600
|
||||||
|
}, null); // null for res, just check, don't send response
|
||||||
|
|
||||||
|
if (rateLimitExceeded) {
|
||||||
|
logger.warn('[RATE LIMIT] Blocked login attempt', { ip: req.ip, time: new Date() });
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Rate limit exceeded. Please try again later.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let result;
|
||||||
|
const uow = new UnitOfWork(); // No need to pass pool
|
||||||
|
try {
|
||||||
|
// Step 3: If not blocked, check credentials
|
||||||
|
logger.info('[LOGIN][AUTH] Checking credentials', { email });
|
||||||
|
result = await LoginService.login(email, password);
|
||||||
|
logger.info('[LOGIN][AUTH] Credentials valid', { email });
|
||||||
|
} catch (error) {
|
||||||
|
// Step 4: If credentials invalid, increment rate limit
|
||||||
|
logger.warn('[LOGIN][AUTH] Invalid credentials', { email, ip: req.ip });
|
||||||
|
await checkAndIncrementRateLimit({
|
||||||
|
rateKey: `login:${req.ip}`,
|
||||||
|
max: 10,
|
||||||
|
windowSeconds: 600
|
||||||
|
}, res); // increment and send response if needed
|
||||||
|
// If rate limit exceeded, response is already sent
|
||||||
|
if (res.headersSent) {
|
||||||
|
logger.warn('[RATE LIMIT] Rollback after failed login attempt', { ip: req.ip, time: new Date() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error.status) {
|
||||||
|
logger.warn('[AUTH] Rollback due to invalid credentials', { email, ip: req.ip, time: new Date() });
|
||||||
|
return res.status(error.status).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
logger.error('Login error', { error });
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: If credentials valid, allow login (do NOT increment rate limit)
|
||||||
|
// Set refresh token as HTTP-only cookie
|
||||||
|
res.cookie('refreshToken', result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
expires: result.refreshTokenExpires
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Send login notification email ---
|
||||||
|
try {
|
||||||
|
await MailService.sendLoginNotificationEmail({
|
||||||
|
email: result.user.email,
|
||||||
|
ip: req.ip,
|
||||||
|
loginTime: new Date(),
|
||||||
|
userAgent: req.headers['user-agent'] || '',
|
||||||
|
lang: lang // <-- pass lang from request body
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
logger.error('Error sending login notification email', { error: mailError });
|
||||||
|
// Do not block login
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[LOGIN][SUCCESS] Login successful', { email, ip: req.ip });
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
user: result.user
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Should not reach here, but fallback
|
||||||
|
logger.error('Login error (outer catch)', { error });
|
||||||
|
if (error && error.stack) {
|
||||||
|
logger.error('Error stack', { stack: error.stack });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async refresh(req, res) {
|
||||||
|
try {
|
||||||
|
const refreshToken = req.cookies.refreshToken;
|
||||||
|
if (!refreshToken) {
|
||||||
|
logger.warn('No refresh token provided');
|
||||||
|
return res.status(401).json({ success: false, message: 'No refresh token provided' });
|
||||||
|
}
|
||||||
|
const result = await LoginService.refresh(refreshToken);
|
||||||
|
|
||||||
|
// FIX: Set new refresh token as HTTP-only cookie after rotation
|
||||||
|
res.cookie('refreshToken', result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
expires: result.refreshTokenExpires
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Refresh token rotated', { userId: result.user.id });
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
user: result.user
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status) {
|
||||||
|
logger.warn('Refresh error', { error });
|
||||||
|
return res.status(error.status).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
logger.error('Refresh error', { error });
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async logout(req, res) {
|
||||||
|
try {
|
||||||
|
// Very detailed request-level logs
|
||||||
|
logger.info('[LOGOUT][REQ] Logout request received', {
|
||||||
|
ip: req.ip,
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
cookieKeys: Object.keys(req.cookies || {})
|
||||||
|
});
|
||||||
|
|
||||||
|
// helper to mask tokens in logs (avoid leaking full token)
|
||||||
|
const mask = (s) => {
|
||||||
|
if (!s || typeof s !== 'string') return null;
|
||||||
|
if (s.length <= 12) return `${s.slice(0, 3)}...${s.slice(-3)}`;
|
||||||
|
return `${s.slice(0, 6)}...${s.slice(-6)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshToken = req.cookies.refreshToken;
|
||||||
|
logger.debug('[LOGOUT] refreshToken presence', { present: !!refreshToken, tokenMasked: mask(refreshToken) });
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
logger.warn('[LOGOUT] No refresh token provided for logout; still attempting to clear cookie on backend');
|
||||||
|
|
||||||
|
// Try to set expired cookie header explicitly (backend-only for httpOnly)
|
||||||
|
try {
|
||||||
|
res.cookie('refreshToken', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/',
|
||||||
|
expires: new Date(0)
|
||||||
|
});
|
||||||
|
const setCookieHeader = res.getHeader && res.getHeader('Set-Cookie');
|
||||||
|
logger.info('[LOGOUT] Set-Cookie header after expiring (no token)', { setCookieHeader });
|
||||||
|
} catch (cookieErr) {
|
||||||
|
logger.error('[LOGOUT] Error while setting expired cookie (no token)', { error: cookieErr });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.clearCookie('refreshToken', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
logger.info('[LOGOUT] res.clearCookie called (no token)');
|
||||||
|
} catch (clearErr) {
|
||||||
|
logger.error('[LOGOUT] Error while calling res.clearCookie (no token)', { error: clearErr });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[LOGOUT] Response headersSent (no token)', { headersSent: !!res.headersSent });
|
||||||
|
return res.status(400).json({ success: false, message: 'No refresh token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[LOGOUT] Calling LoginService.logout', { tokenMasked: mask(refreshToken), ip: req.ip });
|
||||||
|
try {
|
||||||
|
await LoginService.logout(refreshToken);
|
||||||
|
logger.info('[LOGOUT] LoginService.logout completed', { ip: req.ip });
|
||||||
|
} catch (serviceErr) {
|
||||||
|
logger.error('[LOGOUT] LoginService.logout failed', { error: serviceErr });
|
||||||
|
// continue to attempt to clear cookie even if service logout fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly expire the refresh token cookie
|
||||||
|
try {
|
||||||
|
res.cookie('refreshToken', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/',
|
||||||
|
expires: new Date(0)
|
||||||
|
});
|
||||||
|
const setCookieHeader = res.getHeader && res.getHeader('Set-Cookie');
|
||||||
|
logger.info('[LOGOUT] Set-Cookie header after expiring cookie', { setCookieHeader });
|
||||||
|
} catch (cookieErr) {
|
||||||
|
logger.error('[LOGOUT] Error while setting expired cookie', { error: cookieErr });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the cookie as well
|
||||||
|
try {
|
||||||
|
res.clearCookie('refreshToken', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
logger.info('[LOGOUT] res.clearCookie called', { headersSent: !!res.headersSent });
|
||||||
|
} catch (clearErr) {
|
||||||
|
logger.error('[LOGOUT] Error while calling res.clearCookie', { error: clearErr });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[LOGOUT][SUCCESS] Logged out successfully (response about to be sent)', { ip: req.ip });
|
||||||
|
return res.status(200).json({ success: true, message: 'Logged out successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[LOGOUT][ERROR] Unexpected logout error', { error, stack: error && error.stack });
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LoginController;
|
||||||
189
controller/auth/UserController.js
Normal file
189
controller/auth/UserController.js
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const UserRepository = require('../../repositories/UserRepository');
|
||||||
|
const { s3 } = require('../../utils/exoscaleUploader');
|
||||||
|
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
const { logger } = require('../../middleware/logger'); // fixed import
|
||||||
|
|
||||||
|
class UserController {
|
||||||
|
static async getMe(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
logger.info(`[UserController] getMe called for userId: ${userId}`);
|
||||||
|
const userRepo = new UserRepository(unitOfWork);
|
||||||
|
const user = await userRepo.findUserByEmailOrId(userId);
|
||||||
|
if (!user) {
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.warn(`[UserController] User not found: ${userId}`);
|
||||||
|
return res.status(404).json({ success: false, message: 'User not found' });
|
||||||
|
}
|
||||||
|
const iban = await userRepo.getIban(userId);
|
||||||
|
const profile = await userRepo.getProfile(userId, user.userType);
|
||||||
|
const referralEmail = await userRepo.getReferralEmail(userId, user.userType);
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`[UserController] getMe success for userId: ${userId}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
user: { ...user.getPublicData(), iban, referralEmail },
|
||||||
|
profile
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error(`[UserController] getMe error for userId: ${userId}`, { error });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFullUserData(req, res) {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
logger.info(`[UserController] getFullUserData called for userId: ${userId}`);
|
||||||
|
const userRepo = new UserRepository(unitOfWork);
|
||||||
|
const user = await userRepo.findUserByEmailOrId(Number(userId));
|
||||||
|
if (!user) {
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.warn(`[UserController] User not found: ${userId}`);
|
||||||
|
return res.status(404).json({ success: false, message: 'User not found' });
|
||||||
|
}
|
||||||
|
const iban = await userRepo.getIban(userId);
|
||||||
|
const profile = await userRepo.getProfile(userId, user.userType);
|
||||||
|
const referralEmail = await userRepo.getReferralEmail(userId, user.userType);
|
||||||
|
const permissions = await userRepo.getPermissions(userId);
|
||||||
|
const userStatus = await userRepo.getUserStatus(userId);
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`[UserController] getFullUserData success for userId: ${userId}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
user: { ...user.getPublicData(), referralEmail, iban },
|
||||||
|
profile,
|
||||||
|
permissions,
|
||||||
|
userStatus
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error(`[UserController] getFullUserData error for userId: ${userId}`, { error });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserDocumentsAndContracts(req, res) {
|
||||||
|
const requestedUserId = Number(req.params.id);
|
||||||
|
const requesterUserId = req.user.userId;
|
||||||
|
const requesterRole = req.user.role;
|
||||||
|
|
||||||
|
if (requestedUserId !== requesterUserId && requesterRole !== 'admin' && requesterRole !== 'super_admin') {
|
||||||
|
logger.warn(`[UserController] Forbidden access to documents for userId: ${requestedUserId} by userId: ${requesterUserId}`);
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
logger.info(`[UserController] getUserDocumentsAndContracts called for userId: ${requestedUserId}`);
|
||||||
|
const userRepo = new UserRepository(unitOfWork);
|
||||||
|
|
||||||
|
// Use repository methods instead of direct queries
|
||||||
|
const contracts = await userRepo.getContracts(requestedUserId);
|
||||||
|
const idDocs = await userRepo.getIdDocuments(requestedUserId);
|
||||||
|
|
||||||
|
// Signed URLs for contracts (no Content-Disposition header)
|
||||||
|
const contractsWithUrls = await Promise.all(
|
||||||
|
contracts.map(async doc => {
|
||||||
|
let signedUrl = null;
|
||||||
|
if (doc.object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.object_storage_id
|
||||||
|
});
|
||||||
|
signedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
} catch (err) {
|
||||||
|
signedUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...doc, signedUrl };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Signed URLs for front/back of ID documents (with Content-Disposition header)
|
||||||
|
const idDocFiles = await Promise.all(
|
||||||
|
idDocs.flatMap(doc => [
|
||||||
|
(async () => {
|
||||||
|
let signedUrl = null;
|
||||||
|
if (doc.front_object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.front_object_storage_id,
|
||||||
|
ResponseContentDisposition: `attachment; filename="${doc.original_filename_front}"`
|
||||||
|
});
|
||||||
|
signedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
} catch (err) {
|
||||||
|
signedUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user_id_document_id: doc.id,
|
||||||
|
user_id: doc.user_id,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
side: 'front',
|
||||||
|
object_storage_id: doc.front_object_storage_id,
|
||||||
|
signedUrl,
|
||||||
|
id_type: doc.id_type,
|
||||||
|
id_number: doc.id_number,
|
||||||
|
expiry_date: doc.expiry_date,
|
||||||
|
original_filename: doc.original_filename_front
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(async () => {
|
||||||
|
let signedUrl = null;
|
||||||
|
if (doc.back_object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.back_object_storage_id,
|
||||||
|
ResponseContentDisposition: `attachment; filename="${doc.original_filename_back}"`
|
||||||
|
});
|
||||||
|
signedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
} catch (err) {
|
||||||
|
signedUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user_id_document_id: doc.id,
|
||||||
|
user_id: doc.user_id,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
side: 'back',
|
||||||
|
object_storage_id: doc.back_object_storage_id,
|
||||||
|
signedUrl,
|
||||||
|
id_type: doc.id_type,
|
||||||
|
id_number: doc.id_number,
|
||||||
|
expiry_date: doc.expiry_date,
|
||||||
|
original_filename: doc.original_filename_back
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`[UserController] getUserDocumentsAndContracts success for userId: ${requestedUserId}`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
contracts: contractsWithUrls,
|
||||||
|
idDocuments: idDocFiles
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error(`[UserController] getUserDocumentsAndContracts error for userId: ${requestedUserId}`, { error });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserController;
|
||||||
29
controller/auth/UserSettingsController.js
Normal file
29
controller/auth/UserSettingsController.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const UserSettingsRepository = require('../../repositories/UserSettingsRepository');
|
||||||
|
const { logger } = require('../../middleware/logger'); // <-- import logger
|
||||||
|
|
||||||
|
class UserSettingsController {
|
||||||
|
static async getSettings(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info(`[UserSettingsController] getSettings called for userId: ${userId}`);
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const repo = new UserSettingsRepository(unitOfWork);
|
||||||
|
const settings = await repo.getSettingsByUserId(userId);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
if (!settings) {
|
||||||
|
logger.warn(`[UserSettingsController] No settings found for userId: ${userId}`);
|
||||||
|
return res.status(404).json({ success: false, message: 'Settings not found' });
|
||||||
|
}
|
||||||
|
logger.info(`[UserSettingsController] getSettings success for userId: ${userId}`);
|
||||||
|
res.json({ success: true, settings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[UserSettingsController] getSettings error for userId: ${userId}`, { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserSettingsController;
|
||||||
54
controller/auth/UserStatusController.js
Normal file
54
controller/auth/UserStatusController.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const UserStatusRepository = require('../../repositories/UserStatusRepository');
|
||||||
|
const UserStatusService = require('../../services/UserStatusService'); // Add this import
|
||||||
|
const { logger } = require('../../middleware/logger'); // <-- import logger
|
||||||
|
|
||||||
|
class UserStatusController {
|
||||||
|
static async getStatus(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info(`[UserStatusController] getStatus called for userId: ${userId}`);
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('userStatus', new UserStatusRepository(unitOfWork));
|
||||||
|
try {
|
||||||
|
const userStatusRepo = unitOfWork.getRepository('userStatus');
|
||||||
|
const status = await userStatusRepo.getStatusByUserId(userId);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
if (!status) {
|
||||||
|
logger.warn(`[UserStatusController] No status found for userId: ${userId}`);
|
||||||
|
return res.status(404).json({ success: false, message: 'User status not found' });
|
||||||
|
}
|
||||||
|
logger.info(`[UserStatusController] getStatus success for userId: ${userId}`);
|
||||||
|
return res.json({ success: true, status });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[UserStatusController] getStatus error for userId: ${userId}`, { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStatusProgress(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info(`[UserStatusController] getStatusProgress called for userId: ${userId}`);
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('userStatus', new UserStatusRepository(unitOfWork));
|
||||||
|
try {
|
||||||
|
// Use service to get progress details
|
||||||
|
const progress = await UserStatusService.getStatusProgress(userId, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
if (!progress) {
|
||||||
|
logger.warn(`[UserStatusController] No status found for userId: ${userId}`);
|
||||||
|
return res.status(404).json({ success: false, message: 'User status not found' });
|
||||||
|
}
|
||||||
|
logger.info(`[UserStatusController] getStatusProgress success for userId: ${userId}`);
|
||||||
|
return res.json({ success: true, progress });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[UserStatusController] getStatusProgress error for userId: ${userId}`, { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserStatusController;
|
||||||
72
controller/companyStamp/CompanyStampController.js
Normal file
72
controller/companyStamp/CompanyStampController.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const CompanyStampService = require('../../services/CompanyStampService');
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
exports.upload = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { base64, mimeType, label, activate } = req.body || {};
|
||||||
|
if (!base64) return res.status(400).json({ error: 'Missing base64' });
|
||||||
|
const stamp = await CompanyStampService.uploadStamp({
|
||||||
|
user: req.user,
|
||||||
|
base64,
|
||||||
|
mimeType,
|
||||||
|
label,
|
||||||
|
activate: !!activate
|
||||||
|
});
|
||||||
|
res.status(201).json(stamp);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'PRIMARY_STAMP_EXISTS') {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: e.message,
|
||||||
|
existing: e.existing
|
||||||
|
? {
|
||||||
|
id: e.existing.id,
|
||||||
|
mime_type: e.existing.mime_type,
|
||||||
|
label: e.existing.label,
|
||||||
|
created_at: e.existing.created_at,
|
||||||
|
is_active: e.existing.is_active,
|
||||||
|
previewDataUri: `data:${e.existing.mime_type};base64,${e.existing.image_base64}`
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.error('CompanyStampController.upload:error', e.message);
|
||||||
|
res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.listMine = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const list = await CompanyStampService.listCompanyStamps(req.user);
|
||||||
|
res.json(list);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(403).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.activeMine = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stamp = await CompanyStampService.getMyActive(req.user);
|
||||||
|
res.json(stamp || null);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(403).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.activate = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const updated = await CompanyStampService.activateStamp(req.params.id, req.user);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(e.message === 'Not found' ? 404 : 403).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.delete = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ok = await CompanyStampService.deleteStamp(req.params.id, req.user);
|
||||||
|
if (!ok) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(403).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
1350
controller/documentTemplate/DocumentTemplateController.js
Normal file
1350
controller/documentTemplate/DocumentTemplateController.js
Normal file
File diff suppressed because it is too large
Load Diff
36
controller/documents/CompanyDocumentController.js
Normal file
36
controller/documents/CompanyDocumentController.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const CompanyDocumentService = require('../../services/CompanyDocumentService');
|
||||||
|
const { logger } = require('../../middleware/logger'); // <-- import logger
|
||||||
|
|
||||||
|
class CompanyDocumentController {
|
||||||
|
static async uploadCompanyId(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info(`[CompanyDocumentController] uploadCompanyId called for userId: ${userId}`);
|
||||||
|
const { idType, idNumber, expiryDate } = req.body;
|
||||||
|
const files = {
|
||||||
|
front: req.files['front'] ? req.files['front'][0] : null,
|
||||||
|
back: req.files['back'] ? req.files['back'][0] : null
|
||||||
|
};
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const result = await CompanyDocumentService.uploadCompanyId({
|
||||||
|
userId,
|
||||||
|
idType,
|
||||||
|
idNumber,
|
||||||
|
expiryDate,
|
||||||
|
files,
|
||||||
|
unitOfWork
|
||||||
|
});
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`[CompanyDocumentController] uploadCompanyId success for userId: ${userId}`);
|
||||||
|
res.json({ success: true, uploads: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[CompanyDocumentController] uploadCompanyId error for userId: ${userId}`, { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyDocumentController;
|
||||||
64
controller/documents/ContractUploadController.js
Normal file
64
controller/documents/ContractUploadController.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const ContractUploadService = require('../../services/ContractUploadService');
|
||||||
|
const { logger } = require('../../middleware/logger'); // <-- import logger
|
||||||
|
|
||||||
|
class ContractUploadController {
|
||||||
|
static async uploadPersonalContract(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info(`[ContractUploadController] uploadPersonalContract called for userId: ${userId}`);
|
||||||
|
const file = req.file;
|
||||||
|
// Accept contractData and signatureImage from body (JSON or multipart)
|
||||||
|
const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined;
|
||||||
|
const signatureImage = req.body.signatureImage; // base64 string or Buffer
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const result = await ContractUploadService.uploadContract({
|
||||||
|
userId,
|
||||||
|
file,
|
||||||
|
documentType: 'contract',
|
||||||
|
contractCategory: 'personal',
|
||||||
|
unitOfWork,
|
||||||
|
contractData,
|
||||||
|
signatureImage
|
||||||
|
});
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`[ContractUploadController] uploadPersonalContract success for userId: ${userId}`);
|
||||||
|
res.json({ success: true, upload: result, downloadUrl: result.url || null });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[ContractUploadController] uploadPersonalContract error for userId: ${userId}`, { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async uploadCompanyContract(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info(`[ContractUploadController] uploadCompanyContract called for userId: ${userId}`);
|
||||||
|
const file = req.file;
|
||||||
|
const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined;
|
||||||
|
const signatureImage = req.body.signatureImage;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const result = await ContractUploadService.uploadContract({
|
||||||
|
userId,
|
||||||
|
file,
|
||||||
|
documentType: 'contract',
|
||||||
|
contractCategory: 'company',
|
||||||
|
unitOfWork,
|
||||||
|
contractData,
|
||||||
|
signatureImage
|
||||||
|
});
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`[ContractUploadController] uploadCompanyContract success for userId: ${userId}`);
|
||||||
|
res.json({ success: true, upload: result, downloadUrl: result.url || null });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[ContractUploadController] uploadCompanyContract error for userId: ${userId}`, { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ContractUploadController;
|
||||||
36
controller/documents/PersonalDocumentController.js
Normal file
36
controller/documents/PersonalDocumentController.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const PersonalDocumentService = require('../../services/PersonalDocumentService');
|
||||||
|
const { logger } = require('../../middleware/logger'); // <-- import logger
|
||||||
|
|
||||||
|
class PersonalDocumentController {
|
||||||
|
static async uploadPersonalId(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info(`[PersonalDocumentController] uploadPersonalId called for userId: ${userId}`);
|
||||||
|
const { idType, idNumber, expiryDate } = req.body;
|
||||||
|
const files = {
|
||||||
|
front: req.files['front'] ? req.files['front'][0] : null,
|
||||||
|
back: req.files['back'] ? req.files['back'][0] : null
|
||||||
|
};
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const result = await PersonalDocumentService.uploadPersonalId({
|
||||||
|
userId,
|
||||||
|
idType,
|
||||||
|
idNumber,
|
||||||
|
expiryDate,
|
||||||
|
files,
|
||||||
|
unitOfWork
|
||||||
|
});
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info(`[PersonalDocumentController] uploadPersonalId success for userId: ${userId}`);
|
||||||
|
res.json({ success: true, uploads: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[PersonalDocumentController] uploadPersonalId error for userId: ${userId}`, { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalDocumentController;
|
||||||
121
controller/documents/UserDocumentController.js
Normal file
121
controller/documents/UserDocumentController.js
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const UserDocumentRepository = require('../../repositories/UserDocumentRepository');
|
||||||
|
const { s3 } = require('../../utils/exoscaleUploader');
|
||||||
|
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
class UserDocumentController {
|
||||||
|
static async getAllDocumentsForUser(req, res) {
|
||||||
|
const userId = req.params.id;
|
||||||
|
logger.info(`[UserDocumentController] Fetching all documents for userId: ${userId}`);
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const repo = new UserDocumentRepository(unitOfWork);
|
||||||
|
|
||||||
|
// Use repository methods for queries
|
||||||
|
const documents = await repo.getDocumentsForUser(userId);
|
||||||
|
logger.info(`[UserDocumentController] Found ${documents.length} user_documents for userId: ${userId}`);
|
||||||
|
|
||||||
|
const idDocs = await repo.getIdDocumentsForUser(userId);
|
||||||
|
logger.info(`[UserDocumentController] Found ${idDocs.length} user_id_documents for userId: ${userId}`);
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
|
||||||
|
// Generate signed URLs for each document in user_documents
|
||||||
|
const signedDocuments = await Promise.all(
|
||||||
|
documents.map(async doc => {
|
||||||
|
let signedUrl = null;
|
||||||
|
if (doc.object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.object_storage_id
|
||||||
|
});
|
||||||
|
signedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
logger.info(`[UserDocumentController] Generated signedUrl for object_storage_id: ${doc.object_storage_id}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[UserDocumentController] Failed to generate signedUrl for object_storage_id: ${doc.object_storage_id}`, { error: err });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`[UserDocumentController] No object_storage_id for document id: ${doc.id}`);
|
||||||
|
}
|
||||||
|
return { ...doc, signedUrl };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate signed URLs for front and back of each ID document in user_id_documents
|
||||||
|
const idDocFiles = await Promise.all(
|
||||||
|
idDocs.flatMap(doc => [
|
||||||
|
(async () => {
|
||||||
|
let signedUrl = null;
|
||||||
|
if (doc.front_object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.front_object_storage_id
|
||||||
|
});
|
||||||
|
signedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
logger.info(`[UserDocumentController] Generated signedUrl for front_object_storage_id: ${doc.front_object_storage_id}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[UserDocumentController] Failed to generate signedUrl for front_object_storage_id: ${doc.front_object_storage_id}`, { error: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user_id_document_id: doc.id,
|
||||||
|
user_id: doc.user_id,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
side: 'front',
|
||||||
|
object_storage_id: doc.front_object_storage_id,
|
||||||
|
signedUrl,
|
||||||
|
id_type: doc.id_type,
|
||||||
|
id_number: doc.id_number,
|
||||||
|
expiry_date: doc.expiry_date,
|
||||||
|
original_filename: doc.original_filename_front
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
(async () => {
|
||||||
|
let signedUrl = null;
|
||||||
|
if (doc.back_object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.back_object_storage_id
|
||||||
|
});
|
||||||
|
signedUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
logger.info(`[UserDocumentController] Generated signedUrl for back_object_storage_id: ${doc.back_object_storage_id}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[UserDocumentController] Failed to generate signedUrl for back_object_storage_id: ${doc.back_object_storage_id}`, { error: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user_id_document_id: doc.id,
|
||||||
|
user_id: doc.user_id,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
side: 'back',
|
||||||
|
object_storage_id: doc.back_object_storage_id,
|
||||||
|
signedUrl,
|
||||||
|
id_type: doc.id_type,
|
||||||
|
id_number: doc.id_number,
|
||||||
|
expiry_date: doc.expiry_date,
|
||||||
|
original_filename: doc.original_filename_back
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge user_documents and idDocFiles for a complete list
|
||||||
|
const allDocuments = [...signedDocuments, ...idDocFiles];
|
||||||
|
|
||||||
|
logger.info(`[UserDocumentController] Returning ${allDocuments.length} documents with signed URLs for userId: ${userId}`);
|
||||||
|
res.json({ success: true, documents: allDocuments, idDocuments: idDocs });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[UserDocumentController] Error in getAllDocumentsForUser', { error });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserDocumentController;
|
||||||
138
controller/password-reset/PasswordResetController.js
Normal file
138
controller/password-reset/PasswordResetController.js
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const MailService = require('../../services/MailService');
|
||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const PersonalUserRepository = require('../../repositories/PersonalUserRepository');
|
||||||
|
const CompanyUserRepository = require('../../repositories/CompanyUserRepository');
|
||||||
|
const PasswordResetRepository = require('../../repositories/PasswordResetRepository');
|
||||||
|
const User = require('../../models/User');
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
function generateToken() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async requestPasswordReset(req, res) {
|
||||||
|
// If rate limiter already sent a response, do not continue
|
||||||
|
if (res.headersSent) return;
|
||||||
|
|
||||||
|
const { email } = req.body;
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
logger.warn('passwordReset:invalid_request', { email });
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid request.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always respond generically
|
||||||
|
let user = null, userType = null;
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const personalRepo = new PersonalUserRepository(uow);
|
||||||
|
const companyRepo = new CompanyUserRepository(uow);
|
||||||
|
const passwordResetRepo = new PasswordResetRepository(uow);
|
||||||
|
|
||||||
|
user = await personalRepo.findByEmail(email);
|
||||||
|
userType = user ? 'personal' : null;
|
||||||
|
if (!user) {
|
||||||
|
user = await companyRepo.findByEmail(email);
|
||||||
|
userType = user ? 'company' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
logger.info('passwordReset:user_found', { userId: user.id, userType });
|
||||||
|
const existingToken = await passwordResetRepo.findValidTokenByUserId(user.id);
|
||||||
|
if (existingToken) {
|
||||||
|
await uow.rollback();
|
||||||
|
logger.info('passwordReset:existing_token', { userId: user.id });
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'A password reset link has already been sent and is still valid. Please check your email.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token and expiry
|
||||||
|
const token = generateToken();
|
||||||
|
const expiresAt = new Date(Date.now() + 25 * 60 * 1000);
|
||||||
|
|
||||||
|
// Store in password_resets
|
||||||
|
await passwordResetRepo.createToken(user.id, token, expiresAt);
|
||||||
|
logger.info('passwordReset:token_created', { userId: user.id, token });
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
await MailService.sendPasswordResetEmail({
|
||||||
|
email,
|
||||||
|
firstName: userType === 'personal' ? user.firstName : undefined,
|
||||||
|
companyName: userType === 'company' ? user.companyName : undefined,
|
||||||
|
token,
|
||||||
|
lang: req.body.lang || 'en'
|
||||||
|
});
|
||||||
|
logger.info('passwordReset:email_sent', { email });
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await uow.rollback();
|
||||||
|
logger.error('passwordReset:request_error', { error: err });
|
||||||
|
}
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'If an account exists for this email, a password reset link has been sent.'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyPasswordResetToken(req, res) {
|
||||||
|
const { token } = req.query;
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
logger.warn('passwordReset:invalid_token', { token });
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid token.' });
|
||||||
|
}
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const passwordResetRepo = new PasswordResetRepository(uow);
|
||||||
|
const row = await passwordResetRepo.findValidToken(token);
|
||||||
|
if (!row) {
|
||||||
|
await uow.rollback();
|
||||||
|
logger.warn('passwordReset:token_invalid_or_expired', { token });
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid or expired token.' });
|
||||||
|
}
|
||||||
|
await uow.commit();
|
||||||
|
logger.info('passwordReset:token_valid', { token });
|
||||||
|
return res.json({ success: true, message: 'Token is valid.' });
|
||||||
|
} catch (err) {
|
||||||
|
await uow.rollback();
|
||||||
|
logger.error('passwordReset:verify_error', { error: err });
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetPassword(req, res) {
|
||||||
|
const { token, newPassword } = req.body;
|
||||||
|
if (!token || typeof token !== 'string' || !newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||||
|
logger.warn('passwordReset:invalid_reset_request', { token });
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid request.' });
|
||||||
|
}
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const passwordResetRepo = new PasswordResetRepository(uow);
|
||||||
|
const row = await passwordResetRepo.findValidToken(token);
|
||||||
|
if (!row) {
|
||||||
|
await uow.rollback();
|
||||||
|
logger.warn('passwordReset:token_invalid_or_expired', { token });
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid or expired token.' });
|
||||||
|
}
|
||||||
|
// Update password
|
||||||
|
const hashed = await User.hashPassword(newPassword);
|
||||||
|
await passwordResetRepo.updateUserPassword(row.user_id, hashed);
|
||||||
|
await passwordResetRepo.markTokenUsed(row.id);
|
||||||
|
await uow.commit();
|
||||||
|
logger.info('passwordReset:password_reset', { userId: row.user_id });
|
||||||
|
return res.json({ success: true, message: 'Password has been reset.' });
|
||||||
|
} catch (err) {
|
||||||
|
await uow.rollback();
|
||||||
|
logger.error('passwordReset:reset_error', { error: err });
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
63
controller/permissions/PermissionController.js
Normal file
63
controller/permissions/PermissionController.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const PermissionService = require('../../services/PermissionService');
|
||||||
|
const PermissionRepository = require('../../repositories/PermissionRepository');
|
||||||
|
|
||||||
|
class PermissionController {
|
||||||
|
static async list(req, res) {
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const permissions = await PermissionService.getAllPermissions(unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, permissions });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(req, res) {
|
||||||
|
const { name, description, is_active } = req.body;
|
||||||
|
const userId = req.user.userId; // Get user ID from access token
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Permission name is required' });
|
||||||
|
}
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const permission = await PermissionService.createPermission({ name, description, is_active }, userId, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.status(201).json({ success: true, permission });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserPermissions(req, res) {
|
||||||
|
// Access control: only self or admin/super_admin can view
|
||||||
|
const requestedUserId = Number(req.params.id);
|
||||||
|
const requesterUserId = req.user.userId;
|
||||||
|
const requesterRole = req.user.role;
|
||||||
|
|
||||||
|
if (requestedUserId !== requesterUserId && requesterRole !== 'admin' && requesterRole !== 'super_admin') {
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
// Use PermissionRepository for data access
|
||||||
|
const repo = new PermissionRepository(unitOfWork);
|
||||||
|
const permissions = await repo.getPermissionsByUserId(requestedUserId);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
res.json({ success: true, permissions });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PermissionController;
|
||||||
|
module.exports = PermissionController;
|
||||||
25
controller/profile/CompanyProfileController.js
Normal file
25
controller/profile/CompanyProfileController.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const CompanyProfileService = require('../../services/CompanyProfileService');
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
class CompanyProfileController {
|
||||||
|
static async completeProfile(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const profileData = req.body;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
logger.info('CompanyProfileController:completeProfile:start', { userId, profileData });
|
||||||
|
try {
|
||||||
|
await CompanyProfileService.completeProfile(userId, profileData, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('CompanyProfileController:completeProfile:success', { userId });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('CompanyProfileController:completeProfile:error', { userId, error });
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyProfileController;
|
||||||
27
controller/profile/PersonalProfileController.js
Normal file
27
controller/profile/PersonalProfileController.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const PersonalProfileService = require('../../services/PersonalProfileService');
|
||||||
|
const PersonalUserRepository = require('../../repositories/PersonalUserRepository');
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
class PersonalProfileController {
|
||||||
|
static async completeProfile(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const profileData = req.body;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
logger.info('PersonalProfileController:completeProfile:start', { userId, profileData });
|
||||||
|
try {
|
||||||
|
const repo = new PersonalUserRepository(unitOfWork);
|
||||||
|
await repo.updateProfileAndMarkCompleted(userId, profileData); // Correct method name
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('PersonalProfileController:completeProfile:success', { userId });
|
||||||
|
res.json({ success: true, message: 'Personal profile completed successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('PersonalProfileController:completeProfile:error', { userId, error });
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalProfileController;
|
||||||
94
controller/referral/ReferralRegistrationController.js
Normal file
94
controller/referral/ReferralRegistrationController.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const ReferralService = require('../../services/ReferralService');
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
class ReferralRegistrationController {
|
||||||
|
static async getReferrerInfo(req, res) {
|
||||||
|
const { token } = req.params;
|
||||||
|
// Unlimited tokens (max_uses / uses_remaining = -1) now bypass expiry & remaining checks.
|
||||||
|
logger.info('ReferralRegistrationController:getReferrerInfo:start', { token });
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const info = await ReferralService.getReferrerInfo(token, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
|
||||||
|
if (!info.valid) {
|
||||||
|
const reason = info.reason;
|
||||||
|
let status = 400;
|
||||||
|
if (reason === 'not_found') status = 404;
|
||||||
|
else if (reason === 'expired' || reason === 'exhausted') status = 410;
|
||||||
|
else if (reason === 'inactive') status = 403;
|
||||||
|
logger.warn('ReferralRegistrationController:getReferrerInfo:invalid', { token, reason, status });
|
||||||
|
return res.status(status).json({ success: false, reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('ReferralRegistrationController:getReferrerInfo:success', { token, referrerName: info.referrerName });
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
referrerName: info.referrerName,
|
||||||
|
referrerEmail: info.referrerEmail,
|
||||||
|
isUnlimited: info.isUnlimited,
|
||||||
|
usesRemaining: info.usesRemaining
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('ReferralRegistrationController:getReferrerInfo:error', { token, error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: 'internal_error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async registerPersonalReferral(req, res) {
|
||||||
|
const { refToken, lang, ...registrationData } = req.body;
|
||||||
|
logger.info('ReferralRegistrationController:registerPersonalReferral:start', { refToken, lang, registrationData });
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const user = await ReferralService.registerPersonalWithReferral(
|
||||||
|
{ ...registrationData, lang }, refToken, unitOfWork
|
||||||
|
);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('ReferralRegistrationController:registerPersonalReferral:success', { userId: user.id, email: user.email });
|
||||||
|
res.json({ success: true, userId: user.id, email: user.email });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('ReferralRegistrationController:registerPersonalReferral:error', { refToken, error });
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async registerCompanyReferral(req, res) {
|
||||||
|
const { refToken, lang, ...registrationData } = req.body;
|
||||||
|
logger.info('ReferralRegistrationController:registerCompanyReferral:start', { refToken, lang, registrationData });
|
||||||
|
const {
|
||||||
|
companyEmail,
|
||||||
|
password,
|
||||||
|
companyName,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone
|
||||||
|
} = registrationData;
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const user = await ReferralService.registerCompanyWithReferral({
|
||||||
|
companyEmail,
|
||||||
|
password,
|
||||||
|
companyName,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone,
|
||||||
|
lang
|
||||||
|
}, refToken, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('ReferralRegistrationController:registerCompanyReferral:success', { userId: user.id, email: user.email });
|
||||||
|
res.json({ success: true, userId: user.id, email: user.email });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('ReferralRegistrationController:registerCompanyReferral:error', { refToken, error });
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ReferralRegistrationController;
|
||||||
141
controller/referral/ReferralTokenController.js
Normal file
141
controller/referral/ReferralTokenController.js
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
const UnitOfWork = require('../../repositories/UnitOfWork');
|
||||||
|
const ReferralService = require('../../services/ReferralService'); // <-- use unified service
|
||||||
|
const { logger } = require('../../middleware/logger');
|
||||||
|
|
||||||
|
// Helper to get frontend URL without trailing slash, no localhost fallback
|
||||||
|
function getFrontendUrl() {
|
||||||
|
let url = process.env.FRONTEND_URL || '';
|
||||||
|
if (url.endsWith('/')) {
|
||||||
|
url = url.slice(0, -1);
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReferralTokenController {
|
||||||
|
static async create(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const userRole = req.user.role;
|
||||||
|
const { expiresInDays, maxUses } = req.body;
|
||||||
|
// Normalize unlimited expiry from frontend (0 or -1) to -1 sentinel
|
||||||
|
let normalizedExpiresInDays = (expiresInDays === 0) ? -1 : expiresInDays;
|
||||||
|
logger.info('ReferralTokenController:createReferralToken:start', { userId, normalizedExpiresInDays, maxUses });
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
let maxActiveLinks = 5;
|
||||||
|
if (userRole === 'admin' || userRole === 'super_admin') {
|
||||||
|
maxActiveLinks = 15;
|
||||||
|
}
|
||||||
|
const activeCount = await ReferralService.countActiveReferralTokens(userId, unitOfWork);
|
||||||
|
if (activeCount >= maxActiveLinks) {
|
||||||
|
await unitOfWork.rollback();
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: `Maximum active referral links reached (${maxActiveLinks}) for your role (${userRole}).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const token = await ReferralService.createReferralToken({
|
||||||
|
userId,
|
||||||
|
expiresInDays: normalizedExpiresInDays,
|
||||||
|
maxUses,
|
||||||
|
unitOfWork
|
||||||
|
});
|
||||||
|
if (normalizedExpiresInDays === -1) {
|
||||||
|
logger.info('ReferralTokenController:createReferralToken:unlimited_expiry', { userId });
|
||||||
|
}
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('ReferralTokenController:createReferralToken:success', { token });
|
||||||
|
const link = `${getFrontendUrl()}/register?ref=${token.token}`;
|
||||||
|
res.json({ success: true, token: token.token, link, expiresAt: token.expiresAt, maxUses: token.maxUses });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('ReferralTokenController:createReferralToken:error', { userId, error });
|
||||||
|
res.status(400).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async list(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info('ReferralTokenController:getUserReferralTokens:start', { userId });
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const tokens = await ReferralService.getUserReferralTokens(userId, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('ReferralTokenController:getUserReferralTokens:success', { count: tokens.length });
|
||||||
|
const frontendUrl = getFrontendUrl();
|
||||||
|
|
||||||
|
// FIX: tokens already transformed by service; use provided fields instead of raw DB column names
|
||||||
|
const result = tokens.map(t => {
|
||||||
|
const isUnlimitedExpiry = t.isUnlimited || !t.expires;
|
||||||
|
const usage = t.usage || (t.isUnlimited ? `${t.used}/∞` : `${t.used}/${t.maxUses}`);
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
status: t.status,
|
||||||
|
created: t.created,
|
||||||
|
created_at: t.created, // legacy alias
|
||||||
|
expires: isUnlimitedExpiry ? '∞' : t.expires,
|
||||||
|
expires_at: t.expires, // legacy alias
|
||||||
|
link: t.link || `${frontendUrl}/register?ref=${t.token}`,
|
||||||
|
token: t.token,
|
||||||
|
// normalized usage fields
|
||||||
|
used: t.used,
|
||||||
|
maxUses: t.maxUses,
|
||||||
|
isUnlimited: t.isUnlimited,
|
||||||
|
usage,
|
||||||
|
usageDisplay: usage,
|
||||||
|
// backward compatibility (old FE expected these)
|
||||||
|
max_uses: t.isUnlimited ? -1 : t.maxUses,
|
||||||
|
usage_count: t.used
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ success: true, tokens: result });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('ReferralTokenController:getUserReferralTokens:error', { userId, error });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async stats(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
logger.info('ReferralTokenController:getUserReferralStats:start', { userId });
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
const stats = await ReferralService.getUserReferralStats(userId, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('ReferralTokenController:getUserReferralStats:success', { stats });
|
||||||
|
// Response now includes companyUsersReferred and personalUsersReferred
|
||||||
|
res.json({ success: true, stats });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('ReferralTokenController:getUserReferralStats:error', { userId, error });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deactivate(req, res) {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const { tokenId } = req.body;
|
||||||
|
logger.info('ReferralTokenController:deactivateReferralToken:start', { userId, tokenId });
|
||||||
|
if (!tokenId) {
|
||||||
|
return res.status(400).json({ success: false, message: 'tokenId is required' });
|
||||||
|
}
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
try {
|
||||||
|
await ReferralService.deactivateReferralToken(userId, tokenId, unitOfWork);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('ReferralTokenController:deactivateReferralToken:success', { userId, tokenId });
|
||||||
|
res.json({ success: true, message: 'Referral link deactivated' });
|
||||||
|
} catch (error) {
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
logger.error('ReferralTokenController:deactivateReferralToken:error', { userId, tokenId, error });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ReferralTokenController;
|
||||||
99
controller/register/CompanyRegisterController.js
Normal file
99
controller/register/CompanyRegisterController.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const CompanyUserService = require('../../services/CompanyUserService');
|
||||||
|
const { logger } = require('../../middleware/logger'); // add logger import
|
||||||
|
|
||||||
|
class CompanyRegisterController {
|
||||||
|
static async register(req, res) {
|
||||||
|
logger.info('CompanyRegisterController.register:start');
|
||||||
|
try {
|
||||||
|
console.log('🏢 Company registration attempt started');
|
||||||
|
console.log('📋 Request body:', JSON.stringify(req.body, null, 2));
|
||||||
|
|
||||||
|
const {
|
||||||
|
companyName,
|
||||||
|
companyEmail,
|
||||||
|
confirmCompanyEmail,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone,
|
||||||
|
password,
|
||||||
|
confirmPassword
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
console.log('🔍 Extracting company data:', {
|
||||||
|
companyName,
|
||||||
|
companyEmail,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate email match
|
||||||
|
if (companyEmail !== confirmCompanyEmail) {
|
||||||
|
logger.warn('CompanyRegisterController.register:email_mismatch', { companyEmail, confirmCompanyEmail });
|
||||||
|
console.log('❌ Company email confirmation mismatch');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Company email and confirm email do not match'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password match
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
logger.warn('CompanyRegisterController.register:password_mismatch');
|
||||||
|
console.log('❌ Company password confirmation mismatch');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Password and confirm password do not match'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if company already exists
|
||||||
|
console.log('🔍 Checking if company already exists:', companyEmail);
|
||||||
|
const existingCompany = await CompanyUserService.findCompanyUserByEmail(companyEmail);
|
||||||
|
if (existingCompany) {
|
||||||
|
logger.warn('CompanyRegisterController.register:company_exists', { companyEmail });
|
||||||
|
console.log('❌ Company registration failed: Company already exists');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Company already exists'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new company user
|
||||||
|
console.log('📝 Creating new company user...');
|
||||||
|
const newCompany = await CompanyUserService.createCompanyUser({
|
||||||
|
companyEmail,
|
||||||
|
companyName,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('CompanyRegisterController.register:success', { companyId: newCompany.id });
|
||||||
|
console.log('✅ Company user created successfully:', {
|
||||||
|
companyId: newCompany.id,
|
||||||
|
companyName: newCompany.companyName,
|
||||||
|
companyEmail: newCompany.email,
|
||||||
|
contactPerson: newCompany.contactPersonName
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Company user registered successfully',
|
||||||
|
companyId: newCompany.id
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyRegisterController.register:error', { error: error.message });
|
||||||
|
console.error('💥 Company registration error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info('CompanyRegisterController.register:end');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyRegisterController;
|
||||||
99
controller/register/PersonalRegisterController.js
Normal file
99
controller/register/PersonalRegisterController.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const PersonalUserService = require('../../services/PersonalUserService');
|
||||||
|
const { logger } = require('../../middleware/logger'); // add logger import
|
||||||
|
|
||||||
|
class PersonalRegisterController {
|
||||||
|
static async register(req, res) {
|
||||||
|
logger.info('PersonalRegisterController.register:start');
|
||||||
|
try {
|
||||||
|
console.log('📝 Personal registration attempt started');
|
||||||
|
console.log('📋 Request body:', JSON.stringify(req.body, null, 2));
|
||||||
|
|
||||||
|
const {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
confirmEmail,
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
referralEmail
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
console.log('🔍 Extracting user data:', {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
hasReferral: !!referralEmail
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate email match
|
||||||
|
if (email !== confirmEmail) {
|
||||||
|
logger.warn('PersonalRegisterController.register:email_mismatch', { email, confirmEmail });
|
||||||
|
console.log('❌ Email confirmation mismatch');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Email and confirm email do not match'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password match
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
logger.warn('PersonalRegisterController.register:password_mismatch');
|
||||||
|
console.log('❌ Password confirmation mismatch');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Password and confirm password do not match'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
console.log('🔍 Checking if user already exists:', email);
|
||||||
|
const existingUser = await PersonalUserService.findPersonalUserByEmail(email);
|
||||||
|
if (existingUser) {
|
||||||
|
logger.warn('PersonalRegisterController.register:user_exists', { email });
|
||||||
|
console.log('❌ Personal registration failed: User already exists');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'User already exists'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new personal user
|
||||||
|
console.log('📝 Creating new personal user...');
|
||||||
|
const newUser = await PersonalUserService.createPersonalUser({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
password,
|
||||||
|
referralEmail
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('PersonalRegisterController.register:success', { userId: newUser.id });
|
||||||
|
console.log('✅ Personal user created successfully:', {
|
||||||
|
userId: newUser.id,
|
||||||
|
email: newUser.email,
|
||||||
|
firstName: newUser.firstName,
|
||||||
|
lastName: newUser.lastName
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Personal user registered successfully',
|
||||||
|
userId: newUser.id
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalRegisterController.register:error', { error: error.message });
|
||||||
|
console.error('💥 Personal registration error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info('PersonalRegisterController.register:end');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalRegisterController;
|
||||||
598
database/createDb.js
Normal file
598
database/createDb.js
Normal file
@ -0,0 +1,598 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
|
const getSSLConfig = () => {
|
||||||
|
const useSSL = String(process.env.DB_SSL || '').toLowerCase() === 'true';
|
||||||
|
const caPath = process.env.DB_SSL_CA_PATH;
|
||||||
|
if (!useSSL) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (caPath) {
|
||||||
|
const resolved = path.resolve(caPath);
|
||||||
|
if (fs.existsSync(resolved)) {
|
||||||
|
console.log('🔐 (createDb) Loading DB CA certificate from:', resolved);
|
||||||
|
return {
|
||||||
|
ca: fs.readFileSync(resolved),
|
||||||
|
rejectUnauthorized: false
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ (createDb) CA file not found at:', resolved, '- proceeding with rejectUnauthorized:false');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ (createDb) DB_SSL_CA_PATH not set - proceeding with rejectUnauthorized:false');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ (createDb) Failed to load CA file:', e.message, '- proceeding with rejectUnauthorized:false');
|
||||||
|
}
|
||||||
|
return { rejectUnauthorized: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
let dbConfig;
|
||||||
|
if (NODE_ENV === 'development') {
|
||||||
|
dbConfig = {
|
||||||
|
host: process.env.DEV_DB_HOST || 'localhost',
|
||||||
|
port: Number(process.env.DEV_DB_PORT) || 3306,
|
||||||
|
user: process.env.DEV_DB_USER || 'root',
|
||||||
|
password: process.env.DEV_DB_PASSWORD || '', // XAMPP default: no password
|
||||||
|
database: process.env.DEV_DB_NAME || 'profitplanet_centralserver',
|
||||||
|
ssl: undefined
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
dbConfig = {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
ssl: getSSLConfig()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowCreateDb = String(process.env.DB_ALLOW_CREATE_DB || 'false').toLowerCase() === 'true';
|
||||||
|
|
||||||
|
// --- Performance Helpers (added) ---
|
||||||
|
async function ensureIndex(conn, table, indexName, indexDDL) {
|
||||||
|
const [rows] = await conn.query(`SHOW INDEX FROM \`${table}\` WHERE Key_name = ?`, [indexName]);
|
||||||
|
if (!rows.length) {
|
||||||
|
await conn.query(`CREATE INDEX \`${indexName}\` ON \`${table}\` (${indexDDL})`);
|
||||||
|
console.log(`🆕 Created index ${indexName} ON ${table}`);
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ Index ${indexName} already exists on ${table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDatabase() {
|
||||||
|
console.log('🚀 Starting MySQL database initialization...');
|
||||||
|
console.log('📍 Database host:', process.env.DB_HOST);
|
||||||
|
console.log('📍 Database name:', process.env.DB_NAME);
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
if (allowCreateDb) {
|
||||||
|
// Connect without specifying a database to create it if it doesn't exist
|
||||||
|
connection = await mysql.createConnection({
|
||||||
|
host: dbConfig.host,
|
||||||
|
port: dbConfig.port,
|
||||||
|
user: dbConfig.user,
|
||||||
|
password: dbConfig.password,
|
||||||
|
ssl: dbConfig.ssl
|
||||||
|
});
|
||||||
|
|
||||||
|
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbConfig.database}\`;`);
|
||||||
|
console.log(`✅ Database "${dbConfig.database}" created/verified`);
|
||||||
|
await connection.end();
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Skipping database creation (DB_ALLOW_CREATE_DB=false)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect with the database specified
|
||||||
|
connection = await mysql.createConnection(dbConfig);
|
||||||
|
console.log('✅ Connected to MySQL database');
|
||||||
|
|
||||||
|
// --- Core Tables ---
|
||||||
|
|
||||||
|
// 1. users table: Central user authentication and common attributes
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
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',
|
||||||
|
iban VARCHAR(34),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
last_login_at TIMESTAMP NULL,
|
||||||
|
INDEX idx_email (email),
|
||||||
|
INDEX idx_user_type (user_type),
|
||||||
|
INDEX idx_role (role)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Users table created/verified');
|
||||||
|
|
||||||
|
// 2. personal_profiles table: Details specific to personal users
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS personal_profiles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
first_name VARCHAR(255) NOT NULL,
|
||||||
|
last_name VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(255) NULL,
|
||||||
|
date_of_birth DATE,
|
||||||
|
nationality VARCHAR(255),
|
||||||
|
address TEXT,
|
||||||
|
zip_code VARCHAR(20),
|
||||||
|
city VARCHAR(100), -- Added city column
|
||||||
|
country VARCHAR(100),
|
||||||
|
phone_secondary VARCHAR(255),
|
||||||
|
emergency_contact_name VARCHAR(255),
|
||||||
|
emergency_contact_phone VARCHAR(255),
|
||||||
|
account_holder_name VARCHAR(255), -- Added column
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
UNIQUE KEY unique_user_profile (user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Personal profiles table updated');
|
||||||
|
|
||||||
|
// 3. company_profiles table: Details specific to company users
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS company_profiles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
company_name VARCHAR(255) NOT NULL,
|
||||||
|
registration_number VARCHAR(255) UNIQUE, -- allow NULL
|
||||||
|
phone VARCHAR(255) NULL,
|
||||||
|
address TEXT,
|
||||||
|
zip_code VARCHAR(20),
|
||||||
|
city VARCHAR(100),
|
||||||
|
country VARCHAR(100), -- Added country column after city
|
||||||
|
branch VARCHAR(255),
|
||||||
|
number_of_employees INT,
|
||||||
|
business_type VARCHAR(255),
|
||||||
|
contact_person_name VARCHAR(255),
|
||||||
|
contact_person_phone VARCHAR(255),
|
||||||
|
account_holder_name VARCHAR(255), -- Added column
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
UNIQUE KEY unique_user_profile (user_id),
|
||||||
|
UNIQUE KEY unique_registration_number (registration_number)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Company profiles table updated');
|
||||||
|
|
||||||
|
// Ensure registration_number allows NULL if table already existed
|
||||||
|
try {
|
||||||
|
await connection.query(`ALTER TABLE company_profiles MODIFY COLUMN registration_number VARCHAR(255) UNIQUE NULL`);
|
||||||
|
console.log('🔧 Ensured registration_number allows NULL');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ registration_number column already allows NULL or ALTER not required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure phone columns are nullable if tables already existed
|
||||||
|
try {
|
||||||
|
await connection.query(`ALTER TABLE personal_profiles MODIFY COLUMN phone VARCHAR(255) NULL`);
|
||||||
|
await connection.query(`ALTER TABLE company_profiles MODIFY COLUMN phone VARCHAR(255) NULL`);
|
||||||
|
console.log('🔧 Ensured phone columns are nullable');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ Phone columns already nullable or ALTER not required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. user_status table: Comprehensive tracking of user verification and completion steps
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_status (
|
||||||
|
user_id INT PRIMARY KEY,
|
||||||
|
status ENUM('inactive', 'pending', 'active', 'suspended') DEFAULT 'pending',
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
email_verified_at TIMESTAMP NULL,
|
||||||
|
profile_completed BOOLEAN DEFAULT FALSE,
|
||||||
|
profile_completed_at TIMESTAMP NULL,
|
||||||
|
documents_uploaded BOOLEAN DEFAULT FALSE,
|
||||||
|
documents_uploaded_at TIMESTAMP NULL,
|
||||||
|
contract_signed BOOLEAN DEFAULT FALSE,
|
||||||
|
contract_signed_at TIMESTAMP NULL,
|
||||||
|
is_admin_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
admin_verified_at TIMESTAMP NULL,
|
||||||
|
registration_completed BOOLEAN DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ User status table created/verified');
|
||||||
|
|
||||||
|
// --- Authentication & Verification Tables ---
|
||||||
|
|
||||||
|
// 5. refresh_tokens table: For refresh token authentication
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_expires_at (expires_at)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Refresh tokens table created/verified');
|
||||||
|
|
||||||
|
// 6. email_verifications table: For email verification codes
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
verification_code VARCHAR(6) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
verified_at TIMESTAMP NULL,
|
||||||
|
attempts INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
INDEX idx_user_code (user_id, verification_code),
|
||||||
|
INDEX idx_expires_at (expires_at)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Email verifications table created/verified');
|
||||||
|
|
||||||
|
// 7. password_resets table: For password reset tokens
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS password_resets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
used_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
INDEX idx_user_token (user_id, token),
|
||||||
|
INDEX idx_expires_at (expires_at)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Password resets table created/verified');
|
||||||
|
|
||||||
|
// --- Document & Logging Tables ---
|
||||||
|
|
||||||
|
// 8. user_documents table: Stores object storage IDs
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_documents (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
document_type ENUM('personal_id', 'company_id', 'signature', 'contract', 'other') NOT NULL,
|
||||||
|
object_storage_id VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
original_filename VARCHAR(255),
|
||||||
|
file_size INT,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
upload_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
verified_by_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
admin_verified_at TIMESTAMP NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
INDEX idx_user_document_type (user_id, document_type),
|
||||||
|
INDEX idx_object_storage_id (object_storage_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ User documents table created/verified');
|
||||||
|
|
||||||
|
// 8c. document_templates table: Stores template metadata and object storage keys
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS document_templates (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(100) NOT NULL,
|
||||||
|
storageKey VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
lang VARCHAR(10) NOT NULL,
|
||||||
|
user_type ENUM('personal','company','both') DEFAULT 'both', -- NEW COLUMN
|
||||||
|
version INT DEFAULT 1,
|
||||||
|
state ENUM('active','inactive') DEFAULT 'inactive', -- Added state column
|
||||||
|
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Document templates table created/verified');
|
||||||
|
|
||||||
|
// Ensure version column exists if table already existed
|
||||||
|
try {
|
||||||
|
await connection.query(`ALTER TABLE document_templates ADD COLUMN version INT DEFAULT 1`);
|
||||||
|
console.log('🔧 Ensured version column exists');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ Version column already exists or ALTER not required');
|
||||||
|
}
|
||||||
|
// Ensure state column exists if table already existed
|
||||||
|
try {
|
||||||
|
await connection.query(`ALTER TABLE document_templates ADD COLUMN state ENUM('active','inactive') DEFAULT 'inactive'`);
|
||||||
|
console.log('🔧 Ensured state column exists');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ State column already exists or ALTER not required');
|
||||||
|
}
|
||||||
|
// Ensure user_type column exists
|
||||||
|
try {
|
||||||
|
await connection.query(`ALTER TABLE document_templates ADD COLUMN user_type ENUM('personal','company','both') DEFAULT 'both'`);
|
||||||
|
console.log('🔧 Ensured user_type column exists');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ user_type column already exists or ALTER not required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8b. user_id_documents table: Stores ID-specific metadata (front/back object storage IDs)
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_id_documents (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
document_type ENUM('personal_id', 'company_id') NOT NULL,
|
||||||
|
front_object_storage_id VARCHAR(255) NOT NULL,
|
||||||
|
back_object_storage_id VARCHAR(255) NULL,
|
||||||
|
original_filename_front VARCHAR(255), -- NEW COLUMN
|
||||||
|
original_filename_back VARCHAR(255), -- NEW COLUMN
|
||||||
|
id_type VARCHAR(50),
|
||||||
|
id_number VARCHAR(100),
|
||||||
|
expiry_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ User ID documents table created/verified');
|
||||||
|
|
||||||
|
// 9. user_action_logs table: For detailed user activity logging
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_action_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
affected_user_id INT NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
performed_by_user_id INT NULL,
|
||||||
|
details JSON,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (affected_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (performed_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
INDEX idx_affected_user (affected_user_id),
|
||||||
|
INDEX idx_performed_by (performed_by_user_id),
|
||||||
|
INDEX idx_action (action),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ User action logs table created/verified');
|
||||||
|
|
||||||
|
// --- Email & Registration Flow Tables ---
|
||||||
|
|
||||||
|
// 10. email_attempts table: For tracking email sending attempts
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS email_attempts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
attempt_type ENUM('verification', 'password_reset', 'registration_completion', 'notification', 'other') NOT NULL,
|
||||||
|
email_address VARCHAR(255) NOT NULL,
|
||||||
|
success BOOLEAN DEFAULT FALSE,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
INDEX idx_user_attempts (user_id, attempt_type),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Email attempts table created/verified');
|
||||||
|
|
||||||
|
// --- Referral Tables ---
|
||||||
|
|
||||||
|
// 12. referral_tokens table: Manages referral codes
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS referral_tokens (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
token VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
created_by_user_id INT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
max_uses INT DEFAULT -1,
|
||||||
|
uses_remaining INT DEFAULT -1,
|
||||||
|
status ENUM('active', 'inactive', 'expired', 'exhausted') DEFAULT 'active',
|
||||||
|
deactivation_reason VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
INDEX idx_token (token),
|
||||||
|
INDEX idx_status_expires (status, expires_at),
|
||||||
|
INDEX idx_created_by (created_by_user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Referral tokens table created/verified');
|
||||||
|
|
||||||
|
// Generated label columns (virtual) to display 'unlimited' instead of -1 in UI tools
|
||||||
|
try {
|
||||||
|
await connection.query(`
|
||||||
|
ALTER TABLE referral_tokens
|
||||||
|
ADD COLUMN max_uses_label VARCHAR(20)
|
||||||
|
GENERATED ALWAYS AS (CASE WHEN max_uses = -1 THEN 'unlimited' ELSE CAST(max_uses AS CHAR) END) VIRTUAL
|
||||||
|
`);
|
||||||
|
console.log('🆕 Added virtual column referral_tokens.max_uses_label');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ max_uses_label already exists or cannot add:', e.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await connection.query(`
|
||||||
|
ALTER TABLE referral_tokens
|
||||||
|
ADD COLUMN uses_remaining_label VARCHAR(20)
|
||||||
|
GENERATED ALWAYS AS (CASE WHEN uses_remaining = -1 THEN 'unlimited' ELSE CAST(uses_remaining AS CHAR) END) VIRTUAL
|
||||||
|
`);
|
||||||
|
console.log('🆕 Added virtual column referral_tokens.uses_remaining_label');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ uses_remaining_label already exists or cannot add:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalized view now sources the generated label columns (still handy for joins)
|
||||||
|
try {
|
||||||
|
await connection.query(`
|
||||||
|
CREATE OR REPLACE VIEW referral_tokens_normalized AS
|
||||||
|
SELECT
|
||||||
|
rt.*,
|
||||||
|
rt.max_uses_label AS max_uses_display,
|
||||||
|
rt.uses_remaining_label AS uses_remaining_display
|
||||||
|
FROM referral_tokens rt;
|
||||||
|
`);
|
||||||
|
console.log('🆕 referral_tokens_normalized view created/updated');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Could not create referral_tokens_normalized view:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. referral_token_usage table: Tracks each use of a referral token
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS referral_token_usage (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
referral_token_id INT NOT NULL,
|
||||||
|
used_by_user_id INT NOT NULL,
|
||||||
|
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (referral_token_id) REFERENCES referral_tokens(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
UNIQUE KEY unique_token_user_usage (referral_token_id, used_by_user_id),
|
||||||
|
INDEX idx_token_usage (referral_token_id),
|
||||||
|
INDEX idx_user_usage (used_by_user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Referral token usage table created/verified');
|
||||||
|
|
||||||
|
// --- Authorization Tables ---
|
||||||
|
|
||||||
|
// 14. permissions table: Defines granular permissions
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Added
|
||||||
|
created_by INT NULL, -- Added
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Permissions table created/verified');
|
||||||
|
|
||||||
|
// 15. user_permissions join table: Assigns specific permissions to users
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
permission_id INT NOT NULL,
|
||||||
|
granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
granted_by INT NULL, -- Added column
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, -- FK constraint
|
||||||
|
UNIQUE KEY unique_user_permission (user_id, permission_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ User permissions table created/verified');
|
||||||
|
|
||||||
|
// --- User Settings Table ---
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
|
user_id INT PRIMARY KEY,
|
||||||
|
theme ENUM('light', 'dark') DEFAULT 'light',
|
||||||
|
font_size ENUM('normal', 'large') DEFAULT 'normal',
|
||||||
|
high_contrast_mode BOOLEAN DEFAULT FALSE,
|
||||||
|
two_factor_auth_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
account_visibility ENUM('public', 'private') DEFAULT 'public',
|
||||||
|
show_email BOOLEAN DEFAULT TRUE,
|
||||||
|
show_phone BOOLEAN DEFAULT TRUE,
|
||||||
|
data_export_requested BOOLEAN DEFAULT FALSE,
|
||||||
|
last_data_export_at TIMESTAMP NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ User settings table created/verified');
|
||||||
|
|
||||||
|
// --- Rate Limiting Table ---
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rate_key VARCHAR(255) NOT NULL,
|
||||||
|
window_start DATETIME NOT NULL,
|
||||||
|
count INT DEFAULT 0,
|
||||||
|
window_seconds INT NOT NULL,
|
||||||
|
max INT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY unique_key_window (rate_key, window_start)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Rate limit table created/verified');
|
||||||
|
|
||||||
|
// --- NEW: company_stamps table (for company/admin managed stamps) ---
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS company_stamps (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
company_id INT NOT NULL,
|
||||||
|
label VARCHAR(100) NULL,
|
||||||
|
mime_type VARCHAR(50) NOT NULL,
|
||||||
|
image_base64 LONGTEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (company_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
UNIQUE KEY unique_company_label (company_id, label),
|
||||||
|
INDEX idx_company_active (company_id, is_active)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ Company stamps table created/verified');
|
||||||
|
|
||||||
|
// --- Added Index Optimization Section ---
|
||||||
|
try {
|
||||||
|
// Core / status
|
||||||
|
await ensureIndex(connection, 'users', 'idx_users_created_at', 'created_at');
|
||||||
|
await ensureIndex(connection, 'user_status', 'idx_user_status_status', 'status');
|
||||||
|
await ensureIndex(connection, 'user_status', 'idx_user_status_registration_completed', 'registration_completed');
|
||||||
|
|
||||||
|
// Tokens & auth
|
||||||
|
await ensureIndex(connection, 'refresh_tokens', 'idx_refresh_user_expires', 'user_id, expires_at');
|
||||||
|
await ensureIndex(connection, 'email_verifications', 'idx_email_verifications_user_expires', 'user_id, expires_at');
|
||||||
|
await ensureIndex(connection, 'password_resets', 'idx_password_resets_user_expires', 'user_id, expires_at');
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
await ensureIndex(connection, 'user_documents', 'idx_user_documents_upload_at', 'upload_at');
|
||||||
|
await ensureIndex(connection, 'user_documents', 'idx_user_documents_verified', 'verified_by_admin');
|
||||||
|
await ensureIndex(connection, 'user_id_documents', 'idx_user_id_docs_user_type', 'user_id, document_type');
|
||||||
|
|
||||||
|
// Activity logs (composite for common filtered ordering)
|
||||||
|
await ensureIndex(connection, 'user_action_logs', 'idx_user_action_logs_action_created', 'action, created_at');
|
||||||
|
|
||||||
|
// Referrals
|
||||||
|
await ensureIndex(connection, 'referral_token_usage', 'idx_referral_token_usage_used_at', 'used_at');
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
await ensureIndex(connection, 'permissions', 'idx_permissions_is_active', 'is_active');
|
||||||
|
await ensureIndex(connection, 'user_permissions', 'idx_user_permissions_granted_by', 'granted_by');
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
await ensureIndex(connection, 'user_settings', 'idx_user_settings_theme', 'theme');
|
||||||
|
|
||||||
|
// Rate limit (for queries only on rate_key)
|
||||||
|
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'); // NEW composite index
|
||||||
|
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');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Index optimization phase encountered an issue:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🎉 Normalized database schema created/updated successfully!');
|
||||||
|
|
||||||
|
await connection.end();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Error during database initialization:', error.message);
|
||||||
|
if (connection) {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDatabase };
|
||||||
103
database/database.js
Normal file
103
database/database.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Create connection pool for better performance
|
||||||
|
const getSSLConfig = () => {
|
||||||
|
const useSSL = String(process.env.DB_SSL || '').toLowerCase() === 'true';
|
||||||
|
const caPath = process.env.DB_SSL_CA_PATH;
|
||||||
|
if (!useSSL) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (caPath) {
|
||||||
|
const resolved = path.resolve(caPath);
|
||||||
|
if (fs.existsSync(resolved)) {
|
||||||
|
console.log('🔐 Loading DB CA certificate from:', resolved);
|
||||||
|
return {
|
||||||
|
ca: fs.readFileSync(resolved),
|
||||||
|
rejectUnauthorized: false // Allow self-signed / custom CA
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ CA file not found at path:', resolved, '- proceeding with rejectUnauthorized:false');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ DB_SSL_CA_PATH not set - proceeding with empty CA and rejectUnauthorized:false');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Failed to load CA file:', e.message, '- proceeding with empty CA and rejectUnauthorized:false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: still provide object so mysql2 enables TLS
|
||||||
|
return {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
|
let dbConfig;
|
||||||
|
if (NODE_ENV === 'development') {
|
||||||
|
dbConfig = {
|
||||||
|
host: process.env.DEV_DB_HOST || 'localhost',
|
||||||
|
port: Number(process.env.DEV_DB_PORT) || 3306,
|
||||||
|
user: process.env.DEV_DB_USER || 'root',
|
||||||
|
password: process.env.DEV_DB_PASSWORD || '', // XAMPP default: no password
|
||||||
|
database: process.env.DEV_DB_NAME || 'profitplanet_centralserver',
|
||||||
|
// Do NOT use SSL for development/XAMPP
|
||||||
|
ssl: undefined
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
dbConfig = {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
ssl: getSSLConfig()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
...dbConfig,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📊 MySQL connection pool created (SSL:', !!process.env.DB_SSL, ')');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Execute query with parameters
|
||||||
|
async execute(query, params = []) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute(query, params);
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Database query error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Alias for execute to maintain compatibility
|
||||||
|
async query(query, params = []) {
|
||||||
|
return await pool.execute(query, params);
|
||||||
|
},
|
||||||
|
// Get single row
|
||||||
|
async get(query, params = []) {
|
||||||
|
const rows = await this.execute(query, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
},
|
||||||
|
// Get all rows
|
||||||
|
async all(query, params = []) {
|
||||||
|
return await this.execute(query, params);
|
||||||
|
},
|
||||||
|
// Close pool
|
||||||
|
async close() {
|
||||||
|
await pool.end();
|
||||||
|
console.log('📊 MySQL connection pool closed');
|
||||||
|
},
|
||||||
|
// Get a connection from the pool
|
||||||
|
async getConnection() {
|
||||||
|
return await pool.getConnection();
|
||||||
|
}
|
||||||
|
};
|
||||||
19
mailTemplates/de/loginNotification.txt
Normal file
19
mailTemplates/de/loginNotification.txt
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
Hallo,
|
||||||
|
|
||||||
|
Eine Anmeldung bei Ihrem ProfitPlanet-Konto wurde gerade erkannt.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
E-Mail: {{email}}
|
||||||
|
|
||||||
|
IP-Adresse: {{ip}}
|
||||||
|
|
||||||
|
Zeit: {{loginTime}}
|
||||||
|
|
||||||
|
Gerät: {{userAgent}}
|
||||||
|
|
||||||
|
Wenn Sie das waren, ist keine weitere Aktion erforderlich.
|
||||||
|
Wenn Sie sich NICHT angemeldet haben, setzen Sie bitte sofort Ihr Passwort zurück und kontaktieren Sie den Support.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen,
|
||||||
|
Ihr ProfitPlanet Sicherheitsteam
|
||||||
11
mailTemplates/de/passwordReset.txt
Normal file
11
mailTemplates/de/passwordReset.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
Hallo {{firstName}}{{companyName}}!
|
||||||
|
|
||||||
|
Wir haben eine Anfrage zum Zurücksetzen Ihres ProfitPlanet-Passworts erhalten.
|
||||||
|
|
||||||
|
Um Ihr Passwort zurückzusetzen, klicken Sie bitte auf den folgenden Link:
|
||||||
|
{{resetUrl}}
|
||||||
|
|
||||||
|
Falls Sie kein Passwort-Reset angefordert haben, ignorieren Sie diese E-Mail bitte.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen,
|
||||||
|
ProfitPlanet Team
|
||||||
7
mailTemplates/de/registrationCompany.txt
Normal file
7
mailTemplates/de/registrationCompany.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Hallo {{companyName}},
|
||||||
|
|
||||||
|
vielen Dank für die Registrierung Ihres Unternehmens bei ProfitPlanet!
|
||||||
|
Sie können sich jetzt hier anmelden: {{loginUrl}}
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen,
|
||||||
|
Ihr ProfitPlanet Team
|
||||||
7
mailTemplates/de/registrationPersonal.txt
Normal file
7
mailTemplates/de/registrationPersonal.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Hallo {{firstName}} {{lastName}},
|
||||||
|
|
||||||
|
vielen Dank für Ihre Registrierung als persönlicher Nutzer bei ProfitPlanet!
|
||||||
|
Sie können sich jetzt hier anmelden: {{loginUrl}}
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen,
|
||||||
|
Ihr ProfitPlanet Team
|
||||||
10
mailTemplates/de/verificationCode.txt
Normal file
10
mailTemplates/de/verificationCode.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Hallo von ProfitPlanet!
|
||||||
|
|
||||||
|
Ihr Verifizierungscode lautet: {{code}}
|
||||||
|
|
||||||
|
Dieser Code läuft um {{expiresAt}} ab.
|
||||||
|
|
||||||
|
Wenn Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail bitte.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen,
|
||||||
|
Ihr ProfitPlanet Team
|
||||||
15
mailTemplates/en/loginNotification.txt
Normal file
15
mailTemplates/en/loginNotification.txt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
A login to your ProfitPlanet account was just detected.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
- Email: {{email}}
|
||||||
|
- IP Address: {{ip}}
|
||||||
|
- Time: {{loginTime}}
|
||||||
|
- Device: {{userAgent}}
|
||||||
|
|
||||||
|
If this was you, no action is needed.
|
||||||
|
If you did NOT perform this login, please reset your password immediately and contact support.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
ProfitPlanet Security Team
|
||||||
11
mailTemplates/en/passwordReset.txt
Normal file
11
mailTemplates/en/passwordReset.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
Hello {{firstName}}{{companyName}}!
|
||||||
|
|
||||||
|
We received a request to reset your ProfitPlanet account password.
|
||||||
|
|
||||||
|
To reset your password, please click the link below:
|
||||||
|
{{resetUrl}}
|
||||||
|
|
||||||
|
If you did not request a password reset, you can ignore this email.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
ProfitPlanet Team
|
||||||
7
mailTemplates/en/registrationCompany.txt
Normal file
7
mailTemplates/en/registrationCompany.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Hi {{companyName}},
|
||||||
|
|
||||||
|
Thank you for registering your company at ProfitPlanet!
|
||||||
|
You can now log in here: {{loginUrl}}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
ProfitPlanet Team
|
||||||
7
mailTemplates/en/registrationPersonal.txt
Normal file
7
mailTemplates/en/registrationPersonal.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Hi {{firstName}} {{lastName}},
|
||||||
|
|
||||||
|
Thank you for registering as a personal user at ProfitPlanet!
|
||||||
|
You can now log in here: {{loginUrl}}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
ProfitPlanet Team
|
||||||
10
mailTemplates/en/verificationCode.txt
Normal file
10
mailTemplates/en/verificationCode.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Hello from ProfitPlanet!
|
||||||
|
|
||||||
|
Your email verification code is: {{code}}
|
||||||
|
|
||||||
|
This code will expire at {{expiresAt}}.
|
||||||
|
|
||||||
|
If you did not request this, please ignore this email.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
ProfitPlanet Team
|
||||||
19
middleware/authMiddleware.js
Normal file
19
middleware/authMiddleware.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ success: false, message: 'No access token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
req.user = payload; // Attach user info to request
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ success: false, message: 'Invalid or expired access token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = authMiddleware;
|
||||||
228
middleware/logger.js
Normal file
228
middleware/logger.js
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createLogger, format, transports } = require('winston');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||||
|
|
||||||
|
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, '..', 'logs');
|
||||||
|
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||||
|
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
|
||||||
|
const SERVICE_NAME = 'central-server';
|
||||||
|
|
||||||
|
// ensure log directory exists
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fallback in case of permission issues
|
||||||
|
console.error('Failed to ensure log directory', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// use JSON (one object per line) to be FluentBit/OpenSearch-friendly
|
||||||
|
const jsonFormat = format.combine(
|
||||||
|
format.timestamp(),
|
||||||
|
format.errors({ stack: true }),
|
||||||
|
format.splat(),
|
||||||
|
format.json()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom timestamp formatter for console logs
|
||||||
|
const customTimestampFormat = format((info) => {
|
||||||
|
const date = new Date(info.timestamp || Date.now());
|
||||||
|
const pad = (n) => n < 10 ? '0' + n : n;
|
||||||
|
const day = pad(date.getDate());
|
||||||
|
const month = pad(date.getMonth() + 1);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = pad(date.getHours());
|
||||||
|
const minutes = pad(date.getMinutes());
|
||||||
|
const seconds = pad(date.getSeconds());
|
||||||
|
info.timestamp = `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
|
||||||
|
return info;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loggerTransports = [
|
||||||
|
new transports.Console({
|
||||||
|
level: LOG_LEVEL,
|
||||||
|
format: format.combine(
|
||||||
|
format.timestamp(),
|
||||||
|
customTimestampFormat(),
|
||||||
|
format.colorize({ all: NODE_ENV === 'development' }),
|
||||||
|
// keep console human-friendly in dev, but still structured in prod
|
||||||
|
NODE_ENV === 'development'
|
||||||
|
? format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
|
const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : '';
|
||||||
|
return `${timestamp} ${level}: ${message} ${metaStr}`;
|
||||||
|
})
|
||||||
|
: jsonFormat
|
||||||
|
)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
// add daily rotate file transports only in production
|
||||||
|
if (NODE_ENV === 'production') {
|
||||||
|
loggerTransports.push(
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(LOG_DIR, 'error-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: 'error',
|
||||||
|
format: jsonFormat,
|
||||||
|
maxSize: '5m',
|
||||||
|
maxFiles: '7d',
|
||||||
|
zippedArchive: true
|
||||||
|
}),
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(LOG_DIR, 'combined-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: LOG_LEVEL,
|
||||||
|
format: jsonFormat,
|
||||||
|
maxSize: '10m',
|
||||||
|
maxFiles: '7d',
|
||||||
|
zippedArchive: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = createLogger({
|
||||||
|
level: LOG_LEVEL,
|
||||||
|
defaultMeta: { service: SERVICE_NAME, env: NODE_ENV },
|
||||||
|
transports: loggerTransports,
|
||||||
|
exitOnError: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// capture console.* and route to winston so third-party libs are logged
|
||||||
|
const _origConsole = {
|
||||||
|
debug: console.debug,
|
||||||
|
info: console.info,
|
||||||
|
warn: console.warn,
|
||||||
|
error: console.error,
|
||||||
|
log: console.log
|
||||||
|
};
|
||||||
|
['debug','info','warn','error','log'].forEach(level => {
|
||||||
|
console[level] = (...args) => {
|
||||||
|
try {
|
||||||
|
const msg = args.map(a => {
|
||||||
|
if (typeof a === 'string') return a;
|
||||||
|
if (a instanceof Error) return a.stack || a.message;
|
||||||
|
try { return JSON.stringify(a); } catch (e) { return String(a); }
|
||||||
|
}).join(' ');
|
||||||
|
// map console.log -> info
|
||||||
|
const winLevel = level === 'log' ? 'info' : level;
|
||||||
|
if (logger[winLevel]) logger[winLevel](msg);
|
||||||
|
else logger.info(msg);
|
||||||
|
} catch (e) {
|
||||||
|
_origConsole[level](...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// runtime setter for log level
|
||||||
|
function setLogLevel(level) {
|
||||||
|
try {
|
||||||
|
logger.level = level;
|
||||||
|
logger.transports.forEach((t) => {
|
||||||
|
if (typeof t.level !== 'undefined') t.level = level;
|
||||||
|
});
|
||||||
|
logger.info('logLevel:changed', { level });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_origConsole.error('Failed to set log level', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express middleware: attach requestId, log start and end with duration
|
||||||
|
function requestLogger(req, res, next) {
|
||||||
|
const requestId = req.headers['x-request-id'] || crypto.randomBytes(8).toString('hex');
|
||||||
|
req.id = requestId;
|
||||||
|
const start = process.hrtime();
|
||||||
|
|
||||||
|
logger.info('request:start', {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl || req.url,
|
||||||
|
ip: req.ip,
|
||||||
|
headers: {
|
||||||
|
referer: req.get('referer') || '',
|
||||||
|
origin: req.get('origin') || '',
|
||||||
|
authorization: req.get('authorization') ? 'present' : 'absent'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const [s, ns] = process.hrtime(start);
|
||||||
|
const durationMs = Math.round((s * 1e3) + (ns / 1e6));
|
||||||
|
logger.info('request:finish', {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl || req.url,
|
||||||
|
status: res.statusCode,
|
||||||
|
durationMs,
|
||||||
|
ip: req.ip,
|
||||||
|
user: req.user ? { id: req.user.id, email: req.user.email } : undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
const [s, ns] = process.hrtime(start);
|
||||||
|
const durationMs = Math.round((s * 1e3) + (ns / 1e6));
|
||||||
|
logger.warn('request:closed', {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl || req.url,
|
||||||
|
status: res.statusCode,
|
||||||
|
durationMs,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add per-transport setter and a getter for current levels
|
||||||
|
function setTransportLevel(transportIdentifier, level) {
|
||||||
|
try {
|
||||||
|
let matched = false;
|
||||||
|
const id = (transportIdentifier || 'all').toString().toLowerCase();
|
||||||
|
|
||||||
|
logger.transports.forEach((t) => {
|
||||||
|
const ctorName = (t.constructor && t.constructor.name) ? t.constructor.name.toLowerCase() : '';
|
||||||
|
const tName = (t.name || '').toString().toLowerCase();
|
||||||
|
// match "console", "file", transport class name, or "all"
|
||||||
|
if (id === 'all' || ctorName.includes(id) || tName.includes(id) || (id === 'file' && ctorName.includes('file')) || (id === 'console' && ctorName.includes('console'))) {
|
||||||
|
if (typeof t.level !== 'undefined') {
|
||||||
|
t.level = level;
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
logger.info('logLevel:transport_changed', { transport: transportIdentifier, level });
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
_origConsole.warn('setTransportLevel: no transport matched', transportIdentifier);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_origConsole.error('Failed to set transport level', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoggerLevels() {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
loggerLevel: logger.level,
|
||||||
|
transports: logger.transports.map(t => ({
|
||||||
|
name: (t.constructor && t.constructor.name) || t.name || 'unknown',
|
||||||
|
level: typeof t.level !== 'undefined' ? t.level : null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
_origConsole.error('Failed to read logger levels', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { logger, requestLogger, setLogLevel, setTransportLevel, getLoggerLevels };
|
||||||
116
middleware/rateLimiter.js
Normal file
116
middleware/rateLimiter.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const RateLimitRepository = require('../repositories/RateLimitRepository');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for rate limiter middleware.
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {function(req): string} options.keyGenerator - Function to generate a unique key per request.
|
||||||
|
* @param {number} options.max - Max allowed requests per window.
|
||||||
|
* @param {number} options.windowSeconds - Window size in seconds.
|
||||||
|
* @returns {function} Express middleware
|
||||||
|
*/
|
||||||
|
function createRateLimiter({ keyGenerator, max, windowSeconds }) {
|
||||||
|
return async function rateLimiter(req, res, next) {
|
||||||
|
const rateKey = keyGenerator(req);
|
||||||
|
const now = new Date();
|
||||||
|
const windowStart = new Date(Math.floor(now.getTime() / (windowSeconds * 1000)) * windowSeconds * 1000);
|
||||||
|
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const repo = new RateLimitRepository(uow.connection);
|
||||||
|
|
||||||
|
// Cleanup old rows with 1% probability
|
||||||
|
if (Math.random() < 0.01) {
|
||||||
|
await repo.cleanupOldRows(30); // 30 days retention
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically increment or create
|
||||||
|
const row = await repo.incrementOrCreate(rateKey, windowStart, windowSeconds, max, 1);
|
||||||
|
|
||||||
|
if (row.count > max) {
|
||||||
|
await uow.rollback();
|
||||||
|
// Custom JSON error response
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Rate limit exceeded. Please try again later.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.commit();
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
await uow.rollback(err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error (rate limiter)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks and/or increments the rate limit for a given key.
|
||||||
|
* If res is null, only checks (does not increment or send response).
|
||||||
|
* If res is provided, increments and sends response if limited.
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} rateKey
|
||||||
|
* @param {number} max
|
||||||
|
* @param {number} windowSeconds
|
||||||
|
* @param {Object|null} res - Express response object or null
|
||||||
|
* @returns {Promise<boolean>} true if limited, false otherwise
|
||||||
|
*/
|
||||||
|
async function checkAndIncrementRateLimit({ rateKey, max, windowSeconds }, res) {
|
||||||
|
const now = new Date();
|
||||||
|
const windowStart = new Date(Math.floor(now.getTime() / (windowSeconds * 1000)) * windowSeconds * 1000);
|
||||||
|
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const repo = new RateLimitRepository(uow.connection);
|
||||||
|
|
||||||
|
// Cleanup old rows with 1% probability
|
||||||
|
if (Math.random() < 0.01) {
|
||||||
|
await repo.cleanupOldRows(30); // 30 days retention
|
||||||
|
}
|
||||||
|
|
||||||
|
let row;
|
||||||
|
if (res === null) {
|
||||||
|
// "Check only" mode: do NOT increment, just check
|
||||||
|
row = await repo.getForUpdate(rateKey, windowStart);
|
||||||
|
if (row && row.count >= max) {
|
||||||
|
await uow.rollback();
|
||||||
|
console.warn(`[RATE LIMIT] Transaction rollback: rate limit exceeded for key ${rateKey} at ${new Date().toISOString()}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await uow.commit();
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
// "Increment" mode: increment or create
|
||||||
|
row = await repo.incrementOrCreate(rateKey, windowStart, windowSeconds, max, 1);
|
||||||
|
if (row.count > max) {
|
||||||
|
await uow.rollback();
|
||||||
|
console.warn(`[RATE LIMIT] Transaction rollback: rate limit exceeded for key ${rateKey} at ${new Date().toISOString()}`);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Rate limit exceeded. Please try again later.'
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await uow.commit();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await uow.rollback(err);
|
||||||
|
if (res) {
|
||||||
|
console.error(`[RATE LIMIT] Transaction rollback: internal error for key ${rateKey} at ${new Date().toISOString()}`);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error (rate limiter)'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRateLimiter, checkAndIncrementRateLimit };
|
||||||
12
models/CompanyStamp.js
Normal file
12
models/CompanyStamp.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'company_id',
|
||||||
|
'label',
|
||||||
|
'mime_type',
|
||||||
|
'image_base64',
|
||||||
|
'is_active',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
]
|
||||||
|
};
|
||||||
27
models/CompanyUser.js
Normal file
27
models/CompanyUser.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const User = require('./User');
|
||||||
|
|
||||||
|
class CompanyUser extends User {
|
||||||
|
constructor(id, email, password, companyName, companyPhone, contactPersonName, contactPersonPhone, registrationNumber, createdAt, updatedAt, role) {
|
||||||
|
super(id, email, password, 'company', createdAt, updatedAt, role);
|
||||||
|
this.companyName = companyName;
|
||||||
|
this.companyPhone = companyPhone;
|
||||||
|
this.contactPersonName = contactPersonName;
|
||||||
|
this.contactPersonPhone = contactPersonPhone;
|
||||||
|
this.registrationNumber = registrationNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override getPublicData to include company-specific fields
|
||||||
|
getPublicData() {
|
||||||
|
const baseData = super.getPublicData();
|
||||||
|
return {
|
||||||
|
...baseData,
|
||||||
|
companyName: this.companyName,
|
||||||
|
companyPhone: this.companyPhone,
|
||||||
|
contactPersonName: this.contactPersonName,
|
||||||
|
contactPersonPhone: this.contactPersonPhone,
|
||||||
|
registrationNumber: this.registrationNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyUser;
|
||||||
4
models/DocumentTemplate.js
Normal file
4
models/DocumentTemplate.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// This file is now just a field reference for document_templates table
|
||||||
|
module.exports = {
|
||||||
|
fields: ['id', 'name', 'type', 'storageKey', 'description', 'lang', 'version', 'state', 'createdAt', 'updatedAt']
|
||||||
|
};
|
||||||
10
models/Permission.js
Normal file
10
models/Permission.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
class Permission {
|
||||||
|
constructor({ id, name, description, is_active }) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
this.is_active = is_active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Permission;
|
||||||
32
models/PersonalUser.js
Normal file
32
models/PersonalUser.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const User = require('./User');
|
||||||
|
|
||||||
|
class PersonalUser extends User {
|
||||||
|
constructor(id, email, password, firstName, lastName, phone, dateOfBirth, referralEmail, createdAt, updatedAt, role) {
|
||||||
|
super(id, email, password, 'personal', createdAt, updatedAt, role);
|
||||||
|
this.firstName = firstName;
|
||||||
|
this.lastName = lastName;
|
||||||
|
this.phone = phone;
|
||||||
|
this.dateOfBirth = dateOfBirth;
|
||||||
|
this.referralEmail = referralEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full name
|
||||||
|
getFullName() {
|
||||||
|
return `${this.firstName} ${this.lastName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override getPublicData to include personal-specific fields
|
||||||
|
getPublicData() {
|
||||||
|
const baseData = super.getPublicData();
|
||||||
|
return {
|
||||||
|
...baseData,
|
||||||
|
firstName: this.firstName,
|
||||||
|
lastName: this.lastName,
|
||||||
|
fullName: this.getFullName(),
|
||||||
|
phone: this.phone,
|
||||||
|
dateOfBirth: this.dateOfBirth
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalUser;
|
||||||
27
models/ReferralToken.js
Normal file
27
models/ReferralToken.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
class ReferralToken {
|
||||||
|
constructor({
|
||||||
|
id,
|
||||||
|
token,
|
||||||
|
createdByUserId,
|
||||||
|
expiresAt,
|
||||||
|
maxUses,
|
||||||
|
usesRemaining,
|
||||||
|
status,
|
||||||
|
deactivationReason,
|
||||||
|
createdAt,
|
||||||
|
updatedAt
|
||||||
|
}) {
|
||||||
|
this.id = id;
|
||||||
|
this.token = token;
|
||||||
|
this.createdByUserId = createdByUserId;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.maxUses = maxUses;
|
||||||
|
this.usesRemaining = usesRemaining;
|
||||||
|
this.status = status;
|
||||||
|
this.deactivationReason = deactivationReason;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ReferralToken;
|
||||||
64
models/User.js
Normal file
64
models/User.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const db = require('../database/database');
|
||||||
|
const argon2 = require('argon2');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
class User {
|
||||||
|
constructor(id, email, password, userType, createdAt, updatedAt, role) {
|
||||||
|
this.id = id;
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
this.userType = userType; // 'personal' or 'company'
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
this.role = role; // Add role property
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
static async hashPassword(password) {
|
||||||
|
console.log('🔐 Hashing password with Argon2...');
|
||||||
|
return await argon2.hash(password, {
|
||||||
|
type: argon2.argon2i,
|
||||||
|
memoryCost: 2 ** 16, // 64 MB
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare password
|
||||||
|
async comparePassword(password) {
|
||||||
|
console.log('🔍 Comparing password with Argon2...');
|
||||||
|
return await argon2.verify(this.password, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
generateToken() {
|
||||||
|
console.log('🎫 Generating JWT token for user:', this.id);
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
userId: this.id,
|
||||||
|
email: this.email,
|
||||||
|
userType: this.userType
|
||||||
|
},
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: process.env.JWT_EXPIRES_IN }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
static verifyToken(token) {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Token verification failed:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user basic info (without password)
|
||||||
|
getPublicData() {
|
||||||
|
const { password, ...publicData } = this;
|
||||||
|
return publicData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = User;
|
||||||
6921
package-lock.json
generated
Normal file
6921
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "central-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Central server for handling frontend requests",
|
||||||
|
"keywords": [
|
||||||
|
"express",
|
||||||
|
"server",
|
||||||
|
"api",
|
||||||
|
"mysql"
|
||||||
|
],
|
||||||
|
"license": "ISC",
|
||||||
|
"author": "Alex Ibrahim",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
|
"@getbrevo/brevo": "^3.0.1",
|
||||||
|
"argon2": "^0.31.2",
|
||||||
|
"aws-sdk": "^2.1692.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"get-stream": "^9.0.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"mysql2": "^3.14.3",
|
||||||
|
"nodemailer": "^6.9.9",
|
||||||
|
"pdfkit": "^0.17.1",
|
||||||
|
"pidusage": "^4.0.1",
|
||||||
|
"puppeteer": "^24.16.2",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"winston-daily-rotate-file": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
238
repositories/AdminRepository.js
Normal file
238
repositories/AdminRepository.js
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class AdminRepository {
|
||||||
|
static async getUserStats(conn) {
|
||||||
|
logger.info('AdminRepository.getUserStats:start');
|
||||||
|
try {
|
||||||
|
const [[{ totalUsers }]] = await conn.query(`SELECT COUNT(*) AS totalUsers FROM users`);
|
||||||
|
const [[{ adminUsers }]] = await conn.query(`SELECT COUNT(*) AS adminUsers FROM users WHERE role IN ('admin', 'super_admin')`);
|
||||||
|
const [[{ verificationPending }]] = await conn.query(`
|
||||||
|
SELECT COUNT(*) AS verificationPending
|
||||||
|
FROM user_status
|
||||||
|
WHERE
|
||||||
|
status = 'pending'
|
||||||
|
AND email_verified = 1
|
||||||
|
AND profile_completed = 1
|
||||||
|
AND documents_uploaded = 1
|
||||||
|
AND contract_signed = 1
|
||||||
|
AND is_admin_verified = 0
|
||||||
|
`);
|
||||||
|
const [[{ activeUsers }]] = await conn.query(`
|
||||||
|
SELECT COUNT(*) AS activeUsers
|
||||||
|
FROM user_status us
|
||||||
|
JOIN users u ON us.user_id = u.id
|
||||||
|
WHERE us.is_admin_verified = 1 AND u.role = 'user'
|
||||||
|
`);
|
||||||
|
const [[{ personalUsers }]] = await conn.query(`SELECT COUNT(*) AS personalUsers FROM users WHERE user_type = 'personal'`);
|
||||||
|
const [[{ companyUsers }]] = await conn.query(`SELECT COUNT(*) AS companyUsers FROM users WHERE user_type = 'company'`);
|
||||||
|
logger.info('AdminRepository.getUserStats:success', { totalUsers, adminUsers, verificationPending, activeUsers, personalUsers, companyUsers });
|
||||||
|
return { totalUsers, adminUsers, verificationPending, activeUsers, personalUsers, companyUsers };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getUserStats:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserList(conn) {
|
||||||
|
logger.info('AdminRepository.getUserList:start');
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(`
|
||||||
|
SELECT
|
||||||
|
u.id, u.email, u.user_type, u.role, u.created_at, u.last_login_at,
|
||||||
|
us.status, us.is_admin_verified,
|
||||||
|
pp.first_name, pp.last_name,
|
||||||
|
cp.company_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_status us ON u.id = us.user_id
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
`);
|
||||||
|
logger.info('AdminRepository.getUserList:success', { count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getUserList:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getVerificationPendingUsers(conn) {
|
||||||
|
logger.info('AdminRepository.getVerificationPendingUsers:start');
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(`
|
||||||
|
SELECT
|
||||||
|
u.id, u.email, u.user_type, u.role, u.created_at, u.last_login_at,
|
||||||
|
us.status, us.is_admin_verified,
|
||||||
|
pp.first_name, pp.last_name,
|
||||||
|
cp.company_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_status us ON u.id = us.user_id
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
WHERE
|
||||||
|
us.status = 'pending'
|
||||||
|
AND us.email_verified = 1
|
||||||
|
AND us.profile_completed = 1
|
||||||
|
AND us.documents_uploaded = 1
|
||||||
|
AND us.contract_signed = 1
|
||||||
|
AND us.is_admin_verified = 0
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
`);
|
||||||
|
logger.info('AdminRepository.getVerificationPendingUsers:success', { count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getVerificationPendingUsers:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserDocuments(conn, userId) {
|
||||||
|
logger.info('AdminRepository.getUserDocuments:start', { userId });
|
||||||
|
try {
|
||||||
|
const [documents] = await conn.query(`SELECT * FROM user_documents WHERE user_id = ?`, [userId]);
|
||||||
|
logger.info('AdminRepository.getUserDocuments:success', { userId, count: documents.length });
|
||||||
|
return documents;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getUserDocuments:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserContracts(conn, userId) {
|
||||||
|
logger.info('AdminRepository.getUserContracts:start', { userId });
|
||||||
|
try {
|
||||||
|
const [contracts] = await conn.query(
|
||||||
|
`SELECT * FROM user_documents WHERE user_id = ? AND document_type = 'contract'`, [userId]
|
||||||
|
);
|
||||||
|
logger.info('AdminRepository.getUserContracts:success', { userId, count: contracts.length });
|
||||||
|
return contracts;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getUserContracts:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserIdDocuments(conn, userId) {
|
||||||
|
logger.info('AdminRepository.getUserIdDocuments:start', { userId });
|
||||||
|
try {
|
||||||
|
const [idDocs] = await conn.query(`SELECT * FROM user_id_documents WHERE user_id = ?`, [userId]);
|
||||||
|
logger.info('AdminRepository.getUserIdDocuments:success', { userId, count: idDocs.length });
|
||||||
|
return idDocs;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getUserIdDocuments:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyUser(conn, userId) {
|
||||||
|
logger.info('AdminRepository.verifyUser:start', { userId });
|
||||||
|
try {
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE user_status SET is_admin_verified = 1, admin_verified_at = NOW(), status = 'active' WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('AdminRepository.verifyUser:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.verifyUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async assignPermissions(conn, userId, permissions) {
|
||||||
|
logger.info('AdminRepository.assignPermissions:start', { userId, permissions });
|
||||||
|
try {
|
||||||
|
const [permRows] = await conn.query(
|
||||||
|
`SELECT id, name FROM permissions WHERE name IN (?)`, [permissions]
|
||||||
|
);
|
||||||
|
for (const perm of permRows) {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT IGNORE INTO user_permissions (user_id, permission_id) VALUES (?, ?)`,
|
||||||
|
[userId, perm.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('AdminRepository.assignPermissions:success', { userId, permissions });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.assignPermissions:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserById(conn, userId) {
|
||||||
|
logger.info('AdminRepository.getUserById:start', { userId });
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(`SELECT * FROM users WHERE id = ? LIMIT 1`, [userId]);
|
||||||
|
logger.info('AdminRepository.getUserById:success', { userId, found: !!(rows.length) });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getUserById:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getPersonalProfile(conn, userId) {
|
||||||
|
logger.info('AdminRepository.getPersonalProfile:start', { userId });
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(`SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1`, [userId]);
|
||||||
|
logger.info('AdminRepository.getPersonalProfile:success', { userId, found: !!(rows.length) });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getPersonalProfile:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getCompanyProfile(conn, userId) {
|
||||||
|
logger.info('AdminRepository.getCompanyProfile:start', { userId });
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(`SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1`, [userId]);
|
||||||
|
logger.info('AdminRepository.getCompanyProfile:success', { userId, found: !!(rows.length) });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getCompanyProfile:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserPermissions(conn, userId) {
|
||||||
|
logger.info('AdminRepository.getUserPermissions:start', { userId });
|
||||||
|
try {
|
||||||
|
const [permRows] = await conn.query(
|
||||||
|
`SELECT p.id, p.name, p.description, p.is_active
|
||||||
|
FROM user_permissions up
|
||||||
|
JOIN permissions p ON up.permission_id = p.id
|
||||||
|
WHERE up.user_id = ? AND p.is_active = TRUE`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('AdminRepository.getUserPermissions:success', { userId, count: permRows.length });
|
||||||
|
return permRows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.getUserPermissions:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateUserPermissions(conn, userId, permissions) {
|
||||||
|
logger.info('AdminRepository.updateUserPermissions:start', { userId, permissions });
|
||||||
|
try {
|
||||||
|
await conn.query(`DELETE FROM user_permissions WHERE user_id = ?`, [userId]);
|
||||||
|
if (permissions.length > 0) {
|
||||||
|
const [permRows] = await conn.query(
|
||||||
|
`SELECT id, name FROM permissions WHERE name IN (?) AND is_active = TRUE`, [permissions]
|
||||||
|
);
|
||||||
|
const permIds = permRows.map(row => row.id);
|
||||||
|
if (permIds.length > 0) {
|
||||||
|
const values = permIds.map(pid => [userId, pid]);
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO user_permissions (user_id, permission_id) VALUES ?`, [values]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info('AdminRepository.updateUserPermissions:success', { userId, permissions });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminRepository.updateUserPermissions:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AdminRepository;
|
||||||
67
repositories/CompanyStampRepository.js
Normal file
67
repositories/CompanyStampRepository.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
const db = require('../database/database');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class CompanyStampRepository {
|
||||||
|
async create(data, conn) {
|
||||||
|
logger.info('CompanyStampRepository.create:start', { company_id: data.company_id, label: data.label });
|
||||||
|
const q = `
|
||||||
|
INSERT INTO company_stamps (company_id, label, mime_type, image_base64, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
`;
|
||||||
|
const params = [data.company_id, data.label || null, data.mime_type, data.image_base64, !!data.is_active];
|
||||||
|
const executor = conn || db;
|
||||||
|
const [res] = await executor.execute(q, params);
|
||||||
|
const id = res.insertId;
|
||||||
|
logger.info('CompanyStampRepository.create:success', { id });
|
||||||
|
return { id, ...data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id, conn) {
|
||||||
|
const q = `SELECT * FROM company_stamps WHERE id = ? LIMIT 1`;
|
||||||
|
const executor = conn || db;
|
||||||
|
const [rows] = await executor.execute(q, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCompanyId(companyId, conn) {
|
||||||
|
const q = `SELECT * FROM company_stamps WHERE company_id = ? ORDER BY created_at DESC`;
|
||||||
|
const executor = conn || db;
|
||||||
|
const [rows] = await executor.execute(q, [companyId]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveByCompanyId(companyId, conn) {
|
||||||
|
const q = `SELECT * FROM company_stamps WHERE company_id = ? AND is_active = 1 LIMIT 1`;
|
||||||
|
const executor = conn || db;
|
||||||
|
const [rows] = await executor.execute(q, [companyId]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateAllForCompany(companyId, conn) {
|
||||||
|
const q = `UPDATE company_stamps SET is_active = 0, updated_at = NOW() WHERE company_id = ? AND is_active = 1`;
|
||||||
|
const executor = conn || db;
|
||||||
|
await executor.execute(q, [companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(id, companyId, conn) {
|
||||||
|
const q = `UPDATE company_stamps SET is_active = 1, updated_at = NOW() WHERE id = ? AND company_id = ?`;
|
||||||
|
const executor = conn || db;
|
||||||
|
await executor.execute(q, [id, companyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id, companyId, conn) {
|
||||||
|
const q = `DELETE FROM company_stamps WHERE id = ? AND company_id = ?`;
|
||||||
|
const executor = conn || db;
|
||||||
|
const [res] = await executor.execute(q, [id, companyId]);
|
||||||
|
return res.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAll(conn) {
|
||||||
|
const q = `SELECT * FROM company_stamps ORDER BY created_at DESC`;
|
||||||
|
const executor = conn || db;
|
||||||
|
const [rows] = await executor.execute(q);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CompanyStampRepository();
|
||||||
197
repositories/CompanyUserRepository.js
Normal file
197
repositories/CompanyUserRepository.js
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
const CompanyUser = require('../models/CompanyUser');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class CompanyUserRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create({ companyEmail, password, companyName, companyPhone, contactPersonName, contactPersonPhone }) {
|
||||||
|
logger.info('CompanyUserRepository.create:start', { companyEmail, companyName });
|
||||||
|
try {
|
||||||
|
console.log('📊 CompanyUserRepository: Creating company user in database...');
|
||||||
|
const hashedPassword = await User.hashPassword(password);
|
||||||
|
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
// 1. Insert into users table
|
||||||
|
const userQuery = `
|
||||||
|
INSERT INTO users (email, password, user_type, role)
|
||||||
|
VALUES (?, ?, 'company', 'user')
|
||||||
|
`;
|
||||||
|
const [userResult] = await conn.query(userQuery, [companyEmail, hashedPassword]);
|
||||||
|
const userId = userResult.insertId;
|
||||||
|
|
||||||
|
logger.info('CompanyUserRepository.create:user_created', { userId });
|
||||||
|
console.log('✅ User record created with ID:', userId);
|
||||||
|
|
||||||
|
// 2. Insert into company_profiles table
|
||||||
|
const profileQuery = `
|
||||||
|
INSERT INTO company_profiles (user_id, company_name, phone, contact_person_name, contact_person_phone)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
await conn.query(profileQuery, [
|
||||||
|
userId,
|
||||||
|
companyName,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info('CompanyUserRepository.create:profile_created', { userId, companyName });
|
||||||
|
console.log('✅ Company profile created');
|
||||||
|
|
||||||
|
logger.info('CompanyUserRepository.create:success', { userId });
|
||||||
|
return new CompanyUser(
|
||||||
|
userId,
|
||||||
|
companyEmail,
|
||||||
|
hashedPassword,
|
||||||
|
companyName,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone,
|
||||||
|
null, // registrationNumber is now null at registration
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyUserRepository.create:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email) {
|
||||||
|
logger.info('CompanyUserRepository.findByEmail:start', { email });
|
||||||
|
try {
|
||||||
|
console.log('🔍 CompanyUserRepository: Querying database for company user...');
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
SELECT u.*, cp.company_name, cp.registration_number, cp.phone as company_phone,
|
||||||
|
cp.contact_person_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
WHERE u.email = ? AND u.user_type = 'company'
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [email]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('CompanyUserRepository.findByEmail:found', { email, userId: rows[0].id });
|
||||||
|
const row = rows[0];
|
||||||
|
return new CompanyUser(
|
||||||
|
row.id,
|
||||||
|
row.email,
|
||||||
|
row.password,
|
||||||
|
row.company_name,
|
||||||
|
row.company_phone,
|
||||||
|
row.contact_person_name,
|
||||||
|
null,
|
||||||
|
row.registration_number,
|
||||||
|
row.created_at,
|
||||||
|
row.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('CompanyUserRepository.findByEmail:not_found', { email });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyUserRepository.findByEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(userId) {
|
||||||
|
logger.info('CompanyUserRepository.findById:start', { userId });
|
||||||
|
try {
|
||||||
|
console.log('🔍 CompanyUserRepository: Querying database for company user by ID...');
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
SELECT u.*, cp.company_name, cp.registration_number, cp.phone as company_phone,
|
||||||
|
cp.contact_person_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
WHERE u.id = ? AND u.user_type = 'company'
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [userId]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('CompanyUserRepository.findById:found', { userId });
|
||||||
|
const row = rows[0];
|
||||||
|
return new CompanyUser(
|
||||||
|
row.id,
|
||||||
|
row.email,
|
||||||
|
row.password,
|
||||||
|
row.company_name,
|
||||||
|
row.company_phone,
|
||||||
|
row.contact_person_name,
|
||||||
|
null,
|
||||||
|
row.registration_number,
|
||||||
|
row.created_at,
|
||||||
|
row.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('CompanyUserRepository.findById:not_found', { userId });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyUserRepository.findById:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfileAndMarkCompleted(userId, profileData) {
|
||||||
|
logger.info('CompanyUserRepository.updateProfileAndMarkCompleted:start', { userId });
|
||||||
|
try {
|
||||||
|
console.log('Updating profile and marking registration as completed for user ID:', userId);
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const {
|
||||||
|
address,
|
||||||
|
zip_code,
|
||||||
|
city,
|
||||||
|
country, // Add country here
|
||||||
|
branch,
|
||||||
|
numberOfEmployees,
|
||||||
|
registrationNumber,
|
||||||
|
businessType,
|
||||||
|
iban,
|
||||||
|
accountHolderName
|
||||||
|
} = profileData;
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE company_profiles SET
|
||||||
|
address = ?, zip_code = ?, city = ?, country = ?, branch = ?, number_of_employees = ?,
|
||||||
|
registration_number = ?, business_type = ?, account_holder_name = ?
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
[
|
||||||
|
address,
|
||||||
|
zip_code,
|
||||||
|
city,
|
||||||
|
country, // Add country to parameter list
|
||||||
|
branch,
|
||||||
|
numberOfEmployees,
|
||||||
|
registrationNumber,
|
||||||
|
businessType,
|
||||||
|
accountHolderName,
|
||||||
|
userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('CompanyUserRepository.updateProfileAndMarkCompleted:profile_updated', { userId });
|
||||||
|
|
||||||
|
// Update IBAN in users table if provided
|
||||||
|
if (iban) {
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE users SET iban = ? WHERE id = ?`,
|
||||||
|
[iban, userId]
|
||||||
|
);
|
||||||
|
logger.info('CompanyUserRepository.updateProfileAndMarkCompleted:iban_updated', { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE user_status SET profile_completed = 1, profile_completed_at = NOW() WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('CompanyUserRepository.updateProfileAndMarkCompleted:profile_completed', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyUserRepository.updateProfileAndMarkCompleted:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyUserRepository;
|
||||||
163
repositories/DocumentTemplateRepository.js
Normal file
163
repositories/DocumentTemplateRepository.js
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
const db = require('../database/database');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class DocumentTemplateRepository {
|
||||||
|
async create(data, conn) {
|
||||||
|
logger.info('DocumentTemplateRepository.create:start', { name: data.name, type: data.type });
|
||||||
|
const { name, type, storageKey, description, lang } = data;
|
||||||
|
const user_type = (data.user_type || data.userType || 'both');
|
||||||
|
const query = `
|
||||||
|
INSERT INTO document_templates (name, type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, 'inactive', NOW(), NOW())
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
if (conn) {
|
||||||
|
const [res] = await conn.execute(query, [name, type, storageKey, description, lang, user_type]);
|
||||||
|
const insertId = res && (res.insertId || res[0]?.insertId);
|
||||||
|
logger.info('DocumentTemplateRepository.create:success', { id: insertId || res.insertId });
|
||||||
|
return { id: insertId || res.insertId, name, type, storageKey, description, lang, user_type, version: 1, state: 'inactive' };
|
||||||
|
}
|
||||||
|
const result = await db.execute(query, [name, type, storageKey, description, lang, user_type]);
|
||||||
|
const insertId = result && result.insertId ? result.insertId : (Array.isArray(result) && result[0] && result[0].insertId ? result[0].insertId : undefined);
|
||||||
|
logger.info('DocumentTemplateRepository.create:success', { id: insertId });
|
||||||
|
return { id: insertId, name, type, storageKey, description, lang, user_type, version: 1, state: 'inactive' };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateRepository.create:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(conn) {
|
||||||
|
logger.info('DocumentTemplateRepository.findAll:start');
|
||||||
|
const query = `SELECT * FROM document_templates ORDER BY createdAt DESC`;
|
||||||
|
try {
|
||||||
|
if (conn) {
|
||||||
|
const [rows] = await conn.execute(query);
|
||||||
|
logger.info('DocumentTemplateRepository.findAll:success');
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const result = await db.execute(query);
|
||||||
|
// db.execute may return rows directly or [rows, fields]
|
||||||
|
if (Array.isArray(result) && Array.isArray(result[0])) return result[0];
|
||||||
|
logger.info('DocumentTemplateRepository.findAll:success');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateRepository.findAll:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id, conn) {
|
||||||
|
logger.info('DocumentTemplateRepository.findById:start', { id });
|
||||||
|
const query = `SELECT * FROM document_templates WHERE id = ? LIMIT 1`;
|
||||||
|
try {
|
||||||
|
if (conn) {
|
||||||
|
const [rows] = await conn.execute(query, [id]);
|
||||||
|
logger.info('DocumentTemplateRepository.findById:success', { id });
|
||||||
|
return (rows && rows[0]) ? rows[0] : null;
|
||||||
|
}
|
||||||
|
const result = await db.execute(query, [id]);
|
||||||
|
if (Array.isArray(result) && Array.isArray(result[0])) {
|
||||||
|
return result[0][0] || null;
|
||||||
|
}
|
||||||
|
logger.info('DocumentTemplateRepository.findById:success', { id });
|
||||||
|
return (result && result[0]) ? result[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateRepository.findById:error', { id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id, data, conn) {
|
||||||
|
logger.info('DocumentTemplateRepository.update:start', { id, data });
|
||||||
|
// data: { name, type, storageKey, description, lang, version }
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
for (const key of ['name', 'type', 'storageKey', 'description', 'lang', 'version', 'user_type']) {
|
||||||
|
if (data[key] !== undefined) {
|
||||||
|
fields.push(`${key} = ?`);
|
||||||
|
values.push(data[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Support camelCase input
|
||||||
|
if (data.userType !== undefined && data.user_type === undefined) {
|
||||||
|
fields.push(`user_type = ?`);
|
||||||
|
values.push(data.userType);
|
||||||
|
}
|
||||||
|
// Do not update state here
|
||||||
|
if (!fields.length) return false;
|
||||||
|
const query = `
|
||||||
|
UPDATE document_templates SET ${fields.join(', ')}, updatedAt = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
`;
|
||||||
|
values.push(id);
|
||||||
|
try {
|
||||||
|
if (conn) await conn.execute(query, values);
|
||||||
|
else await db.execute(query, values);
|
||||||
|
logger.info('DocumentTemplateRepository.update:success', { id });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateRepository.update:error', { id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateState(id, state, conn) {
|
||||||
|
logger.info('DocumentTemplateRepository.updateState:start', { id, state });
|
||||||
|
const query = `
|
||||||
|
UPDATE document_templates SET state = ?, updatedAt = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
if (conn) await conn.execute(query, [state, id]);
|
||||||
|
else await db.execute(query, [state, id]);
|
||||||
|
logger.info('DocumentTemplateRepository.updateState:success', { id, state });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateRepository.updateState:error', { id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id, conn) {
|
||||||
|
logger.info('DocumentTemplateRepository.delete:start', { id });
|
||||||
|
const query = `DELETE FROM document_templates WHERE id = ?`;
|
||||||
|
try {
|
||||||
|
if (conn) await conn.execute(query, [id]);
|
||||||
|
else await db.execute(query, [id]);
|
||||||
|
logger.info('DocumentTemplateRepository.delete:success', { id });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateRepository.delete:error', { id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveByUserType(userType, templateType = null, conn) {
|
||||||
|
logger.info('DocumentTemplateRepository.findActiveByUserType:start', { userType, templateType });
|
||||||
|
const safeType = (userType === 'personal' || userType === 'company') ? userType : 'personal';
|
||||||
|
let query = `SELECT * FROM document_templates WHERE state = 'active' AND (user_type = ? OR user_type = 'both')`;
|
||||||
|
const params = [safeType];
|
||||||
|
if (templateType) {
|
||||||
|
query += ` AND type = ?`;
|
||||||
|
params.push(templateType);
|
||||||
|
}
|
||||||
|
query += ` ORDER BY createdAt DESC`;
|
||||||
|
try {
|
||||||
|
if (conn) {
|
||||||
|
const [rows] = await conn.execute(query, params);
|
||||||
|
logger.info('DocumentTemplateRepository.findActiveByUserType:success', { count: rows.length });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const result = await db.execute(query, params);
|
||||||
|
const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
|
||||||
|
logger.info('DocumentTemplateRepository.findActiveByUserType:success', { count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateRepository.findActiveByUserType:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new DocumentTemplateRepository();
|
||||||
94
repositories/EmailVerificationRepository.js
Normal file
94
repositories/EmailVerificationRepository.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class EmailVerificationRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertCode(userId, code, expiresAt) {
|
||||||
|
logger.info('EmailVerificationRepository.upsertCode:start', { userId });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
// Try to update first
|
||||||
|
const [result] = await conn.query(
|
||||||
|
`UPDATE email_verifications SET verification_code = ?, expires_at = ?, verified_at = NULL, attempts = 0 WHERE user_id = ? AND (verified_at IS NULL OR expires_at > NOW())`,
|
||||||
|
[code, expiresAt, userId]
|
||||||
|
);
|
||||||
|
if (result.affectedRows === 0) {
|
||||||
|
// Insert if no row updated
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO email_verifications (user_id, verification_code, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
[userId, code, expiresAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('EmailVerificationRepository.upsertCode:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EmailVerificationRepository.upsertCode:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUserId(userId) {
|
||||||
|
logger.info('EmailVerificationRepository.getByUserId:start', { userId });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM email_verifications WHERE user_id = ? ORDER BY id DESC LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('EmailVerificationRepository.getByUserId:success', { userId, found: rows.length > 0 });
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EmailVerificationRepository.getByUserId:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async incrementAttempts(id) {
|
||||||
|
logger.info('EmailVerificationRepository.incrementAttempts:start', { id });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE email_verifications SET attempts = attempts + 1 WHERE id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
logger.info('EmailVerificationRepository.incrementAttempts:success', { id });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EmailVerificationRepository.incrementAttempts:error', { id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVerified(id) {
|
||||||
|
logger.info('EmailVerificationRepository.setVerified:start', { id });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE email_verifications SET verified_at = NOW() WHERE id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
logger.info('EmailVerificationRepository.setVerified:success', { id });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EmailVerificationRepository.setVerified:error', { id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBasic(userId) {
|
||||||
|
logger.info('EmailVerificationRepository.getUserBasic:start', { userId });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT id, email FROM users WHERE id = ? LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('EmailVerificationRepository.getUserBasic:success', { userId, found: rows.length > 0 });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EmailVerificationRepository.getUserBasic:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = EmailVerificationRepository;
|
||||||
99
repositories/LoginRepository.js
Normal file
99
repositories/LoginRepository.js
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class LoginRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
this.conn = unitOfWork.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertRefreshToken(userId, token, expiresAt) {
|
||||||
|
logger.info('LoginRepository.insertRefreshToken:start', { userId });
|
||||||
|
try {
|
||||||
|
await this.conn.query(
|
||||||
|
`INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
[userId, token, expiresAt]
|
||||||
|
);
|
||||||
|
logger.info('LoginRepository.insertRefreshToken:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginRepository.insertRefreshToken:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRefreshToken(token) {
|
||||||
|
logger.info('LoginRepository.findRefreshToken:start');
|
||||||
|
try {
|
||||||
|
const [rows] = await this.conn.query(
|
||||||
|
`SELECT user_id, expires_at FROM refresh_tokens WHERE token = ? AND revoked_at IS NULL`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
logger.info('LoginRepository.findRefreshToken:success', { found: !!rows.length });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginRepository.findRefreshToken:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeRefreshToken(token) {
|
||||||
|
logger.info('LoginRepository.revokeRefreshToken:start');
|
||||||
|
try {
|
||||||
|
await this.conn.query(
|
||||||
|
`UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token = ?`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
logger.info('LoginRepository.revokeRefreshToken:success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginRepository.revokeRefreshToken:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLastLogin(userId) {
|
||||||
|
logger.info('LoginRepository.updateLastLogin:start', { userId });
|
||||||
|
try {
|
||||||
|
await this.conn.query(
|
||||||
|
`UPDATE users SET last_login_at = NOW() WHERE id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('LoginRepository.updateLastLogin:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginRepository.updateLastLogin:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPermissions(userId) {
|
||||||
|
logger.info('LoginRepository.getUserPermissions:start', { userId });
|
||||||
|
try {
|
||||||
|
const [permRows] = await this.conn.query(
|
||||||
|
`SELECT p.name FROM user_permissions up
|
||||||
|
JOIN permissions p ON up.permission_id = p.id
|
||||||
|
WHERE up.user_id = ? AND p.is_active = TRUE`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('LoginRepository.getUserPermissions:success', { userId, count: permRows.length });
|
||||||
|
return permRows.map(row => row.name);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginRepository.getUserPermissions:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserRole(userId) {
|
||||||
|
logger.info('LoginRepository.getUserRole:start', { userId });
|
||||||
|
try {
|
||||||
|
const [roleRows] = await this.conn.query(
|
||||||
|
`SELECT role FROM users WHERE id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('LoginRepository.getUserRole:success', { userId, role: roleRows.length ? roleRows[0].role : null });
|
||||||
|
return roleRows.length ? roleRows[0].role : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginRepository.getUserRole:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LoginRepository;
|
||||||
86
repositories/PasswordResetRepository.js
Normal file
86
repositories/PasswordResetRepository.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class PasswordResetRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
this.conn = unitOfWork.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findValidTokenByUserId(userId) {
|
||||||
|
logger.info('PasswordResetRepository.findValidTokenByUserId:start', { userId });
|
||||||
|
try {
|
||||||
|
const [rows] = await this.conn.query(
|
||||||
|
`SELECT * FROM password_resets WHERE user_id = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('PasswordResetRepository.findValidTokenByUserId:success', { userId, found: rows.length > 0 });
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PasswordResetRepository.findValidTokenByUserId:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createToken(userId, token, expiresAt) {
|
||||||
|
logger.info('PasswordResetRepository.createToken:start', { userId, token, expiresAt });
|
||||||
|
try {
|
||||||
|
await this.conn.query(
|
||||||
|
`INSERT INTO password_resets (user_id, token, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
[userId, token, expiresAt]
|
||||||
|
);
|
||||||
|
logger.info('PasswordResetRepository.createToken:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PasswordResetRepository.createToken:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findValidToken(token) {
|
||||||
|
logger.info('PasswordResetRepository.findValidToken:start', { token });
|
||||||
|
try {
|
||||||
|
const [rows] = await this.conn.query(
|
||||||
|
`SELECT * FROM password_resets WHERE token = ? AND used_at IS NULL LIMIT 1`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
logger.info('PasswordResetRepository.findValidToken:success', { token, found: !!row });
|
||||||
|
if (!row || new Date(row.expires_at) < new Date()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PasswordResetRepository.findValidToken:error', { token, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUserPassword(userId, hashedPassword) {
|
||||||
|
logger.info('PasswordResetRepository.updateUserPassword:start', { userId });
|
||||||
|
try {
|
||||||
|
await this.conn.query(
|
||||||
|
`UPDATE users SET password = ? WHERE id = ?`,
|
||||||
|
[hashedPassword, userId]
|
||||||
|
);
|
||||||
|
logger.info('PasswordResetRepository.updateUserPassword:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PasswordResetRepository.updateUserPassword:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markTokenUsed(tokenId) {
|
||||||
|
logger.info('PasswordResetRepository.markTokenUsed:start', { tokenId });
|
||||||
|
try {
|
||||||
|
await this.conn.query(
|
||||||
|
`UPDATE password_resets SET used_at = NOW() WHERE id = ?`,
|
||||||
|
[tokenId]
|
||||||
|
);
|
||||||
|
logger.info('PasswordResetRepository.markTokenUsed:success', { tokenId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PasswordResetRepository.markTokenUsed:error', { tokenId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PasswordResetRepository;
|
||||||
66
repositories/PermissionRepository.js
Normal file
66
repositories/PermissionRepository.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
const Permission = require('../models/Permission');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class PermissionRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPermissions() {
|
||||||
|
logger.info('PermissionRepository.getAllPermissions:start');
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT id, name, description, is_active FROM permissions`
|
||||||
|
);
|
||||||
|
logger.info('PermissionRepository.getAllPermissions:success', { count: rows.length });
|
||||||
|
return rows.map(row => new Permission(row));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PermissionRepository.getAllPermissions:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPermission({ name, description, is_active, created_by }) {
|
||||||
|
logger.info('PermissionRepository.createPermission:start', { name });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [result] = await conn.query(
|
||||||
|
`INSERT INTO permissions (name, description, is_active, created_by) VALUES (?, ?, ?, ?)`,
|
||||||
|
[name, description, is_active !== undefined ? is_active : true, created_by]
|
||||||
|
);
|
||||||
|
logger.info('PermissionRepository.createPermission:success', { id: result.insertId, name });
|
||||||
|
return new Permission({
|
||||||
|
id: result.insertId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_active: is_active !== undefined ? is_active : true,
|
||||||
|
created_by
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PermissionRepository.createPermission:error', { name, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissionsByUserId(userId) {
|
||||||
|
logger.info('PermissionRepository.getPermissionsByUserId:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT p.id, p.name, p.description, p.is_active
|
||||||
|
FROM user_permissions up
|
||||||
|
JOIN permissions p ON up.permission_id = p.id
|
||||||
|
WHERE up.user_id = ? AND p.is_active = TRUE`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('PermissionRepository.getPermissionsByUserId:success', { userId, count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PermissionRepository.getPermissionsByUserId:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PermissionRepository;
|
||||||
190
repositories/PersonalUserRepository.js
Normal file
190
repositories/PersonalUserRepository.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
const db = require('../database/database');
|
||||||
|
const PersonalUser = require('../models/PersonalUser');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class PersonalUserRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create({ email, password, firstName, lastName, phone, referralEmail }) {
|
||||||
|
logger.info('PersonalUserRepository.create:start', { email, firstName, lastName });
|
||||||
|
try {
|
||||||
|
console.log('📊 PersonalUserRepository: Creating personal user in database...');
|
||||||
|
const hashedPassword = await User.hashPassword(password);
|
||||||
|
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
// 1. Insert into users table
|
||||||
|
const userQuery = `
|
||||||
|
INSERT INTO users (email, password, user_type, role)
|
||||||
|
VALUES (?, ?, 'personal', 'user')
|
||||||
|
`;
|
||||||
|
const [userResult] = await conn.query(userQuery, [email, hashedPassword]);
|
||||||
|
const userId = userResult.insertId;
|
||||||
|
|
||||||
|
logger.info('PersonalUserRepository.create:user_created', { userId });
|
||||||
|
console.log('✅ User record created with ID:', userId);
|
||||||
|
|
||||||
|
// 2. Insert into personal_profiles table
|
||||||
|
const profileQuery = `
|
||||||
|
INSERT INTO personal_profiles (user_id, first_name, last_name, phone)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
await conn.query(profileQuery, [userId, firstName, lastName, phone]);
|
||||||
|
|
||||||
|
logger.info('PersonalUserRepository.create:profile_created', { userId });
|
||||||
|
console.log('✅ Personal profile created');
|
||||||
|
|
||||||
|
logger.info('PersonalUserRepository.create:success', { userId });
|
||||||
|
return new PersonalUser(
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
hashedPassword,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
null,
|
||||||
|
referralEmail,
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalUserRepository.create:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email) {
|
||||||
|
logger.info('PersonalUserRepository.findByEmail:start', { email });
|
||||||
|
try {
|
||||||
|
console.log('🔍 PersonalUserRepository: Querying database for personal user...');
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
SELECT u.*, pp.first_name, pp.last_name, pp.phone, pp.date_of_birth
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
WHERE u.email = ? AND u.user_type = 'personal'
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [email]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('PersonalUserRepository.findByEmail:found', { email, userId: rows[0].id });
|
||||||
|
const row = rows[0];
|
||||||
|
return new PersonalUser(
|
||||||
|
row.id,
|
||||||
|
row.email,
|
||||||
|
row.password,
|
||||||
|
row.first_name,
|
||||||
|
row.last_name,
|
||||||
|
row.phone,
|
||||||
|
row.date_of_birth,
|
||||||
|
null,
|
||||||
|
row.created_at,
|
||||||
|
row.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('PersonalUserRepository.findByEmail:not_found', { email });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalUserRepository.findByEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfileAndMarkCompleted(userId, profileData) {
|
||||||
|
logger.info('PersonalUserRepository.updateProfileAndMarkCompleted:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const {
|
||||||
|
dateOfBirth,
|
||||||
|
nationality,
|
||||||
|
address,
|
||||||
|
zip_code,
|
||||||
|
city, // Added city
|
||||||
|
country,
|
||||||
|
phoneSecondary,
|
||||||
|
emergencyContactName,
|
||||||
|
emergencyContactPhone,
|
||||||
|
accountHolderName,
|
||||||
|
iban // Added field
|
||||||
|
} = profileData;
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE personal_profiles SET
|
||||||
|
date_of_birth = ?, nationality = ?, address = ?, zip_code = ?, city = ?, country = ?,
|
||||||
|
phone_secondary = ?, emergency_contact_name = ?, emergency_contact_phone = ?,
|
||||||
|
account_holder_name = ?
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
[
|
||||||
|
dateOfBirth,
|
||||||
|
nationality,
|
||||||
|
address,
|
||||||
|
zip_code,
|
||||||
|
city, // Added city
|
||||||
|
country,
|
||||||
|
phoneSecondary,
|
||||||
|
emergencyContactName,
|
||||||
|
emergencyContactPhone,
|
||||||
|
accountHolderName,
|
||||||
|
userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('PersonalUserRepository.updateProfileAndMarkCompleted:profile_updated', { userId });
|
||||||
|
|
||||||
|
// Update IBAN in users table if provided
|
||||||
|
if (iban) {
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE users SET iban = ? WHERE id = ?`,
|
||||||
|
[iban, userId]
|
||||||
|
);
|
||||||
|
logger.info('PersonalUserRepository.updateProfileAndMarkCompleted:iban_updated', { userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE user_status SET profile_completed = 1, profile_completed_at = NOW() WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('PersonalUserRepository.updateProfileAndMarkCompleted:profile_completed', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalUserRepository.updateProfileAndMarkCompleted:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(userId) {
|
||||||
|
logger.info('PersonalUserRepository.findById:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
SELECT u.*, pp.first_name, pp.last_name, pp.phone, pp.date_of_birth
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
WHERE u.id = ? AND u.user_type = 'personal'
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [userId]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('PersonalUserRepository.findById:found', { userId });
|
||||||
|
const row = rows[0];
|
||||||
|
return new PersonalUser(
|
||||||
|
row.id,
|
||||||
|
row.email,
|
||||||
|
row.password,
|
||||||
|
row.first_name,
|
||||||
|
row.last_name,
|
||||||
|
row.phone,
|
||||||
|
row.date_of_birth,
|
||||||
|
null,
|
||||||
|
row.created_at,
|
||||||
|
row.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('PersonalUserRepository.findById:not_found', { userId });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalUserRepository.findById:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalUserRepository;
|
||||||
133
repositories/RateLimitRepository.js
Normal file
133
repositories/RateLimitRepository.js
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class RateLimitRepository {
|
||||||
|
constructor(connection) {
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and lock the rate limit row for a key and window_start.
|
||||||
|
* @param {string} rateKey
|
||||||
|
* @param {Date} windowStart
|
||||||
|
* @returns {Promise<Object|null>}
|
||||||
|
*/
|
||||||
|
async getForUpdate(rateKey, windowStart) {
|
||||||
|
logger.info('RateLimitRepository.getForUpdate:start', { rateKey, windowStart });
|
||||||
|
try {
|
||||||
|
const [rows] = await this.connection.query(
|
||||||
|
`SELECT * FROM rate_limit WHERE rate_key = ? AND window_start = ? FOR UPDATE`,
|
||||||
|
[rateKey, windowStart]
|
||||||
|
);
|
||||||
|
logger.info('RateLimitRepository.getForUpdate:success', { rateKey, found: !!rows[0] });
|
||||||
|
return rows[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('RateLimitRepository.getForUpdate:error', { rateKey, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new rate limit row.
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async insert(data) {
|
||||||
|
logger.info('RateLimitRepository.insert:start', { rateKey: data.rate_key, windowStart: data.window_start });
|
||||||
|
try {
|
||||||
|
await this.connection.query(
|
||||||
|
`INSERT INTO rate_limit (rate_key, window_start, count, window_seconds, max)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[data.rate_key, data.window_start, data.count, data.window_seconds, data.max]
|
||||||
|
);
|
||||||
|
logger.info('RateLimitRepository.insert:success', { rateKey: data.rate_key });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('RateLimitRepository.insert:error', { rateKey: data.rate_key, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update count for an existing rate limit row.
|
||||||
|
* @param {string} rateKey
|
||||||
|
* @param {Date} windowStart
|
||||||
|
* @param {number} count
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async updateCount(rateKey, windowStart, count) {
|
||||||
|
logger.info('RateLimitRepository.updateCount:start', { rateKey, windowStart, count });
|
||||||
|
try {
|
||||||
|
await this.connection.query(
|
||||||
|
`UPDATE rate_limit SET count = ? WHERE rate_key = ? AND window_start = ?`,
|
||||||
|
[count, rateKey, windowStart]
|
||||||
|
);
|
||||||
|
logger.info('RateLimitRepository.updateCount:success', { rateKey, count });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('RateLimitRepository.updateCount:error', { rateKey, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically get or create and update the rate limit row.
|
||||||
|
* @param {string} rateKey
|
||||||
|
* @param {Date} windowStart
|
||||||
|
* @param {number} windowSeconds
|
||||||
|
* @param {number} max
|
||||||
|
* @param {number} increment
|
||||||
|
* @returns {Promise<Object>} The updated row
|
||||||
|
*/
|
||||||
|
async incrementOrCreate(rateKey, windowStart, windowSeconds, max, increment = 1) {
|
||||||
|
logger.info('RateLimitRepository.incrementOrCreate:start', { rateKey, windowStart, windowSeconds, max, increment });
|
||||||
|
try {
|
||||||
|
// Lock row if exists
|
||||||
|
let row = await this.getForUpdate(rateKey, windowStart);
|
||||||
|
if (row) {
|
||||||
|
const newCount = row.count + increment;
|
||||||
|
await this.updateCount(rateKey, windowStart, newCount);
|
||||||
|
row.count = newCount;
|
||||||
|
logger.info('RateLimitRepository.incrementOrCreate:success', { rateKey });
|
||||||
|
return row;
|
||||||
|
} else {
|
||||||
|
await this.insert({
|
||||||
|
rate_key: rateKey,
|
||||||
|
window_start: windowStart,
|
||||||
|
count: increment,
|
||||||
|
window_seconds: windowSeconds,
|
||||||
|
max: max
|
||||||
|
});
|
||||||
|
logger.info('RateLimitRepository.incrementOrCreate:success', { rateKey });
|
||||||
|
return {
|
||||||
|
rate_key: rateKey,
|
||||||
|
window_start: windowStart,
|
||||||
|
count: increment,
|
||||||
|
window_seconds: windowSeconds,
|
||||||
|
max: max
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('RateLimitRepository.incrementOrCreate:error', { rateKey, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup old rate limit rows older than the specified number of days.
|
||||||
|
* @param {number} days
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async cleanupOldRows(days) {
|
||||||
|
logger.info('RateLimitRepository.cleanupOldRows:start', { days });
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||||
|
await this.connection.query(
|
||||||
|
'DELETE FROM rate_limit WHERE window_start < ?', [cutoff]
|
||||||
|
);
|
||||||
|
logger.info('RateLimitRepository.cleanupOldRows:success', { days });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('RateLimitRepository.cleanupOldRows:error', { days, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RateLimitRepository;
|
||||||
290
repositories/ReferralTokenRepository.js
Normal file
290
repositories/ReferralTokenRepository.js
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
const ReferralToken = require('../models/ReferralToken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class ReferralTokenRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createToken({ createdByUserId, expiresAt, maxUses }) {
|
||||||
|
logger.info('ReferralTokenRepository.createToken:start', { createdByUserId, expiresAt, maxUses });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const token = crypto.randomBytes(24).toString('hex');
|
||||||
|
const unlimited = (maxUses === -1);
|
||||||
|
const usesRemaining = unlimited ? -1 : maxUses;
|
||||||
|
const query = `
|
||||||
|
INSERT INTO referral_tokens
|
||||||
|
(token, created_by_user_id, expires_at, max_uses, uses_remaining, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'active')
|
||||||
|
`;
|
||||||
|
const [result] = await conn.query(query, [
|
||||||
|
token,
|
||||||
|
createdByUserId,
|
||||||
|
expiresAt,
|
||||||
|
maxUses,
|
||||||
|
usesRemaining
|
||||||
|
]);
|
||||||
|
logger.info('ReferralTokenRepository.createToken:success', {
|
||||||
|
id: result.insertId, token, unlimited, usesRemaining
|
||||||
|
});
|
||||||
|
return new ReferralToken({
|
||||||
|
id: result.insertId,
|
||||||
|
token,
|
||||||
|
createdByUserId,
|
||||||
|
expiresAt,
|
||||||
|
maxUses,
|
||||||
|
usesRemaining,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.createToken:error', { createdByUserId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByToken(token) {
|
||||||
|
logger.info('ReferralTokenRepository.findByToken:start', { token });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM referral_tokens WHERE token = ? LIMIT 1`,
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
logger.info('ReferralTokenRepository.findByToken:success', { token, found: !!rows.length });
|
||||||
|
return rows.length ? new ReferralToken(rows[0]) : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.findByToken:error', { token, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTokensByUser(userId) {
|
||||||
|
logger.info('ReferralTokenRepository.getTokensByUser:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
rt.id,
|
||||||
|
rt.token,
|
||||||
|
rt.created_by_user_id,
|
||||||
|
rt.expires_at,
|
||||||
|
rt.max_uses AS max_uses,
|
||||||
|
rt.uses_remaining AS uses_remaining,
|
||||||
|
rt.status,
|
||||||
|
rt.created_at,
|
||||||
|
rt.updated_at,
|
||||||
|
rt.max_uses_label AS max_uses_label,
|
||||||
|
rt.uses_remaining_label AS uses_remaining_label,
|
||||||
|
(SELECT COUNT(*) FROM referral_token_usage rtu WHERE rtu.referral_token_id = rt.id) AS usage_count,
|
||||||
|
CASE
|
||||||
|
WHEN rt.max_uses = -1 THEN 0
|
||||||
|
WHEN rt.max_uses IS NULL OR rt.uses_remaining IS NULL THEN 0
|
||||||
|
ELSE GREATEST(rt.max_uses - rt.uses_remaining, 0)
|
||||||
|
END AS used_count
|
||||||
|
FROM referral_tokens rt
|
||||||
|
WHERE rt.created_by_user_id = ?
|
||||||
|
ORDER BY rt.created_at DESC
|
||||||
|
`;
|
||||||
|
logger.debug('ReferralTokenRepository.getTokensByUser:sql', { sql, params: [userId] });
|
||||||
|
const [rows] = await conn.query(sql, [userId]);
|
||||||
|
rows.slice(0, 10).forEach(r => {
|
||||||
|
logger.debug('ReferralTokenRepository.getTokensByUser:row', {
|
||||||
|
id: r.id,
|
||||||
|
max_uses: r.max_uses,
|
||||||
|
uses_remaining: r.uses_remaining,
|
||||||
|
max_uses_label: r.max_uses_label,
|
||||||
|
uses_remaining_label: r.uses_remaining_label,
|
||||||
|
used_count: r.used_count
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info('ReferralTokenRepository.getTokensByUser:success', { userId, count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.getTokensByUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatsByUser(userId) {
|
||||||
|
logger.info('ReferralTokenRepository.getStatsByUser:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
// Total links generated
|
||||||
|
const [[{ totalLinks }]] = await conn.query(
|
||||||
|
`SELECT COUNT(*) AS totalLinks FROM referral_tokens WHERE created_by_user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
// Total active links
|
||||||
|
const [[{ activeLinks }]] = await conn.query(
|
||||||
|
`SELECT COUNT(*) AS activeLinks FROM referral_tokens WHERE created_by_user_id = ? AND status = 'active' AND expires_at > NOW()`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
// Total links used (sum of all usages)
|
||||||
|
const [[{ linksUsed }]] = await conn.query(
|
||||||
|
`SELECT COUNT(*) AS linksUsed FROM referral_token_usage rtu
|
||||||
|
JOIN referral_tokens rt ON rtu.referral_token_id = rt.id
|
||||||
|
WHERE rt.created_by_user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
// Count personal users referred (from users table)
|
||||||
|
const [[{ personalUsersReferred }]] = await conn.query(
|
||||||
|
`SELECT COUNT(*) AS personalUsersReferred
|
||||||
|
FROM referral_token_usage rtu
|
||||||
|
JOIN referral_tokens rt ON rtu.referral_token_id = rt.id
|
||||||
|
JOIN users u ON rtu.used_by_user_id = u.id
|
||||||
|
WHERE rt.created_by_user_id = ? AND u.user_type = 'personal'`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
// Count company users referred (from users table)
|
||||||
|
const [[{ companyUsersReferred }]] = await conn.query(
|
||||||
|
`SELECT COUNT(*) AS companyUsersReferred
|
||||||
|
FROM referral_token_usage rtu
|
||||||
|
JOIN referral_tokens rt ON rtu.referral_token_id = rt.id
|
||||||
|
JOIN users u ON rtu.used_by_user_id = u.id
|
||||||
|
WHERE rt.created_by_user_id = ? AND u.user_type = 'company'`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('ReferralTokenRepository.getStatsByUser:success', { userId, totalLinks, activeLinks, linksUsed, personalUsersReferred, companyUsersReferred });
|
||||||
|
return {
|
||||||
|
totalLinks,
|
||||||
|
activeLinks,
|
||||||
|
linksUsed,
|
||||||
|
personalUsersReferred,
|
||||||
|
companyUsersReferred
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.getStatsByUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateToken(tokenId, userId) {
|
||||||
|
logger.info('ReferralTokenRepository.deactivateToken:start', { tokenId, userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
// Only allow deactivation if the token belongs to the user
|
||||||
|
const [result] = await conn.query(
|
||||||
|
`UPDATE referral_tokens SET status = 'inactive', deactivation_reason = 'user_deactivated', updated_at = NOW()
|
||||||
|
WHERE id = ? AND created_by_user_id = ? AND status = 'active'`,
|
||||||
|
[tokenId, userId]
|
||||||
|
);
|
||||||
|
logger.info('ReferralTokenRepository.deactivateToken:success', { tokenId, userId, deactivated: result.affectedRows > 0 });
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.deactivateToken:error', { tokenId, userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReferrerInfoByToken(token) {
|
||||||
|
logger.info('ReferralTokenRepository.getReferrerInfoByToken:start', { token });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
rt.id AS token_id,
|
||||||
|
rt.token,
|
||||||
|
rt.status,
|
||||||
|
rt.expires_at,
|
||||||
|
rt.max_uses AS max_uses,
|
||||||
|
rt.uses_remaining AS uses_remaining,
|
||||||
|
rt.max_uses_label AS max_uses_label,
|
||||||
|
rt.uses_remaining_label AS uses_remaining_label,
|
||||||
|
CASE
|
||||||
|
WHEN rt.max_uses = -1 THEN 0
|
||||||
|
WHEN rt.max_uses IS NULL OR rt.uses_remaining IS NULL THEN 0
|
||||||
|
ELSE GREATEST(rt.max_uses - rt.uses_remaining, 0)
|
||||||
|
END AS used_count,
|
||||||
|
(SELECT COUNT(*) FROM referral_token_usage rtu WHERE rtu.referral_token_id = rt.id) AS usage_count,
|
||||||
|
u.id AS referrer_id,
|
||||||
|
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
|
||||||
|
WHERE rt.token = ?
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
logger.debug('ReferralTokenRepository.getReferrerInfoByToken:sql', { sql, params: [token] });
|
||||||
|
const [rows] = await conn.query(sql, [token]);
|
||||||
|
if (rows.length) {
|
||||||
|
const r = rows[0];
|
||||||
|
logger.debug('ReferralTokenRepository.getReferrerInfoByToken:row', {
|
||||||
|
token: r.token,
|
||||||
|
max_uses: r.max_uses,
|
||||||
|
uses_remaining: r.uses_remaining,
|
||||||
|
max_uses_label: r.max_uses_label,
|
||||||
|
uses_remaining_label: r.uses_remaining_label,
|
||||||
|
used_count: r.used_count
|
||||||
|
});
|
||||||
|
logger.info('ReferralTokenRepository.getReferrerInfoByToken:success', { token });
|
||||||
|
} else {
|
||||||
|
logger.warn('ReferralTokenRepository.getReferrerInfoByToken:not_found', { token });
|
||||||
|
}
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.getReferrerInfoByToken:error', { token, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markReferralTokenUsed(tokenId, usedByUserId, unitOfWork) {
|
||||||
|
logger.info('ReferralTokenRepository.markReferralTokenUsed:start', { tokenId, usedByUserId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO referral_token_usage (referral_token_id, used_by_user_id) VALUES (?, ?)`,
|
||||||
|
[tokenId, usedByUserId]
|
||||||
|
);
|
||||||
|
// Decrement only for limited tokens
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE referral_tokens
|
||||||
|
SET uses_remaining = uses_remaining - 1
|
||||||
|
WHERE id = ?
|
||||||
|
AND uses_remaining > 0
|
||||||
|
AND max_uses <> -1
|
||||||
|
AND uses_remaining <> -1`,
|
||||||
|
[tokenId]
|
||||||
|
);
|
||||||
|
// Exhaust only when reaches 0 and not unlimited
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE referral_tokens
|
||||||
|
SET status = 'exhausted'
|
||||||
|
WHERE id = ?
|
||||||
|
AND uses_remaining = 0
|
||||||
|
AND max_uses <> -1`,
|
||||||
|
[tokenId]
|
||||||
|
);
|
||||||
|
logger.info('ReferralTokenRepository.markReferralTokenUsed:success', { tokenId, usedByUserId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.markReferralTokenUsed:error', { tokenId, usedByUserId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async countActiveTokensByUser(userId) {
|
||||||
|
logger.info('ReferralTokenRepository.countActiveTokensByUser:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [[{ count }]] = await conn.query(
|
||||||
|
`SELECT COUNT(*) AS count
|
||||||
|
FROM referral_tokens
|
||||||
|
WHERE created_by_user_id = ?
|
||||||
|
AND status = 'active'
|
||||||
|
AND expires_at > NOW()`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('ReferralTokenRepository.countActiveTokensByUser:success', { userId, count });
|
||||||
|
return count;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralTokenRepository.countActiveTokensByUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...add more methods as needed (e.g., update, usage)...
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ReferralTokenRepository;
|
||||||
78
repositories/UnitOfWork.js
Normal file
78
repositories/UnitOfWork.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const db = require('../database/database');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class UnitOfWork {
|
||||||
|
constructor() {
|
||||||
|
this.repositories = {};
|
||||||
|
this.connection = null;
|
||||||
|
this.inTransaction = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
logger.info('UnitOfWork.start:start');
|
||||||
|
try {
|
||||||
|
this.connection = await db.getConnection();
|
||||||
|
await this.connection.query('START TRANSACTION');
|
||||||
|
this.inTransaction = true;
|
||||||
|
logger.info('UnitOfWork.start:success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UnitOfWork.start:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async commit() {
|
||||||
|
logger.info('UnitOfWork.commit:start');
|
||||||
|
if (this.inTransaction && this.connection) {
|
||||||
|
try {
|
||||||
|
await this.connection.query('COMMIT');
|
||||||
|
this.inTransaction = false;
|
||||||
|
await this.connection.release();
|
||||||
|
this.connection = null;
|
||||||
|
logger.info('UnitOfWork.commit:success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UnitOfWork.commit:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rollback(error) {
|
||||||
|
logger.warn('UnitOfWork.rollback:start');
|
||||||
|
if (this.inTransaction && this.connection) {
|
||||||
|
try {
|
||||||
|
await this.connection.query('ROLLBACK');
|
||||||
|
if (error) {
|
||||||
|
logger.error('UnitOfWork.rollback:error', { error: error.message, stack: error.stack });
|
||||||
|
console.error('💥 Transaction rolled back due to error:', error);
|
||||||
|
if (error.stack) {
|
||||||
|
console.error('💥 Error stack:', error.stack);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error('UnitOfWork.rollback:unknown_error');
|
||||||
|
console.error('💥 Transaction rolled back due to unknown error');
|
||||||
|
}
|
||||||
|
} catch (rollbackError) {
|
||||||
|
logger.error('UnitOfWork.rollback:rollback_error', { error: rollbackError.message, stack: rollbackError.stack });
|
||||||
|
console.error('💥 Error during rollback:', rollbackError);
|
||||||
|
if (rollbackError.stack) {
|
||||||
|
console.error('💥 Rollback error stack:', rollbackError.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.inTransaction = false;
|
||||||
|
await this.connection.release();
|
||||||
|
this.connection = null;
|
||||||
|
logger.info('UnitOfWork.rollback:complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerRepository(name, repository) {
|
||||||
|
this.repositories[name] = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRepository(name) {
|
||||||
|
return this.repositories[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UnitOfWork;
|
||||||
150
repositories/UserDocumentRepository.js
Normal file
150
repositories/UserDocumentRepository.js
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class UserDocumentRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertDocument({
|
||||||
|
userId,
|
||||||
|
documentType,
|
||||||
|
objectStorageId,
|
||||||
|
originalFilename,
|
||||||
|
fileSize,
|
||||||
|
mimeType
|
||||||
|
}) {
|
||||||
|
logger.info('UserDocumentRepository.insertDocument:start', { userId, documentType, originalFilename });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
INSERT INTO user_documents (
|
||||||
|
user_id, document_type, object_storage_id,
|
||||||
|
original_filename, file_size, mime_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
const [result] = await conn.query(query, [
|
||||||
|
userId,
|
||||||
|
documentType,
|
||||||
|
objectStorageId,
|
||||||
|
originalFilename,
|
||||||
|
fileSize,
|
||||||
|
mimeType
|
||||||
|
]);
|
||||||
|
logger.info('UserDocumentRepository.insertDocument:success', { userId, documentId: result.insertId });
|
||||||
|
return result.insertId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserDocumentRepository.insertDocument:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertIdMetadata({ userDocumentId, idType, idNumber, expiryDate }) {
|
||||||
|
logger.info('UserDocumentRepository.insertIdMetadata:start', { userDocumentId, idType, idNumber });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO user_id_documents (user_document_id, id_type, id_number, expiry_date)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[userDocumentId, idType, idNumber, expiryDate]
|
||||||
|
);
|
||||||
|
logger.info('UserDocumentRepository.insertIdMetadata:success', { userDocumentId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserDocumentRepository.insertIdMetadata:error', { userDocumentId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertIdDocument({
|
||||||
|
userId,
|
||||||
|
documentType,
|
||||||
|
frontObjectStorageId,
|
||||||
|
backObjectStorageId,
|
||||||
|
idType,
|
||||||
|
idNumber,
|
||||||
|
expiryDate,
|
||||||
|
originalFilenameFront,
|
||||||
|
originalFilenameBack
|
||||||
|
}) {
|
||||||
|
logger.info('UserDocumentRepository.insertIdDocument:start', { userId, documentType, idType, idNumber });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO user_id_documents (
|
||||||
|
user_id, document_type, front_object_storage_id, back_object_storage_id,
|
||||||
|
original_filename_front, original_filename_back, id_type, id_number, expiry_date
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
documentType,
|
||||||
|
frontObjectStorageId,
|
||||||
|
backObjectStorageId,
|
||||||
|
originalFilenameFront || null,
|
||||||
|
originalFilenameBack || null,
|
||||||
|
idType,
|
||||||
|
idNumber,
|
||||||
|
expiryDate
|
||||||
|
]
|
||||||
|
);
|
||||||
|
logger.info('UserDocumentRepository.insertIdDocument:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserDocumentRepository.insertIdDocument:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDocumentsForUser(userId) {
|
||||||
|
logger.info('UserDocumentRepository.getDocumentsForUser:start', { userId });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM user_documents WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserDocumentRepository.getDocumentsForUser:success', { userId, count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserDocumentRepository.getDocumentsForUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIdDocumentsForUser(userId) {
|
||||||
|
logger.info('UserDocumentRepository.getIdDocumentsForUser:start', { userId });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM user_id_documents WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserDocumentRepository.getIdDocumentsForUser:success', { userId, count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserDocumentRepository.getIdDocumentsForUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllObjectStorageIdsForUser(userId) {
|
||||||
|
logger.info('UserDocumentRepository.getAllObjectStorageIdsForUser:start', { userId });
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
try {
|
||||||
|
// Get object_storage_id from user_documents
|
||||||
|
const [docRows] = await conn.query(
|
||||||
|
`SELECT object_storage_id FROM user_documents WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
// Get front/back object_storage_id from user_id_documents
|
||||||
|
const [idDocRows] = await conn.query(
|
||||||
|
`SELECT front_object_storage_id, back_object_storage_id FROM user_id_documents WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserDocumentRepository.getAllObjectStorageIdsForUser:success', { userId, count: docRows.length + idDocRows.length });
|
||||||
|
return [...docRows, ...idDocRows];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserDocumentRepository.getAllObjectStorageIdsForUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserDocumentRepository;
|
||||||
374
repositories/UserRepository.js
Normal file
374
repositories/UserRepository.js
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
const PersonalUser = require('../models/PersonalUser');
|
||||||
|
const CompanyUser = require('../models/CompanyUser');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class UserRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(email, hashedPassword, userType) {
|
||||||
|
logger.info('UserRepository.createUser:start', { email, userType });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
INSERT INTO users (email, password, user_type, role)
|
||||||
|
VALUES (?, ?, ?, 'user')
|
||||||
|
`;
|
||||||
|
const [result] = await conn.query(query, [email, hashedPassword, userType]);
|
||||||
|
logger.info('UserRepository.createUser:success', { email, userType, userId: result.insertId });
|
||||||
|
return result.insertId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.createUser:error', { email, userType, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByEmail(email) {
|
||||||
|
logger.info('UserRepository.findUserByEmail:start', { email });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
// Query user, company profile, and personal profile data
|
||||||
|
const query = `
|
||||||
|
SELECT u.*,
|
||||||
|
cp.company_name, cp.registration_number, cp.phone as company_phone,
|
||||||
|
cp.contact_person_name, cp.contact_person_phone,
|
||||||
|
pp.first_name, pp.last_name, pp.phone as personal_phone, pp.date_of_birth
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
WHERE u.email = ?
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [email]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('UserRepository.findUserByEmail:found', { email, userId: rows[0].id });
|
||||||
|
const row = rows[0];
|
||||||
|
if (row.user_type === 'personal') {
|
||||||
|
return new PersonalUser(
|
||||||
|
row.id, row.email, row.password, row.first_name || '', row.last_name || '',
|
||||||
|
row.phone || '', row.date_of_birth || null, null, row.created_at, row.updated_at, row.role
|
||||||
|
);
|
||||||
|
} else if (row.user_type === 'company') {
|
||||||
|
return new CompanyUser(
|
||||||
|
row.id, row.email, row.password, row.company_name || '', row.company_phone || '',
|
||||||
|
row.contact_person_name || '', row.contact_person_phone || '', row.registration_number || '',
|
||||||
|
row.created_at, row.updated_at, row.role
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return new User(
|
||||||
|
row.id, row.email, row.password, row.user_type, row.created_at, row.updated_at, row.role
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info('UserRepository.findUserByEmail:not_found', { email });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.findUserByEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPersonalProfile(userId, profileData) {
|
||||||
|
logger.info('UserRepository.createPersonalProfile:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const { firstName, lastName, phone } = profileData;
|
||||||
|
const query = `
|
||||||
|
INSERT INTO personal_profiles (user_id, first_name, last_name, phone)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
await conn.query(query, [userId, firstName, lastName, phone]);
|
||||||
|
logger.info('UserRepository.createPersonalProfile:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.createPersonalProfile:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPersonalUserByEmail(email) {
|
||||||
|
logger.info('UserRepository.findPersonalUserByEmail:start', { email });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
SELECT u.*, pp.first_name, pp.last_name, pp.phone, pp.date_of_birth
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
WHERE u.email = ? AND u.user_type = 'personal'
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [email]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('UserRepository.findPersonalUserByEmail:found', { email, userId: rows[0].id });
|
||||||
|
const row = rows[0];
|
||||||
|
return new PersonalUser(
|
||||||
|
row.id, row.email, row.password, row.first_name,
|
||||||
|
row.last_name, row.phone, row.date_of_birth, null,
|
||||||
|
row.created_at, row.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('UserRepository.findPersonalUserByEmail:not_found', { email });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.findPersonalUserByEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCompanyProfile(userId, profileData) {
|
||||||
|
logger.info('UserRepository.createCompanyProfile:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const { companyName, registrationNumber, companyPhone, contactPersonName } = profileData;
|
||||||
|
const query = `
|
||||||
|
INSERT INTO company_profiles (user_id, company_name, registration_number, phone, contact_person_name)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
await conn.query(query, [userId, companyName, registrationNumber, companyPhone, contactPersonName]);
|
||||||
|
logger.info('UserRepository.createCompanyProfile:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.createCompanyProfile:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCompanyUserByEmail(email) {
|
||||||
|
logger.info('UserRepository.findCompanyUserByEmail:start', { email });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
SELECT u.*, cp.company_name, cp.registration_number, cp.phone as company_phone, cp.contact_person_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
WHERE u.email = ? AND u.user_type = 'company'
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [email]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('UserRepository.findCompanyUserByEmail:found', { email, userId: rows[0].id });
|
||||||
|
const row = rows[0];
|
||||||
|
return new CompanyUser(
|
||||||
|
row.id, row.email, row.password, row.company_name,
|
||||||
|
row.company_phone, row.contact_person_name, null,
|
||||||
|
row.registration_number, row.created_at, row.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info('UserRepository.findCompanyUserByEmail:not_found', { email });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.findCompanyUserByEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUserStatus(userId) {
|
||||||
|
logger.info('UserRepository.createUserStatus:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
INSERT INTO user_status (user_id, status, email_verified, profile_completed, registration_completed)
|
||||||
|
VALUES (?, 'pending', FALSE, TRUE, FALSE)
|
||||||
|
`;
|
||||||
|
await conn.query(query, [userId]);
|
||||||
|
logger.info('UserRepository.createUserStatus:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.createUserStatus:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByEmailOrId(identifier) {
|
||||||
|
logger.info('UserRepository.findUserByEmailOrId:start', { identifier });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
let query, params;
|
||||||
|
if (typeof identifier === 'number' || /^\d+$/.test(identifier)) {
|
||||||
|
query = `
|
||||||
|
SELECT u.*,
|
||||||
|
cp.company_name, cp.registration_number, cp.phone as company_phone,
|
||||||
|
cp.contact_person_name, cp.contact_person_phone,
|
||||||
|
pp.first_name, pp.last_name, pp.phone as personal_phone, pp.date_of_birth
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
WHERE u.id = ?
|
||||||
|
`;
|
||||||
|
params = [identifier];
|
||||||
|
} else {
|
||||||
|
query = `
|
||||||
|
SELECT u.*,
|
||||||
|
cp.company_name, cp.registration_number, cp.phone as company_phone,
|
||||||
|
cp.contact_person_name, cp.contact_person_phone,
|
||||||
|
pp.first_name, pp.last_name, pp.phone as personal_phone, pp.date_of_birth
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN company_profiles cp ON u.id = cp.user_id
|
||||||
|
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
|
||||||
|
WHERE u.email = ?
|
||||||
|
`;
|
||||||
|
params = [identifier];
|
||||||
|
}
|
||||||
|
const [rows] = await conn.query(query, params);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
logger.info('UserRepository.findUserByEmailOrId:found', { identifier, userId: rows[0].id });
|
||||||
|
const row = rows[0];
|
||||||
|
if (row.user_type === 'company') {
|
||||||
|
return new CompanyUser(
|
||||||
|
row.id, row.email, row.password, row.company_name || '', row.company_phone || '',
|
||||||
|
row.contact_person_name || '', row.contact_person_phone || '', row.registration_number || '',
|
||||||
|
row.created_at, row.updated_at
|
||||||
|
);
|
||||||
|
} else if (row.user_type === 'personal') {
|
||||||
|
return new PersonalUser(
|
||||||
|
row.id, row.email, row.password, row.first_name || '', row.last_name || '',
|
||||||
|
row.personal_phone || '', row.date_of_birth || null, null,
|
||||||
|
row.created_at, row.updated_at
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return new User(
|
||||||
|
row.id, row.email, row.password, row.user_type, row.created_at, row.updated_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info('UserRepository.findUserByEmailOrId:not_found', { identifier });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.findUserByEmailOrId:error', { identifier, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIban(userId) {
|
||||||
|
logger.info('UserRepository.getIban:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT iban FROM users WHERE id = ? LIMIT 1`, [userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getIban:success', { userId, iban: rows.length ? rows[0].iban : null });
|
||||||
|
return rows.length ? rows[0].iban : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.getIban:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId, userType) {
|
||||||
|
logger.info('UserRepository.getProfile:start', { userId, userType });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
if (userType === 'personal') {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM personal_profiles WHERE user_id = ? LIMIT 1`, [userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getProfile:success', { userId, userType });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} else if (userType === 'company') {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM company_profiles WHERE user_id = ? LIMIT 1`, [userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getProfile:success', { userId, userType });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
}
|
||||||
|
logger.info('UserRepository.getProfile:not_found', { userId, userType });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.getProfile:error', { userId, userType, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReferralEmail(userId, userType) {
|
||||||
|
logger.info('UserRepository.getReferralEmail:start', { userId, userType });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT u.email AS referralEmail
|
||||||
|
FROM referral_token_usage rtu
|
||||||
|
JOIN referral_tokens rt ON rtu.referral_token_id = rt.id
|
||||||
|
JOIN users u ON rt.created_by_user_id = u.id
|
||||||
|
WHERE rtu.used_by_user_id = ? LIMIT 1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getReferralEmail:success', { userId, referralEmail: rows.length ? rows[0].referralEmail : null });
|
||||||
|
return rows.length ? rows[0].referralEmail : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.getReferralEmail:error', { userId, userType, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissions(userId) {
|
||||||
|
logger.info('UserRepository.getPermissions:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [permRows] = await conn.query(
|
||||||
|
`SELECT p.name FROM user_permissions up
|
||||||
|
JOIN permissions p ON up.permission_id = p.id
|
||||||
|
WHERE up.user_id = ? AND p.is_active = TRUE`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getPermissions:success', { userId, count: permRows.length });
|
||||||
|
return permRows.map(row => row.name);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.getPermissions:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserStatus(userId) {
|
||||||
|
logger.info('UserRepository.getUserStatus:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM user_status WHERE user_id = ? LIMIT 1`, [userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getUserStatus:success', { userId, found: rows.length > 0 });
|
||||||
|
return rows.length ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.getUserStatus:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContracts(userId) {
|
||||||
|
logger.info('UserRepository.getContracts:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM user_documents WHERE user_id = ? AND document_type = 'contract'`, [userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getContracts:success', { userId, count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.getContracts:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIdDocuments(userId) {
|
||||||
|
logger.info('UserRepository.getIdDocuments:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT * FROM user_id_documents WHERE user_id = ?`, [userId]
|
||||||
|
);
|
||||||
|
logger.info('UserRepository.getIdDocuments:success', { userId, count: rows.length });
|
||||||
|
return rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.getIdDocuments:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUserById(userId) {
|
||||||
|
logger.info('UserRepository.deleteUserById:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
await conn.query(`DELETE FROM users WHERE id = ?`, [userId]);
|
||||||
|
logger.info('UserRepository.deleteUserById:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserRepository.deleteUserById:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserRepository;
|
||||||
37
repositories/UserSettingsRepository.js
Normal file
37
repositories/UserSettingsRepository.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class UserSettingsRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSettingsByUserId(userId) {
|
||||||
|
logger.info('UserSettingsRepository.getSettingsByUserId:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query('SELECT * FROM user_settings WHERE user_id = ?', [userId]);
|
||||||
|
logger.info('UserSettingsRepository.getSettingsByUserId:success', { userId, found: rows.length > 0 });
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserSettingsRepository.getSettingsByUserId:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDefaultSettings(userId, unitOfWork) {
|
||||||
|
logger.info('UserSettingsRepository.createDefaultSettings:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = unitOfWork ? unitOfWork.connection : this.unitOfWork.connection;
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO user_settings (user_id) VALUES (?) ON DUPLICATE KEY UPDATE user_id = user_id`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserSettingsRepository.createDefaultSettings:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserSettingsRepository.createDefaultSettings:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserSettingsRepository;
|
||||||
100
repositories/UserStatusRepository.js
Normal file
100
repositories/UserStatusRepository.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class UserStatusRepository {
|
||||||
|
constructor(unitOfWork) {
|
||||||
|
this.unitOfWork = unitOfWork;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatusByUserId(userId) {
|
||||||
|
logger.info('UserStatusRepository.getStatusByUserId:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
const [rows] = await conn.query('SELECT * FROM user_status WHERE user_id = ?', [userId]);
|
||||||
|
logger.info('UserStatusRepository.getStatusByUserId:success', { userId, found: rows.length > 0 });
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusRepository.getStatusByUserId:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeUserStatus(userId, status = 'inactive') {
|
||||||
|
logger.info('UserStatusRepository.initializeUserStatus:start', { userId, status });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
await conn.query(
|
||||||
|
`INSERT INTO user_status (user_id, status) VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE status = VALUES(status)`,
|
||||||
|
[userId, status]
|
||||||
|
);
|
||||||
|
logger.info('UserStatusRepository.initializeUserStatus:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusRepository.initializeUserStatus:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmailVerified(userId) {
|
||||||
|
logger.info('UserStatusRepository.updateEmailVerified:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE user_status
|
||||||
|
SET email_verified = TRUE, email_verified_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserStatusRepository.updateEmailVerified:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusRepository.updateEmailVerified:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRegistrationComplete(userId) {
|
||||||
|
logger.info('UserStatusRepository.markRegistrationComplete:start', { userId });
|
||||||
|
try {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE user_status
|
||||||
|
SET registration_completed = TRUE, status = 'active'
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserStatusRepository.markRegistrationComplete:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusRepository.markRegistrationComplete:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPendingIfComplete(userId) {
|
||||||
|
logger.info('UserStatusRepository.setPendingIfComplete:start', { userId });
|
||||||
|
try {
|
||||||
|
const status = await this.getStatusByUserId(userId);
|
||||||
|
if (
|
||||||
|
status &&
|
||||||
|
status.email_verified &&
|
||||||
|
status.profile_completed &&
|
||||||
|
status.documents_uploaded &&
|
||||||
|
status.contract_signed &&
|
||||||
|
status.status === 'inactive'
|
||||||
|
) {
|
||||||
|
const conn = this.unitOfWork.connection;
|
||||||
|
await conn.query(
|
||||||
|
`UPDATE user_status SET status = 'pending' WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('UserStatusRepository.setPendingIfComplete:status_set_pending', { userId });
|
||||||
|
}
|
||||||
|
logger.info('UserStatusRepository.setPendingIfComplete:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusRepository.setPendingIfComplete:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No changes required for progress calculation, as getStatusByUserId returns all needed fields.
|
||||||
|
|
||||||
|
module.exports = UserStatusRepository;
|
||||||
76
routes/admin.js
Normal file
76
routes/admin.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const AdminUserController = require('../controller/admin/AdminUserController');
|
||||||
|
const UserDocumentController = require('../controller/documents/UserDocumentController');
|
||||||
|
const ServerStatusController = require('../controller/admin/ServerStatusController');
|
||||||
|
const PasswordResetController = require('../controller/password-reset/PasswordResetController');
|
||||||
|
|
||||||
|
// Helper middleware to check admin role
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden: Admins only.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/admin/user-stats', authMiddleware, requireAdmin, AdminUserController.getUserStats);
|
||||||
|
router.get('/admin/user-list', authMiddleware, requireAdmin, AdminUserController.getUserList);
|
||||||
|
router.get('/admin/verification-pending-users', authMiddleware, requireAdmin, AdminUserController.getVerificationPendingUsers);
|
||||||
|
router.post('/admin/verify-user/:id', authMiddleware, requireAdmin, AdminUserController.verifyUser);
|
||||||
|
router.get('/admin/user/:id/documents', authMiddleware, requireAdmin, UserDocumentController.getAllDocumentsForUser);
|
||||||
|
router.get('/admin/server-status', authMiddleware, requireAdmin, ServerStatusController.getStatus);
|
||||||
|
|
||||||
|
// PUT /admin/users/:id/permissions - update user permissions
|
||||||
|
router.put(
|
||||||
|
'/admin/users/:id/permissions',
|
||||||
|
authMiddleware,
|
||||||
|
requireAdmin,
|
||||||
|
AdminUserController.updateUserPermissions
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin: send password reset link for a user
|
||||||
|
router.post(
|
||||||
|
'/admin/send-password-reset/:userId',
|
||||||
|
authMiddleware,
|
||||||
|
requireAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
// Find user by ID and get their email
|
||||||
|
const userId = req.params.userId;
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const PersonalUserRepository = require('../repositories/PersonalUserRepository');
|
||||||
|
const CompanyUserRepository = require('../repositories/CompanyUserRepository');
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
let user = null;
|
||||||
|
let email = null;
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const personalRepo = new PersonalUserRepository(uow);
|
||||||
|
const companyRepo = new CompanyUserRepository(uow);
|
||||||
|
user = await personalRepo.findById(userId);
|
||||||
|
if (!user) user = await companyRepo.findById(userId);
|
||||||
|
if (!user) {
|
||||||
|
await uow.rollback();
|
||||||
|
return res.status(404).json({ success: false, message: 'User not found.' });
|
||||||
|
}
|
||||||
|
email = user.email;
|
||||||
|
await uow.commit();
|
||||||
|
} catch (err) {
|
||||||
|
await uow.rollback();
|
||||||
|
console.error('[ADMIN SEND PASSWORD RESET] Error:', err); // <-- log error details
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||||
|
}
|
||||||
|
// Call the password reset controller
|
||||||
|
req.body = { email }; // Set email in body for controller
|
||||||
|
return PasswordResetController.requestPasswordReset(req, res);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/admin/user/:id',
|
||||||
|
authMiddleware,
|
||||||
|
requireAdmin,
|
||||||
|
AdminUserController.deleteUser
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
78
routes/auth.js
Normal file
78
routes/auth.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const LoginController = require('../controller/auth/LoginController');
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const UserStatusController = require('../controller/auth/UserStatusController');
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const UserRepository = require('../repositories/UserRepository');
|
||||||
|
const EmailVerificationController = require('../controller/auth/EmailVerificationController');
|
||||||
|
const UserController = require('../controller/auth/UserController');
|
||||||
|
const UserSettingsController = require('../controller/auth/UserSettingsController'); // Add this line
|
||||||
|
const PermissionController = require('../controller/permissions/PermissionController');
|
||||||
|
const AdminUserController = require('../controller/admin/AdminUserController'); // Import the AdminUserController
|
||||||
|
const PasswordResetController = require('../controller/password-reset/PasswordResetController');
|
||||||
|
const { createRateLimiter } = require('../middleware/rateLimiter');
|
||||||
|
|
||||||
|
// Login route
|
||||||
|
router.post('/login', LoginController.login);
|
||||||
|
|
||||||
|
// Refresh token route
|
||||||
|
router.post('/refresh', LoginController.refresh);
|
||||||
|
|
||||||
|
// Logout route
|
||||||
|
router.post('/logout', LoginController.logout);
|
||||||
|
|
||||||
|
// Get current authenticated user info
|
||||||
|
router.get('/me', authMiddleware, UserController.getMe);
|
||||||
|
|
||||||
|
// Secure endpoint to get current user's status
|
||||||
|
router.get('/user/status', authMiddleware, UserStatusController.getStatus);
|
||||||
|
|
||||||
|
// New endpoint for user status progress
|
||||||
|
router.get('/user/status-progress', authMiddleware, UserStatusController.getStatusProgress);
|
||||||
|
|
||||||
|
// Add this route for full user data by id
|
||||||
|
router.get('/users/:id/full', authMiddleware, UserController.getFullUserData);
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
router.post('/send-verification-email', authMiddleware, EmailVerificationController.sendVerificationEmail);
|
||||||
|
|
||||||
|
// Verify email code
|
||||||
|
router.post('/verify-email-code', authMiddleware, EmailVerificationController.verifyEmailCode);
|
||||||
|
|
||||||
|
// Add user settings route
|
||||||
|
router.get('/user/settings', authMiddleware, UserSettingsController.getSettings); // Add this line
|
||||||
|
|
||||||
|
router.get('/users/:id/permissions', authMiddleware, (req, res, next) => {
|
||||||
|
console.log('[ROUTE] /users/:id/permissions called');
|
||||||
|
console.log('Request method:', req.method);
|
||||||
|
console.log('Request URL:', req.originalUrl);
|
||||||
|
console.log('Request params:', req.params);
|
||||||
|
console.log('Request body:', req.body);
|
||||||
|
console.log('Request headers:', req.headers);
|
||||||
|
next();
|
||||||
|
}, PermissionController.getUserPermissions); // Add this route
|
||||||
|
|
||||||
|
// Add admin-only route for fetching full user account details
|
||||||
|
router.get('/admin/users/:id/full', authMiddleware, AdminUserController.getFullUserAccountDetails); // Add this line
|
||||||
|
|
||||||
|
router.get('/users/:id/documents', authMiddleware, UserController.getUserDocumentsAndContracts); // Add this line
|
||||||
|
|
||||||
|
// Password reset request (rate limited)
|
||||||
|
router.post(
|
||||||
|
'/request-password-reset',
|
||||||
|
createRateLimiter({
|
||||||
|
keyGenerator: req => `pwreset:${req.ip}`,
|
||||||
|
max: 5,
|
||||||
|
windowSeconds: 3600
|
||||||
|
}),
|
||||||
|
PasswordResetController.requestPasswordReset
|
||||||
|
);
|
||||||
|
|
||||||
|
// Password reset token verification
|
||||||
|
router.get('/verify-password-reset', PasswordResetController.verifyPasswordResetToken);
|
||||||
|
|
||||||
|
// Password reset (submit new password)
|
||||||
|
router.post('/reset-password', PasswordResetController.resetPassword);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
34
routes/companyStamps.js
Normal file
34
routes/companyStamps.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const auth = require('../middleware/authMiddleware');
|
||||||
|
const ctrl = require('../controller/companyStamp/CompanyStampController');
|
||||||
|
|
||||||
|
function adminOnly(req, res, next) {
|
||||||
|
if (!req.user || !['admin','super_admin'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Admin role required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: ensure service sees a "company" user_type for admin users
|
||||||
|
function forceCompanyForAdmin(req, res, next) {
|
||||||
|
if (req.user && ['admin','super_admin'].includes(req.user.role) && req.user.user_type !== 'company') {
|
||||||
|
req.user.user_type = 'company'; // mimic company to satisfy service checks
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: For primary company (id=1) only one stamp is allowed. Uploading again returns 409 with existing preview.
|
||||||
|
router.post('/company-stamps', auth, adminOnly, forceCompanyForAdmin, ctrl.upload);
|
||||||
|
router.get('/company-stamps/mine', auth, adminOnly, forceCompanyForAdmin, ctrl.listMine);
|
||||||
|
router.get('/company-stamps/mine/active', auth, adminOnly, forceCompanyForAdmin, ctrl.activeMine);
|
||||||
|
router.patch('/company-stamps/:id/activate', auth, adminOnly, forceCompanyForAdmin, ctrl.activate);
|
||||||
|
router.delete('/company-stamps/:id', auth, adminOnly, forceCompanyForAdmin, ctrl.delete);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Mount example (in main app):
|
||||||
|
const companyStampRoutes = require('./routes/companyStamps');
|
||||||
|
app.use('/api', companyStampRoutes);
|
||||||
|
*/
|
||||||
26
routes/contracts.js
Normal file
26
routes/contracts.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const router = express.Router();
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
|
// GET /api/contracts/personal
|
||||||
|
router.get('/contracts/personal', authMiddleware, (req, res) => {
|
||||||
|
const filePath = path.join(__dirname, '../contractTemplates/personal/test.pdf');
|
||||||
|
res.download(filePath, 'personal-service-contract.pdf', (err) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(404).json({ success: false, message: 'Personal contract not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/contracts/company
|
||||||
|
router.get('/contracts/company', authMiddleware, (req, res) => {
|
||||||
|
const filePath = path.join(__dirname, '../contractTemplates/company/test.pdf');
|
||||||
|
res.download(filePath, 'company-service-contract.pdf', (err) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(404).json({ success: false, message: 'Company contract not found' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
85
routes/documentTemplates.js
Normal file
85
routes/documentTemplates.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
||||||
|
|
||||||
|
// Use memory storage for multer (files will be available as buffers)
|
||||||
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
// Role check middleware for admin/super_admin
|
||||||
|
function adminOnly(req, res, next) {
|
||||||
|
if (!req.user || !['admin', 'super_admin'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: Admins only' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all templates
|
||||||
|
router.get('/document-templates', authMiddleware, DocumentTemplateController.listTemplates);
|
||||||
|
|
||||||
|
// Upload a new template
|
||||||
|
router.post(
|
||||||
|
'/document-templates',
|
||||||
|
authMiddleware,
|
||||||
|
upload.single('file'), // file field for template file
|
||||||
|
DocumentTemplateController.uploadTemplate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a template by ID
|
||||||
|
router.get('/document-templates/:id', authMiddleware, DocumentTemplateController.getTemplate);
|
||||||
|
|
||||||
|
// Update an existing template (edit/upload new version)
|
||||||
|
router.put(
|
||||||
|
'/document-templates/:id',
|
||||||
|
authMiddleware,
|
||||||
|
upload.single('file'), // optional new file
|
||||||
|
DocumentTemplateController.updateTemplate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete a template by ID
|
||||||
|
router.delete('/document-templates/:id', authMiddleware, DocumentTemplateController.deleteTemplate);
|
||||||
|
|
||||||
|
// Public route: List all templates for dashboard (admin only)
|
||||||
|
router.get('/document-templates-public', authMiddleware, adminOnly, DocumentTemplateController.listTemplatesPublic);
|
||||||
|
|
||||||
|
// Update template state (active/inactive)
|
||||||
|
router.patch(
|
||||||
|
'/document-templates/:id/state',
|
||||||
|
authMiddleware,
|
||||||
|
adminOnly,
|
||||||
|
DocumentTemplateController.updateTemplateState
|
||||||
|
);
|
||||||
|
|
||||||
|
// List templates with optional state filter (admin only)
|
||||||
|
router.get('/api/document-templates', authMiddleware, adminOnly, DocumentTemplateController.listTemplatesFiltered);
|
||||||
|
|
||||||
|
// Generate PDF from template
|
||||||
|
router.get(
|
||||||
|
'/document-templates/:id/generate-pdf',
|
||||||
|
authMiddleware,
|
||||||
|
DocumentTemplateController.generatePdf
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve sanitized HTML preview (avoids direct S3 CORS)
|
||||||
|
router.get(
|
||||||
|
'/document-templates/:id/preview',
|
||||||
|
authMiddleware,
|
||||||
|
DocumentTemplateController.previewTemplate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Download PDF (sanitized: template variables emptied for download)
|
||||||
|
router.get(
|
||||||
|
'/document-templates/:id/download-pdf',
|
||||||
|
authMiddleware,
|
||||||
|
DocumentTemplateController.downloadPdf
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate PDF with signature
|
||||||
|
router.post(
|
||||||
|
'/document-templates/:id/generate-pdf-with-signature',
|
||||||
|
authMiddleware,
|
||||||
|
DocumentTemplateController.generatePdfWithSignature
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
49
routes/documents.js
Normal file
49
routes/documents.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const PersonalDocumentController = require('../controller/documents/PersonalDocumentController');
|
||||||
|
const CompanyDocumentController = require('../controller/documents/CompanyDocumentController');
|
||||||
|
const ContractUploadController = require('../controller/documents/ContractUploadController');
|
||||||
|
|
||||||
|
// Use memory storage for multer (files will be available as buffers)
|
||||||
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
// POST /api/upload/personal-id
|
||||||
|
router.post(
|
||||||
|
'/upload/personal-id',
|
||||||
|
authMiddleware,
|
||||||
|
upload.fields([
|
||||||
|
{ name: 'front', maxCount: 1 },
|
||||||
|
{ name: 'back', maxCount: 1 }
|
||||||
|
]),
|
||||||
|
PersonalDocumentController.uploadPersonalId
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/upload/company-id
|
||||||
|
router.post(
|
||||||
|
'/upload/company-id',
|
||||||
|
authMiddleware,
|
||||||
|
upload.fields([
|
||||||
|
{ name: 'front', maxCount: 1 },
|
||||||
|
{ name: 'back', maxCount: 1 }
|
||||||
|
]),
|
||||||
|
CompanyDocumentController.uploadCompanyId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Contract upload endpoints (PDF only)
|
||||||
|
router.post(
|
||||||
|
'/upload/contract/personal',
|
||||||
|
authMiddleware,
|
||||||
|
upload.single('contract'),
|
||||||
|
ContractUploadController.uploadPersonalContract
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/upload/contract/company',
|
||||||
|
authMiddleware,
|
||||||
|
upload.single('contract'),
|
||||||
|
ContractUploadController.uploadCompanyContract
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
11
routes/getRoutes.js
Normal file
11
routes/getRoutes.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
console.log('🛣️ Setting up GET routes');
|
||||||
|
|
||||||
|
// Add other GET routes here as needed
|
||||||
|
// Registration routes have been moved to postRoutes.js
|
||||||
|
|
||||||
|
console.log('✅ GET routes configured successfully');
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
12
routes/permissions.js
Normal file
12
routes/permissions.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const PermissionController = require('../controller/permissions/PermissionController');
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
|
||||||
|
// GET /api/permissions - list all active permissions
|
||||||
|
router.get('/permissions', authMiddleware, PermissionController.list);
|
||||||
|
|
||||||
|
router.post('/permissions', authMiddleware, PermissionController.create);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
24
routes/postRoutes.js
Normal file
24
routes/postRoutes.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const PersonalRegisterController = require('../controller/register/PersonalRegisterController');
|
||||||
|
const CompanyRegisterController = require('../controller/register/CompanyRegisterController');
|
||||||
|
|
||||||
|
console.log('🛣️ Setting up POST routes for registration');
|
||||||
|
|
||||||
|
// Personal user registration route
|
||||||
|
router.post('/register/personal', (req, res) => {
|
||||||
|
console.log('🔗 POST /register/personal route accessed');
|
||||||
|
console.log('📦 Expected data structure: firstName, lastName, email, confirmEmail, phone, password, confirmPassword, referralEmail');
|
||||||
|
PersonalRegisterController.register(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Company user registration route
|
||||||
|
router.post('/register/company', (req, res) => {
|
||||||
|
console.log('🔗 POST /register/company route accessed');
|
||||||
|
console.log('📦 Expected data structure: companyName, companyEmail, confirmCompanyEmail, companyPhone, contactPersonName, contactPersonPhone, password, confirmPassword');
|
||||||
|
CompanyRegisterController.register(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ POST registration routes configured successfully');
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
13
routes/profile.js
Normal file
13
routes/profile.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const PersonalProfileController = require('../controller/profile/PersonalProfileController');
|
||||||
|
const CompanyProfileController = require('../controller/profile/CompanyProfileController');
|
||||||
|
|
||||||
|
// POST /api/profile/personal/complete
|
||||||
|
router.post('/profile/personal/complete', authMiddleware, PersonalProfileController.completeProfile);
|
||||||
|
|
||||||
|
// POST /api/profile/company/complete
|
||||||
|
router.post('/profile/company/complete', authMiddleware, CompanyProfileController.completeProfile);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
15
routes/referral.js
Normal file
15
routes/referral.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const ReferralTokenController = require('../controller/referral/ReferralTokenController');
|
||||||
|
const ReferralRegistrationController = require('../controller/referral/ReferralRegistrationController');
|
||||||
|
|
||||||
|
router.post('/referral/create', authMiddleware, ReferralTokenController.create);
|
||||||
|
router.get('/referral/list', authMiddleware, ReferralTokenController.list);
|
||||||
|
router.get('/referral/stats', authMiddleware, ReferralTokenController.stats);
|
||||||
|
router.post('/referral/deactivate', authMiddleware, ReferralTokenController.deactivate);
|
||||||
|
router.get('/referral/info/:token', ReferralRegistrationController.getReferrerInfo);
|
||||||
|
router.post('/register/personal-referral', ReferralRegistrationController.registerPersonalReferral);
|
||||||
|
router.post('/register/company-referral', ReferralRegistrationController.registerCompanyReferral);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
8
routes/userSettings.js
Normal file
8
routes/userSettings.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
const UserSettingsController = require('../controller/auth/UserSettingsController');
|
||||||
|
|
||||||
|
router.get('/settings', authMiddleware, UserSettingsController.getSettings);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
75
scripts/createAdminUser.js
Normal file
75
scripts/createAdminUser.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const db = require('../database/database'); // Adjust path if needed
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const argon2 = require('argon2');
|
||||||
|
|
||||||
|
async function createAdminUser() {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'office@profit-planet.com';
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || 'W.profit-planet.com.2025'; // Set a secure password in production!
|
||||||
|
const firstName = process.env.ADMIN_FIRST_NAME || 'Admin';
|
||||||
|
const lastName = process.env.ADMIN_LAST_NAME || 'User';
|
||||||
|
|
||||||
|
const uow = new UnitOfWork(); // No need to pass pool
|
||||||
|
await uow.start();
|
||||||
|
try {
|
||||||
|
// Check if admin user exists
|
||||||
|
const [users] = await uow.connection.query(
|
||||||
|
`SELECT id FROM users WHERE email = ? AND role = 'admin' LIMIT 1`, [adminEmail]
|
||||||
|
);
|
||||||
|
let userId;
|
||||||
|
const hashedPassword = await argon2.hash(adminPassword);
|
||||||
|
if (users.length) {
|
||||||
|
userId = users[0].id;
|
||||||
|
// Update password hash to match new secret
|
||||||
|
await uow.connection.query(
|
||||||
|
`UPDATE users SET password = ? WHERE id = ?`,
|
||||||
|
[hashedPassword, userId]
|
||||||
|
);
|
||||||
|
console.log('✅ Admin user password updated');
|
||||||
|
} else {
|
||||||
|
// Create admin user
|
||||||
|
const [userResult] = await uow.connection.query(
|
||||||
|
`INSERT INTO users (email, password, user_type, role, created_at) VALUES (?, ?, 'personal', 'admin', NOW())`,
|
||||||
|
[adminEmail, hashedPassword]
|
||||||
|
);
|
||||||
|
userId = userResult.insertId;
|
||||||
|
|
||||||
|
// Insert into personal_profiles
|
||||||
|
await uow.connection.query(
|
||||||
|
`INSERT INTO personal_profiles (user_id, first_name, last_name) VALUES (?, ?, ?)`,
|
||||||
|
[userId, firstName, lastName]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert into user_status (active, admin verified)
|
||||||
|
await uow.connection.query(
|
||||||
|
`INSERT INTO user_status (user_id, status, is_admin_verified, admin_verified_at, email_verified, profile_completed, documents_uploaded, contract_signed)
|
||||||
|
VALUES (?, 'active', 1, NOW(), 1, 1, 1, 1)`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert into user_settings
|
||||||
|
await uow.connection.query(
|
||||||
|
`INSERT INTO user_settings (user_id) VALUES (?)`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign can_create_referrals permission (fix name)
|
||||||
|
const [permRows] = await uow.connection.query(
|
||||||
|
`SELECT id FROM permissions WHERE name = 'can_create_referrals' LIMIT 1`
|
||||||
|
);
|
||||||
|
if (permRows.length) {
|
||||||
|
await uow.connection.query(
|
||||||
|
`INSERT INTO user_permissions (user_id, permission_id) VALUES (?, ?)`,
|
||||||
|
[userId, permRows[0].id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('✅ Admin user created and initialized');
|
||||||
|
}
|
||||||
|
await uow.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await uow.rollback(error);
|
||||||
|
console.error('💥 Failed to create admin user:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createAdminUser;
|
||||||
73
scripts/initPermissions.js
Normal file
73
scripts/initPermissions.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const NODE_ENV = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
|
let dbConfig;
|
||||||
|
if (NODE_ENV === 'development') {
|
||||||
|
dbConfig = {
|
||||||
|
host: process.env.DEV_DB_HOST || 'localhost',
|
||||||
|
port: Number(process.env.DEV_DB_PORT) || 3306,
|
||||||
|
user: process.env.DEV_DB_USER || 'root',
|
||||||
|
password: process.env.DEV_DB_PASSWORD || '',
|
||||||
|
database: process.env.DEV_DB_NAME || 'profitplanet_centralserver',
|
||||||
|
ssl: undefined // No SSL for XAMPP/local
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const getSSLConfig = () => {
|
||||||
|
const useSSL = String(process.env.DB_SSL || '').toLowerCase() === 'true';
|
||||||
|
const caPath = process.env.DB_SSL_CA_PATH;
|
||||||
|
if (!useSSL) return undefined;
|
||||||
|
if (caPath && fs.existsSync(caPath)) {
|
||||||
|
return { ca: fs.readFileSync(caPath) };
|
||||||
|
}
|
||||||
|
return {}; // fallback SSL without CA if path missing
|
||||||
|
};
|
||||||
|
dbConfig = {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT) || 3306,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
ssl: getSSLConfig()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_PERMISSIONS = [
|
||||||
|
{
|
||||||
|
name: 'can_create_referrals',
|
||||||
|
description: 'User can create referral links',
|
||||||
|
is_active: true
|
||||||
|
}
|
||||||
|
// Add more permissions here as needed
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ensurePermissions() {
|
||||||
|
const conn = await mysql.createConnection(dbConfig);
|
||||||
|
try {
|
||||||
|
for (const perm of REQUIRED_PERMISSIONS) {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
'SELECT id FROM permissions WHERE name = ?',
|
||||||
|
[perm.name]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await conn.query(
|
||||||
|
'INSERT INTO permissions (name, description, is_active) VALUES (?, ?, ?)',
|
||||||
|
[perm.name, perm.description, perm.is_active]
|
||||||
|
);
|
||||||
|
console.log(`✅ Permission "${perm.name}" created.`);
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ Permission "${perm.name}" already exists.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await conn.end();
|
||||||
|
console.log('🎉 Permission initialization complete.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('💥 Error initializing permissions:', err);
|
||||||
|
await conn.end();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ensurePermissions;
|
||||||
203
server.js
Normal file
203
server.js
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { createDatabase } = require('./database/createDb');
|
||||||
|
const getRoutes = require('./routes/getRoutes');
|
||||||
|
const postRoutes = require('./routes/postRoutes');
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const authMiddleware = require('./middleware/authMiddleware');
|
||||||
|
const documentsRoutes = require('./routes/documents');
|
||||||
|
const profileRoutes = require('./routes/profile');
|
||||||
|
const contractsRoutes = require('./routes/contracts');
|
||||||
|
const referralRoutes = require('./routes/referral');
|
||||||
|
const permissionsRoutes = require('./routes/permissions');
|
||||||
|
const permissionsInit = require('./scripts/initPermissions');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
const createAdminUser = require('./scripts/createAdminUser');
|
||||||
|
const { createRateLimiter } = require('./middleware/rateLimiter');
|
||||||
|
const documentTemplatesRoutes = require('./routes/documentTemplates');
|
||||||
|
const companyStampRoutes = require('./routes/companyStamps'); // NEW
|
||||||
|
|
||||||
|
// add logger (now with setters/getter)
|
||||||
|
const { logger, requestLogger, setLogLevel, setTransportLevel, getLoggerLevels } = require('./middleware/logger');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
const ALLOWED_ORIGINS = (process.env.CORS_ALLOWED_ORIGINS)
|
||||||
|
.split(',')
|
||||||
|
.map(o => o.trim());
|
||||||
|
|
||||||
|
const corsOptions = {
|
||||||
|
origin: function (origin, callback) {
|
||||||
|
if (!origin) return callback(null, true); // non-browser clients
|
||||||
|
if (ALLOWED_ORIGINS.includes(origin)) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
return callback(new Error('Not allowed by CORS'));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type','Authorization'],
|
||||||
|
exposedHeaders: []
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// -- Replace inline console logging with structured request logger --
|
||||||
|
app.use(requestLogger);
|
||||||
|
// -- end logging middleware --
|
||||||
|
|
||||||
|
// Basic route to handle requests from frontend
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({ message: 'Central server is running and ready to receive requests' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example API endpoint
|
||||||
|
app.post('/api/data', (req, res) => {
|
||||||
|
logger.debug('Received request from frontend', { requestId: req.id, body: req.body && typeof req.body === 'object' ? '[object]' : req.body });
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Data received successfully',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test endpoint for frontend
|
||||||
|
app.get('/api/test', (req, res) => {
|
||||||
|
logger.info('Test request received from frontend', { requestId: req.id });
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Backend connection successful!',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
server: 'Central Server'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protected route example
|
||||||
|
app.use('/api/protected', authMiddleware, (req, res) => {
|
||||||
|
res.json({ success: true, message: 'Access granted', user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use getRoutes for URL routing and controller references
|
||||||
|
app.use('/', getRoutes);
|
||||||
|
app.use('/', postRoutes); // <-- Add this line to enable POST /register/personal and /register/company
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api', documentsRoutes);
|
||||||
|
app.use('/api', profileRoutes);
|
||||||
|
app.use('/api', contractsRoutes);
|
||||||
|
app.use('/api', referralRoutes);
|
||||||
|
app.use('/api', permissionsRoutes);
|
||||||
|
app.use('/api', adminRoutes);
|
||||||
|
app.use('/api', documentTemplatesRoutes);
|
||||||
|
app.use('/api', companyStampRoutes); // NEW
|
||||||
|
logger.info('🛣️ GET routes configured and ready');
|
||||||
|
|
||||||
|
// Insert admin endpoint for dynamic log-level control
|
||||||
|
// Requires authenticated user and admin role
|
||||||
|
app.post('/api/admin/log-level', authMiddleware, (req, res) => {
|
||||||
|
// Basic auth + role check (assumes authMiddleware sets req.user)
|
||||||
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
|
logger.warn('admin:forbidden_log_level_change', { requestId: req.id, user: req.user && req.user.id });
|
||||||
|
return res.status(403).json({ success: false, message: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { level, transport } = req.body || {};
|
||||||
|
const allowedLevels = ['error','warn','info','http','verbose','debug','silly'];
|
||||||
|
|
||||||
|
if (!level || typeof level !== 'string' || !allowedLevels.includes(level)) {
|
||||||
|
return res.status(400).json({ success: false, message: `Invalid level. Allowed: ${allowedLevels.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
if (transport && typeof transport === 'string') {
|
||||||
|
// set specific transport (e.g. 'console' or 'file' or class name)
|
||||||
|
ok = setTransportLevel(transport, level);
|
||||||
|
} else {
|
||||||
|
// set global level (affects logger and all transports)
|
||||||
|
ok = setLogLevel(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = getLoggerLevels();
|
||||||
|
if (!ok) {
|
||||||
|
logger.warn('admin:log_level_change_failed', { requestId: req.id, userId: req.user && req.user.id, level, transport });
|
||||||
|
return res.status(500).json({ success: false, message: 'Failed to apply log level', current });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('admin:log_level_changed', { requestId: req.id, userId: req.user && req.user.id, level, transport });
|
||||||
|
return res.status(200).json({ success: true, message: 'Log level updated', current });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Express error handler (logs structured error and returns safe response)
|
||||||
|
function errorHandler(err, req, res, next) {
|
||||||
|
logger.error('http:error', {
|
||||||
|
message: err && err.message,
|
||||||
|
stack: err && err.stack,
|
||||||
|
requestId: req && req.id,
|
||||||
|
method: req && req.method,
|
||||||
|
url: req && req.originalUrl,
|
||||||
|
user: req && req.user ? { id: req.user.id, email: req.user.email } : undefined
|
||||||
|
});
|
||||||
|
const status = (err && err.status) || 500;
|
||||||
|
const safeMessage = process.env.NODE_ENV === 'development' ? (err && err.message) : 'Internal Server Error';
|
||||||
|
res.status(status).json({ success: false, message: safeMessage });
|
||||||
|
}
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// register global error / process handlers
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
try {
|
||||||
|
logger.error('uncaughtException', { message: err.message, stack: err.stack });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to log uncaughtException', e);
|
||||||
|
}
|
||||||
|
// optionally flush and exit for safety
|
||||||
|
setTimeout(() => process.exit(1), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
try {
|
||||||
|
logger.error('unhandledRejection', { reason: reason && reason.stack ? reason.stack : reason });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to log unhandledRejection', e);
|
||||||
|
}
|
||||||
|
// optional: exit or let process continue based on policy
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server with database initialization
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
logger.info('🔄 Initializing server...');
|
||||||
|
|
||||||
|
// Initialize database first
|
||||||
|
await createDatabase();
|
||||||
|
|
||||||
|
// Initialize permissions
|
||||||
|
await permissionsInit();
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
await createAdminUser();
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
const host = process.env.HOST || 'localhost';
|
||||||
|
const url = `http://${host}:${PORT}`;
|
||||||
|
logger.info('🚀 Central server running', { url, port: PORT });
|
||||||
|
logger.info('📡 Waiting for requests from frontend...');
|
||||||
|
logger.info('💾 Database initialized and ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('💥 Failed to start server', { message: error.message, stack: error.stack });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start everything in order
|
||||||
|
startServer();
|
||||||
223
services/AdminService.js
Normal file
223
services/AdminService.js
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
const AdminRepository = require('../repositories/AdminRepository');
|
||||||
|
const UserDocumentRepository = require('../repositories/UserDocumentRepository');
|
||||||
|
const UserRepository = require('../repositories/UserRepository');
|
||||||
|
const { s3 } = require('../utils/exoscaleUploader');
|
||||||
|
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const pidusage = require('pidusage');
|
||||||
|
const os = require('os');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class AdminService {
|
||||||
|
static async getUserStats(unitOfWork) {
|
||||||
|
logger.info('AdminService.getUserStats:start');
|
||||||
|
try {
|
||||||
|
const stats = await AdminRepository.getUserStats(unitOfWork.connection);
|
||||||
|
logger.info('AdminService.getUserStats:success', stats);
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.getUserStats:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserList(unitOfWork) {
|
||||||
|
logger.info('AdminService.getUserList:start');
|
||||||
|
try {
|
||||||
|
const list = await AdminRepository.getUserList(unitOfWork.connection);
|
||||||
|
logger.info('AdminService.getUserList:success', { count: list.length });
|
||||||
|
return list;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.getUserList:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getVerificationPendingUsers(unitOfWork) {
|
||||||
|
logger.info('AdminService.getVerificationPendingUsers:start');
|
||||||
|
try {
|
||||||
|
const users = await AdminRepository.getVerificationPendingUsers(unitOfWork.connection);
|
||||||
|
logger.info('AdminService.getVerificationPendingUsers:success', { count: users.length });
|
||||||
|
return users;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.getVerificationPendingUsers:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyUser(unitOfWork, userId, permissions) {
|
||||||
|
logger.info('AdminService.verifyUser:start', { userId, permissions });
|
||||||
|
try {
|
||||||
|
// 1. Fetch user
|
||||||
|
const userRepo = new UserRepository(unitOfWork);
|
||||||
|
const user = await userRepo.findUserByEmailOrId(Number(userId));
|
||||||
|
if (!user) throw new Error('User not found');
|
||||||
|
logger.info('AdminService.verifyUser:user_fetched', { userId });
|
||||||
|
|
||||||
|
// 2. Fetch documents and contracts
|
||||||
|
const documents = await AdminRepository.getUserDocuments(unitOfWork.connection, userId);
|
||||||
|
const contracts = await AdminRepository.getUserContracts(unitOfWork.connection, userId);
|
||||||
|
logger.info('AdminService.verifyUser:documents_fetched', { userId, documentsCount: documents.length, contractsCount: contracts.length });
|
||||||
|
|
||||||
|
// 3. Fetch ID document metadata
|
||||||
|
const idDocs = await AdminRepository.getUserIdDocuments(unitOfWork.connection, userId);
|
||||||
|
logger.info('AdminService.verifyUser:id_docs_fetched', { userId, idDocsCount: idDocs.length });
|
||||||
|
|
||||||
|
// 4. Generate signed URLs for front/back of ID documents
|
||||||
|
const idDocumentsWithUrls = await Promise.all(
|
||||||
|
idDocs.map(async doc => {
|
||||||
|
let frontUrl = null, backUrl = null;
|
||||||
|
if (doc.front_object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.front_object_storage_id
|
||||||
|
});
|
||||||
|
frontUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
} catch (err) { frontUrl = null; }
|
||||||
|
}
|
||||||
|
if (doc.back_object_storage_id) {
|
||||||
|
try {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: doc.back_object_storage_id
|
||||||
|
});
|
||||||
|
backUrl = await getSignedUrl(s3, command, { expiresIn: 900 });
|
||||||
|
} catch (err) { backUrl = null; }
|
||||||
|
}
|
||||||
|
return { ...doc, frontUrl, backUrl };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
logger.info('AdminService.verifyUser:id_docs_urls_generated', { userId, idDocsCount: idDocumentsWithUrls.length });
|
||||||
|
|
||||||
|
// 5. Update user_status
|
||||||
|
await AdminRepository.verifyUser(unitOfWork.connection, userId);
|
||||||
|
logger.info('AdminService.verifyUser:user_status_updated', { userId });
|
||||||
|
|
||||||
|
// 6. Assign permissions
|
||||||
|
if (permissions.length > 0) {
|
||||||
|
await AdminRepository.assignPermissions(unitOfWork.connection, userId, permissions);
|
||||||
|
logger.info('AdminService.verifyUser:permissions_assigned', { userId, permissions });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Return updated user data
|
||||||
|
logger.info('AdminService.verifyUser:success', { userId });
|
||||||
|
return {
|
||||||
|
message: 'User verified and permissions updated',
|
||||||
|
user: user.getPublicData(),
|
||||||
|
documents,
|
||||||
|
contracts,
|
||||||
|
idDocuments: idDocumentsWithUrls
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.verifyUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getFullUserAccountDetails(unitOfWork, userId) {
|
||||||
|
logger.info('AdminService.getFullUserAccountDetails:start', { userId });
|
||||||
|
try {
|
||||||
|
const user = await AdminRepository.getUserById(unitOfWork.connection, userId);
|
||||||
|
if (!user) throw new Error('User not found');
|
||||||
|
|
||||||
|
const personalProfile = await AdminRepository.getPersonalProfile(unitOfWork.connection, userId);
|
||||||
|
const companyProfile = await AdminRepository.getCompanyProfile(unitOfWork.connection, userId);
|
||||||
|
const permissions = await AdminRepository.getUserPermissions(unitOfWork.connection, userId);
|
||||||
|
|
||||||
|
logger.info('AdminService.getFullUserAccountDetails:success', { userId });
|
||||||
|
return { user, personalProfile, companyProfile, permissions };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.getFullUserAccountDetails:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateUserPermissions(unitOfWork, userId, permissions) {
|
||||||
|
logger.info('AdminService.updateUserPermissions:start', { userId, permissions });
|
||||||
|
try {
|
||||||
|
await AdminRepository.updateUserPermissions(unitOfWork.connection, userId, permissions);
|
||||||
|
logger.info('AdminService.updateUserPermissions:success', { userId, permissions });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.updateUserPermissions:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getServerStatus() {
|
||||||
|
logger.info('AdminService.getServerStatus:start');
|
||||||
|
try {
|
||||||
|
const stats = await pidusage(process.pid);
|
||||||
|
const uptimeSeconds = process.uptime();
|
||||||
|
const uptimeDays = Math.floor(uptimeSeconds / (60 * 60 * 24));
|
||||||
|
const uptimeHours = Math.floor((uptimeSeconds % (60 * 60 * 24)) / 3600);
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
|
||||||
|
logger.info('AdminService.getServerStatus:success', { status: 'Online', uptime: { days: uptimeDays, hours: uptimeHours } });
|
||||||
|
return {
|
||||||
|
status: 'Online',
|
||||||
|
uptime: { days: uptimeDays, hours: uptimeHours },
|
||||||
|
cpuUsagePercent: Math.round(stats.cpu),
|
||||||
|
memory: {
|
||||||
|
used: (stats.memory / (1024 ** 3)).toFixed(1),
|
||||||
|
total: (totalMem / (1024 ** 3)).toFixed(1)
|
||||||
|
},
|
||||||
|
logs: []
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.getServerStatus:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteUser(unitOfWork, userId) {
|
||||||
|
logger.info('AdminService.deleteUser:start', { userId });
|
||||||
|
try {
|
||||||
|
// 1. Fetch all object storage IDs for user documents
|
||||||
|
const docRepo = new UserDocumentRepository(unitOfWork);
|
||||||
|
const objectIds = await docRepo.getAllObjectStorageIdsForUser(userId);
|
||||||
|
|
||||||
|
// 2. Delete each document from S3
|
||||||
|
for (const obj of objectIds) {
|
||||||
|
try {
|
||||||
|
if (obj.object_storage_id) {
|
||||||
|
await s3.send(new DeleteObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: obj.object_storage_id
|
||||||
|
}));
|
||||||
|
logger.info('AdminService.deleteUser:s3_deleted', { userId, objectKey: obj.object_storage_id });
|
||||||
|
}
|
||||||
|
// For user_id_documents: front/back
|
||||||
|
if (obj.front_object_storage_id) {
|
||||||
|
await s3.send(new DeleteObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: obj.front_object_storage_id
|
||||||
|
}));
|
||||||
|
logger.info('AdminService.deleteUser:s3_deleted', { userId, objectKey: obj.front_object_storage_id });
|
||||||
|
}
|
||||||
|
if (obj.back_object_storage_id) {
|
||||||
|
await s3.send(new DeleteObjectCommand({
|
||||||
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
|
Key: obj.back_object_storage_id
|
||||||
|
}));
|
||||||
|
logger.info('AdminService.deleteUser:s3_deleted', { userId, objectKey: obj.back_object_storage_id });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('AdminService.deleteUser:s3_delete_failed', { userId, error: err.message, objectKey: obj.object_storage_id || obj.front_object_storage_id || obj.back_object_storage_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete user from DB (cascades)
|
||||||
|
const userRepo = new UserRepository(unitOfWork);
|
||||||
|
await userRepo.deleteUserById(userId);
|
||||||
|
|
||||||
|
logger.info('AdminService.deleteUser:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('AdminService.deleteUser:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AdminService;
|
||||||
80
services/CompanyDocumentService.js
Normal file
80
services/CompanyDocumentService.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const UserDocumentRepository = require('../repositories/UserDocumentRepository');
|
||||||
|
const { uploadBuffer } = require('../utils/exoscaleUploader');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class CompanyDocumentService {
|
||||||
|
static async uploadCompanyId({ userId, idType, idNumber, expiryDate, files, unitOfWork }) {
|
||||||
|
logger.info('CompanyDocumentService.uploadCompanyId:start', { userId, idType, idNumber, expiryDate });
|
||||||
|
try {
|
||||||
|
if (!idType || !expiryDate || !files || !files.front) {
|
||||||
|
logger.warn('CompanyDocumentService.uploadCompanyId:missing_fields', { userId });
|
||||||
|
throw new Error('Missing required fields or front image');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!allowedTypes.includes(files.front.mimetype)) {
|
||||||
|
logger.warn('CompanyDocumentService.uploadCompanyId:invalid_front_type', { userId, mimetype: files.front.mimetype });
|
||||||
|
throw new Error('Invalid file type for front image');
|
||||||
|
}
|
||||||
|
if (files.back && !allowedTypes.includes(files.back.mimetype)) {
|
||||||
|
logger.warn('CompanyDocumentService.uploadCompanyId:invalid_back_type', { userId, mimetype: files.back.mimetype });
|
||||||
|
throw new Error('Invalid file type for back image');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('CompanyDocumentService.uploadCompanyId:uploading_front', { userId });
|
||||||
|
const frontUpload = await uploadBuffer(
|
||||||
|
files.front.buffer,
|
||||||
|
files.front.originalname,
|
||||||
|
files.front.mimetype,
|
||||||
|
`company-id/${userId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let backUpload = null;
|
||||||
|
if (files.back) {
|
||||||
|
logger.info('CompanyDocumentService.uploadCompanyId:uploading_back', { userId });
|
||||||
|
backUpload = await uploadBuffer(
|
||||||
|
files.back.buffer,
|
||||||
|
files.back.originalname,
|
||||||
|
files.back.mimetype,
|
||||||
|
`company-id/${userId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = new UserDocumentRepository(unitOfWork);
|
||||||
|
|
||||||
|
await repo.insertIdDocument({
|
||||||
|
userId,
|
||||||
|
documentType: 'company_id',
|
||||||
|
frontObjectStorageId: frontUpload.objectKey,
|
||||||
|
backObjectStorageId: backUpload ? backUpload.objectKey : null,
|
||||||
|
idType,
|
||||||
|
idNumber,
|
||||||
|
expiryDate,
|
||||||
|
originalFilenameFront: files.front.originalname,
|
||||||
|
originalFilenameBack: files.back ? files.back.originalname : null
|
||||||
|
});
|
||||||
|
logger.info('CompanyDocumentService.uploadCompanyId:id_document_inserted', { userId });
|
||||||
|
|
||||||
|
await unitOfWork.connection.query(
|
||||||
|
`UPDATE user_status SET documents_uploaded = 1, documents_uploaded_at = NOW() WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('CompanyDocumentService.uploadCompanyId:user_status_updated', { userId });
|
||||||
|
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
|
||||||
|
logger.info('CompanyDocumentService.uploadCompanyId:pending_check_complete', { userId });
|
||||||
|
|
||||||
|
logger.info('CompanyDocumentService.uploadCompanyId:success', { userId });
|
||||||
|
return {
|
||||||
|
front: frontUpload,
|
||||||
|
back: backUpload
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyDocumentService.uploadCompanyId:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyDocumentService;
|
||||||
26
services/CompanyProfileService.js
Normal file
26
services/CompanyProfileService.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const CompanyUserRepository = require('../repositories/CompanyUserRepository');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class CompanyProfileService {
|
||||||
|
static async completeProfile(userId, profileData, unitOfWork) {
|
||||||
|
logger.info('CompanyProfileService.completeProfile:start', { userId });
|
||||||
|
try {
|
||||||
|
// Pass all profileData including country to repository
|
||||||
|
const repo = new CompanyUserRepository(unitOfWork);
|
||||||
|
await repo.updateProfileAndMarkCompleted(userId, profileData);
|
||||||
|
logger.info('CompanyProfileService.completeProfile:profile_completed', { userId });
|
||||||
|
|
||||||
|
// Check if all steps are complete and set status to 'pending' if so
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
|
||||||
|
logger.info('CompanyProfileService.completeProfile:pending_check_complete', { userId });
|
||||||
|
logger.info('CompanyProfileService.completeProfile:success', { userId });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyProfileService.completeProfile:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyProfileService;
|
||||||
164
services/CompanyStampService.js
Normal file
164
services/CompanyStampService.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
const CompanyStampRepository = require('../repositories/CompanyStampRepository');
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||||
|
const MAX_IMAGE_BYTES = 500 * 1024; // 500 KB
|
||||||
|
const MAX_BASE64_LENGTH = Math.ceil((MAX_IMAGE_BYTES / 3) * 4) + 16; // safety
|
||||||
|
const STAMP_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const _cache = new Map(); // companyId -> { stamp, ts }
|
||||||
|
const PRIMARY_COMPANY_ID = 1; // Our own company (single stamp policy)
|
||||||
|
|
||||||
|
function stripDataUri(b64, providedMime) {
|
||||||
|
if (!b64) return { pure: '', mime: providedMime };
|
||||||
|
const m = /^data:([\w/+.-]+);base64,(.+)$/i.exec(b64);
|
||||||
|
if (m) return { mime: m[1], pure: m[2] };
|
||||||
|
return { pure: b64, mime: providedMime };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBase64(str) {
|
||||||
|
return /^[A-Za-z0-9+/=\r\n]+$/.test(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSizeApprox(base64) {
|
||||||
|
const cleaned = base64.replace(/[\r\n]/g, '');
|
||||||
|
const len = cleaned.length;
|
||||||
|
if (!len) return 0;
|
||||||
|
const padding = (cleaned.endsWith('==') ? 2 : cleaned.endsWith('=') ? 1 : 0);
|
||||||
|
return (len * 3) / 4 - padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompanyStampService {
|
||||||
|
async uploadStamp({ user, base64, mimeType, label, activate = false }) {
|
||||||
|
if (!user || user.user_type !== 'company')
|
||||||
|
throw new Error('Only company users can upload stamps');
|
||||||
|
|
||||||
|
const companyId = user.id || user.userId;
|
||||||
|
|
||||||
|
// Enforce single-stamp rule for primary company BEFORE any mutation
|
||||||
|
if (companyId === PRIMARY_COMPANY_ID) {
|
||||||
|
const existingPrimary = await CompanyStampRepository.findByCompanyId(PRIMARY_COMPANY_ID);
|
||||||
|
if (existingPrimary && existingPrimary.length) {
|
||||||
|
const err = new Error('Primary company stamp already exists');
|
||||||
|
err.code = 'PRIMARY_STAMP_EXISTS';
|
||||||
|
err.existing = existingPrimary[0];
|
||||||
|
throw err; // controller will translate to preview response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pure, mime } = stripDataUri(base64, mimeType);
|
||||||
|
const finalMime = mime || mimeType;
|
||||||
|
if (!ALLOWED_MIME.has(finalMime)) throw new Error('Unsupported MIME type');
|
||||||
|
if (!validateBase64(pure)) throw new Error('Invalid base64 data');
|
||||||
|
if (pure.length > MAX_BASE64_LENGTH) throw new Error('Image too large (base64 length)');
|
||||||
|
const bytes = decodeSizeApprox(pure);
|
||||||
|
if (bytes > MAX_IMAGE_BYTES) throw new Error('Image exceeds size limit');
|
||||||
|
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
await uow.start();
|
||||||
|
try {
|
||||||
|
// default inactive until optionally activated
|
||||||
|
const created = await CompanyStampRepository.create(
|
||||||
|
{
|
||||||
|
company_id: companyId,
|
||||||
|
label,
|
||||||
|
mime_type: finalMime,
|
||||||
|
image_base64: pure,
|
||||||
|
is_active: !!activate // treat activate flag directly (single stamp ok)
|
||||||
|
},
|
||||||
|
uow.connection
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activate) {
|
||||||
|
// For primary company single stamp: no need to deactivate; for others keep old logic
|
||||||
|
if (companyId !== PRIMARY_COMPANY_ID) {
|
||||||
|
await CompanyStampRepository.deactivateAllForCompany(companyId, uow.connection);
|
||||||
|
await CompanyStampRepository.activate(created.id, companyId, uow.connection);
|
||||||
|
created.is_active = true;
|
||||||
|
}
|
||||||
|
_cache.delete(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uow.commit();
|
||||||
|
logger.info('CompanyStampService.uploadStamp:success', { id: created.id, activate, companyId });
|
||||||
|
return created;
|
||||||
|
} catch (e) {
|
||||||
|
await uow.rollback(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCompanyStamps(user) {
|
||||||
|
if (!user || user.user_type !== 'company')
|
||||||
|
throw new Error('Forbidden');
|
||||||
|
return CompanyStampRepository.findByCompanyId(user.id || user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveStampForCompany(companyId) {
|
||||||
|
const cached = _cache.get(companyId);
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now - cached.ts < STAMP_CACHE_TTL_MS) return cached.stamp;
|
||||||
|
|
||||||
|
const stamp = await CompanyStampRepository.findActiveByCompanyId(companyId);
|
||||||
|
_cache.set(companyId, { stamp, ts: now });
|
||||||
|
return stamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyActive(user) {
|
||||||
|
if (!user || user.user_type !== 'company')
|
||||||
|
throw new Error('Forbidden');
|
||||||
|
return this.getActiveStampForCompany(user.id || user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateStamp(id, user) {
|
||||||
|
if (!user || user.user_type !== 'company')
|
||||||
|
throw new Error('Forbidden');
|
||||||
|
const companyId = user.id || user.userId;
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
await uow.start();
|
||||||
|
try {
|
||||||
|
const stamp = await CompanyStampRepository.findById(id, uow.connection);
|
||||||
|
if (!stamp || stamp.company_id !== companyId) throw new Error('Not found');
|
||||||
|
await CompanyStampRepository.deactivateAllForCompany(companyId, uow.connection);
|
||||||
|
await CompanyStampRepository.activate(id, companyId, uow.connection);
|
||||||
|
await uow.commit();
|
||||||
|
_cache.delete(companyId);
|
||||||
|
return { ...stamp, is_active: true };
|
||||||
|
} catch (e) {
|
||||||
|
await uow.rollback(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStamp(id, user) {
|
||||||
|
if (!user || user.user_type !== 'company')
|
||||||
|
throw new Error('Forbidden');
|
||||||
|
const ok = await CompanyStampRepository.delete(id, user.id || user.userId);
|
||||||
|
if (ok) _cache.delete(user.id || user.userId);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfitPlanetStamp() {
|
||||||
|
// Tries cache (reuse normal cache key)
|
||||||
|
const cached = _cache.get(PRIMARY_COMPANY_ID);
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && now - cached.ts < STAMP_CACHE_TTL_MS) return cached.stamp;
|
||||||
|
// Accept active first; if none (single stamp not active), just any
|
||||||
|
let stamp = await CompanyStampRepository.findActiveByCompanyId(PRIMARY_COMPANY_ID);
|
||||||
|
if (!stamp) {
|
||||||
|
const all = await CompanyStampRepository.findByCompanyId(PRIMARY_COMPANY_ID);
|
||||||
|
stamp = all && all[0] ? all[0] : null;
|
||||||
|
}
|
||||||
|
_cache.set(PRIMARY_COMPANY_ID, { stamp, ts: now });
|
||||||
|
return stamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience for placeholder
|
||||||
|
async getProfitPlanetSignatureTag(sizes = { maxW: 300, maxH: 300 }) {
|
||||||
|
const stamp = await this.getProfitPlanetStamp();
|
||||||
|
if (!stamp) return '';
|
||||||
|
return `<img src="data:${stamp.mime_type};base64,${stamp.image_base64}" style="max-width:${sizes.maxW}px;max-height:${sizes.maxH}px;">`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CompanyStampService();
|
||||||
75
services/CompanyUserService.js
Normal file
75
services/CompanyUserService.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const CompanyUserRepository = require('../repositories/CompanyUserRepository');
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const CompanyUser = require('../models/CompanyUser');
|
||||||
|
const MailService = require('./MailService');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class CompanyUserService {
|
||||||
|
static async createCompanyUser({ companyEmail, password, companyName, companyPhone, contactPersonName, contactPersonPhone }) {
|
||||||
|
logger.info('CompanyUserService.createCompanyUser:start', { companyEmail, companyName });
|
||||||
|
console.log('📝 CompanyUserService: Creating company user...');
|
||||||
|
console.log('📋 Data to be saved:', { companyEmail, companyName, companyPhone, contactPersonName });
|
||||||
|
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
|
||||||
|
// Register repositories
|
||||||
|
unitOfWork.registerRepository('companyUser', new CompanyUserRepository(unitOfWork));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companyRepo = unitOfWork.getRepository('companyUser');
|
||||||
|
const newCompany = await companyRepo.create({
|
||||||
|
companyEmail,
|
||||||
|
password,
|
||||||
|
companyName,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('CompanyUserService.createCompanyUser:company_created', { companyId: newCompany.id });
|
||||||
|
|
||||||
|
// Initialize user status
|
||||||
|
await UserStatusService.initializeUserStatus(newCompany.id, 'company', unitOfWork, 'inactive');
|
||||||
|
logger.info('CompanyUserService.createCompanyUser:user_status_initialized', { companyId: newCompany.id });
|
||||||
|
|
||||||
|
// Send registration email to the new company user
|
||||||
|
await MailService.sendRegistrationEmail({
|
||||||
|
email: newCompany.email,
|
||||||
|
userType: 'company',
|
||||||
|
companyName: newCompany.companyName
|
||||||
|
});
|
||||||
|
logger.info('CompanyUserService.createCompanyUser:registration_email_sent', { companyEmail });
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('CompanyUserService.createCompanyUser:success', { companyId: newCompany.id });
|
||||||
|
return newCompany;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyUserService.createCompanyUser:error', { companyEmail, error: error.message });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
console.error('💥 CompanyUserService: Error creating company user:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findCompanyUserByEmail(email) {
|
||||||
|
logger.info('CompanyUserService.findCompanyUserByEmail:start', { email });
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('companyUser', new CompanyUserRepository(unitOfWork));
|
||||||
|
try {
|
||||||
|
const companyRepo = unitOfWork.getRepository('companyUser');
|
||||||
|
const user = await companyRepo.findByEmail(email);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('CompanyUserService.findCompanyUserByEmail:success', { email, found: !!user });
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('CompanyUserService.findCompanyUserByEmail:error', { email, error: error.message });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CompanyUserService;
|
||||||
199
services/ContractUploadService.js
Normal file
199
services/ContractUploadService.js
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
const UserDocumentRepository = require('../repositories/UserDocumentRepository');
|
||||||
|
const { uploadBuffer } = require('../utils/exoscaleUploader');
|
||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
const getStream = require('get-stream');
|
||||||
|
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const DocumentTemplateService = require('./DocumentTemplateService');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
function fillTemplate(template, data) {
|
||||||
|
return template.replace(/{{(\w+)}}/g, (_, key) => data[key] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBUG_PDF_FILES = !!process.env.DEBUG_PDF_FILES;
|
||||||
|
function ensureDebugDir() {
|
||||||
|
const debugDir = path.join(__dirname, '../debug-pdf');
|
||||||
|
if (!fs.existsSync(debugDir)) fs.mkdirSync(debugDir, { recursive: true });
|
||||||
|
return debugDir;
|
||||||
|
}
|
||||||
|
function saveDebugFile(filename, data) {
|
||||||
|
if (!DEBUG_PDF_FILES) return;
|
||||||
|
const p = path.join(ensureDebugDir(), filename);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(p, data);
|
||||||
|
console.log(`[ContractUploadService][DEBUG] wrote ${p}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ContractUploadService][DEBUG] failed to write', p, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal stream reader supporting AWS SDK Body
|
||||||
|
async function streamToString(stream, id) {
|
||||||
|
const chunks = [];
|
||||||
|
let total = 0;
|
||||||
|
if (stream[Symbol.asyncIterator]) {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
chunks.push(buf); total += buf.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
chunks.push(buf); total += buf.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
if (DEBUG_PDF_FILES || total < 32) saveDebugFile(`contract_template_${id}_html_raw.bin`, buffer);
|
||||||
|
console.log(`[ContractUploadService] read template ${id} bytes=${total}`);
|
||||||
|
return buffer.toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractUploadService {
|
||||||
|
static async uploadContract({
|
||||||
|
userId,
|
||||||
|
file,
|
||||||
|
documentType,
|
||||||
|
contractCategory,
|
||||||
|
unitOfWork,
|
||||||
|
contractData,
|
||||||
|
signatureImage,
|
||||||
|
contractTemplate,
|
||||||
|
templateId,
|
||||||
|
lang
|
||||||
|
}) {
|
||||||
|
logger.info('ContractUploadService.uploadContract:start', { userId, documentType, contractCategory, templateId, lang });
|
||||||
|
let pdfBuffer, originalFilename, mimeType, fileSize;
|
||||||
|
let contractBody;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If templateId and lang are provided, fetch HTML template from object storage
|
||||||
|
if (templateId && lang) {
|
||||||
|
logger.info('ContractUploadService.uploadContract:fetching_template', { templateId, lang });
|
||||||
|
const templateMeta = await DocumentTemplateService.getTemplate(templateId);
|
||||||
|
if (!templateMeta || templateMeta.lang !== lang) throw new Error('Template not found for specified language');
|
||||||
|
// Fetch HTML from object storage
|
||||||
|
const s3 = new S3Client({ region: process.env.EXOSCALE_REGION });
|
||||||
|
const getObj = await s3.send(new GetObjectCommand({
|
||||||
|
Bucket: process.env.AWS_BUCKET,
|
||||||
|
Key: templateMeta.storageKey
|
||||||
|
}));
|
||||||
|
const htmlBuffer = await getStream.buffer(getObj.Body);
|
||||||
|
let htmlTemplate = htmlBuffer.toString('utf-8');
|
||||||
|
// Fill variables in HTML template
|
||||||
|
contractBody = fillTemplate(htmlTemplate, contractData);
|
||||||
|
logger.info('ContractUploadService.uploadContract:template_fetched', { templateId });
|
||||||
|
} else if (contractTemplate) {
|
||||||
|
logger.info('ContractUploadService.uploadContract:using_contractTemplate');
|
||||||
|
contractBody = fillTemplate(contractTemplate, contractData);
|
||||||
|
} else if (contractData) {
|
||||||
|
logger.info('ContractUploadService.uploadContract:using_contractData');
|
||||||
|
contractBody = `Contract for ${contractData.name}\nDate: ${contractData.date}\nEmail: ${contractData.email}\n\nTerms and Conditions...\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contractData && signatureImage) {
|
||||||
|
logger.info('ContractUploadService.uploadContract:generating_pdf', { userId });
|
||||||
|
// Generate styled PDF
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
margins: { top: 60, bottom: 60, left: 72, right: 72 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Header
|
||||||
|
doc.font('Helvetica-Bold').fontSize(20).text('Contract Agreement', { align: 'center' });
|
||||||
|
doc.moveDown(1.5);
|
||||||
|
|
||||||
|
// User Info Section
|
||||||
|
doc.font('Helvetica').fontSize(12);
|
||||||
|
doc.text(`Name: ${contractData.name}`);
|
||||||
|
doc.text(`Email: ${contractData.email}`);
|
||||||
|
doc.text(`Date: ${contractData.date}`);
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
// Contract Body Section
|
||||||
|
doc.font('Times-Roman').fontSize(13);
|
||||||
|
doc.text(contractBody, {
|
||||||
|
align: 'justify',
|
||||||
|
lineGap: 4
|
||||||
|
});
|
||||||
|
doc.moveDown(2);
|
||||||
|
|
||||||
|
// Signature Section
|
||||||
|
doc.font('Helvetica-Bold').fontSize(12).text('Signature:', { continued: true });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
const imgBuffer = Buffer.isBuffer(signatureImage) ? signatureImage : Buffer.from(signatureImage, 'base64');
|
||||||
|
doc.image(imgBuffer, doc.x, doc.y, { fit: [180, 60], align: 'left', valign: 'center' });
|
||||||
|
doc.moveDown(2);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
doc.font('Helvetica-Oblique').fontSize(10).fillColor('gray')
|
||||||
|
.text('This contract is generated electronically and is valid without a physical signature.', 72, 780, {
|
||||||
|
align: 'center'
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
pdfBuffer = await getStream.buffer(doc);
|
||||||
|
originalFilename = `signed_contract_${userId}_${Date.now()}.pdf`;
|
||||||
|
mimeType = 'application/pdf';
|
||||||
|
fileSize = pdfBuffer.length;
|
||||||
|
logger.info('ContractUploadService.uploadContract:pdf_generated', { userId, filename: originalFilename, fileSize });
|
||||||
|
} else if (file) {
|
||||||
|
logger.info('ContractUploadService.uploadContract:using_uploaded_pdf', { userId, filename: file.originalname });
|
||||||
|
if (file.mimetype !== 'application/pdf') throw new Error('Only PDF files are allowed');
|
||||||
|
pdfBuffer = file.buffer;
|
||||||
|
originalFilename = file.originalname;
|
||||||
|
mimeType = file.mimetype;
|
||||||
|
fileSize = file.size;
|
||||||
|
} else {
|
||||||
|
logger.warn('ContractUploadService.uploadContract:no_contract_file_or_data', { userId });
|
||||||
|
throw new Error('No contract file or data uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to Exoscale
|
||||||
|
logger.info('ContractUploadService.uploadContract:uploading_to_exoscale', { userId, filename: originalFilename });
|
||||||
|
const uploadResult = await uploadBuffer(
|
||||||
|
pdfBuffer,
|
||||||
|
originalFilename,
|
||||||
|
mimeType,
|
||||||
|
`contracts/${contractCategory}/${userId}`
|
||||||
|
);
|
||||||
|
logger.info('ContractUploadService.uploadContract:uploaded', { userId, objectKey: uploadResult.objectKey });
|
||||||
|
|
||||||
|
// Insert metadata in user_documents
|
||||||
|
const repo = new UserDocumentRepository(unitOfWork);
|
||||||
|
await repo.insertDocument({
|
||||||
|
userId,
|
||||||
|
documentType,
|
||||||
|
objectStorageId: uploadResult.objectKey,
|
||||||
|
idType: null,
|
||||||
|
idNumber: null,
|
||||||
|
expiryDate: null,
|
||||||
|
originalFilename,
|
||||||
|
fileSize,
|
||||||
|
mimeType
|
||||||
|
});
|
||||||
|
logger.info('ContractUploadService.uploadContract:document_metadata_inserted', { userId });
|
||||||
|
|
||||||
|
// Optionally update user_status (contract_signed)
|
||||||
|
await unitOfWork.connection.query(
|
||||||
|
`UPDATE user_status SET contract_signed = 1, contract_signed_at = NOW() WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('ContractUploadService.uploadContract:user_status_updated', { userId });
|
||||||
|
|
||||||
|
// Check if all steps are complete and set status to 'pending' if so
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
|
||||||
|
logger.info('ContractUploadService.uploadContract:pending_check_complete', { userId });
|
||||||
|
|
||||||
|
logger.info('ContractUploadService.uploadContract:success', { userId });
|
||||||
|
return uploadResult;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ContractUploadService.uploadContract:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ContractUploadService;
|
||||||
232
services/DocumentTemplateService.js
Normal file
232
services/DocumentTemplateService.js
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
const DocumentTemplateRepository = require('../repositories/DocumentTemplateRepository');
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class DocumentTemplateService {
|
||||||
|
async listTemplates() {
|
||||||
|
logger.info('DocumentTemplateService.listTemplates:start');
|
||||||
|
try {
|
||||||
|
const templates = await DocumentTemplateRepository.findAll();
|
||||||
|
logger.info('DocumentTemplateService.listTemplates:success', { count: templates.length });
|
||||||
|
return templates.map(t => ({
|
||||||
|
...t,
|
||||||
|
lang: t.lang
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateService.listTemplates:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadTemplate(data) {
|
||||||
|
logger.info('DocumentTemplateService.uploadTemplate:start', { name: data.name, type: data.type });
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const allowed = ['personal','company','both'];
|
||||||
|
const user_type = allowed.includes(data.user_type || data.userType) ? (data.user_type || data.userType) : 'both';
|
||||||
|
const created = await DocumentTemplateRepository.create({ ...data, user_type }, uow.connection);
|
||||||
|
await uow.commit();
|
||||||
|
logger.info('DocumentTemplateService.uploadTemplate:success', { id: created.id });
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('DocumentTemplateService.uploadTemplate:error', { error: err.message });
|
||||||
|
await uow.rollback(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplate(id) {
|
||||||
|
logger.info('DocumentTemplateService.getTemplate:start', { id });
|
||||||
|
try {
|
||||||
|
const template = await DocumentTemplateRepository.findById(id);
|
||||||
|
if (!template) {
|
||||||
|
logger.warn('DocumentTemplateService.getTemplate:not_found', { id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
logger.debug('DocumentTemplateService.getTemplate:meta', { id, storageKey: template.storageKey, lang: template.lang, version: template.version });
|
||||||
|
logger.info('DocumentTemplateService.getTemplate:success', { id });
|
||||||
|
return { ...template, lang: template.lang };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateService.getTemplate:error', { id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTemplate(id) {
|
||||||
|
logger.info('DocumentTemplateService.deleteTemplate:start', { id });
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
await DocumentTemplateRepository.delete(id, uow.connection);
|
||||||
|
await uow.commit();
|
||||||
|
logger.info('DocumentTemplateService.deleteTemplate:success', { id });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('DocumentTemplateService.deleteTemplate:error', { id, error: err.message });
|
||||||
|
await uow.rollback(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemplate(id, data) {
|
||||||
|
logger.info('DocumentTemplateService.updateTemplate:start', { id });
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const current = await DocumentTemplateRepository.findById(id, uow.connection);
|
||||||
|
if (!current) {
|
||||||
|
logger.warn('DocumentTemplateService.updateTemplate:not_found', { id });
|
||||||
|
await uow.rollback();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const allowed = ['personal','company','both'];
|
||||||
|
if (data.userType && !allowed.includes(data.userType)) delete data.userType;
|
||||||
|
if (data.user_type && !allowed.includes(data.user_type)) delete data.user_type;
|
||||||
|
const newVersion = (current.version || 1) + 1;
|
||||||
|
await DocumentTemplateRepository.update(
|
||||||
|
id,
|
||||||
|
{ ...data, version: newVersion, user_type: data.user_type || data.userType || current.user_type },
|
||||||
|
uow.connection
|
||||||
|
);
|
||||||
|
const updated = await DocumentTemplateRepository.findById(id, uow.connection);
|
||||||
|
await uow.commit();
|
||||||
|
logger.info('DocumentTemplateService.updateTemplate:success', { id, version: newVersion });
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('DocumentTemplateService.updateTemplate:error', { id, error: err.message });
|
||||||
|
await uow.rollback(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemplateState(id, state) {
|
||||||
|
logger.info('DocumentTemplateService.updateTemplateState:start', { id, state });
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
await DocumentTemplateRepository.updateState(id, state, uow.connection);
|
||||||
|
const updated = await DocumentTemplateRepository.findById(id, uow.connection);
|
||||||
|
await uow.commit();
|
||||||
|
logger.info('DocumentTemplateService.updateTemplateState:success', { id, state });
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('DocumentTemplateService.updateTemplateState:error', { id, state, error: err.message });
|
||||||
|
await uow.rollback(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveTemplatesForUserType(userType, templateType = null) {
|
||||||
|
logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType });
|
||||||
|
try {
|
||||||
|
const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType);
|
||||||
|
logger.info('DocumentTemplateService.getActiveTemplatesForUserType:success', { count: rows.length });
|
||||||
|
return rows.map(t => ({ ...t, lang: t.lang }));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('DocumentTemplateService.getActiveTemplatesForUserType:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: Retrieve Profit Planet signature (company stamp) as <img> tag
|
||||||
|
// Fallback order:
|
||||||
|
// 1) Active stamp for provided companyId
|
||||||
|
// 2) Any stamp for provided companyId
|
||||||
|
// 3) Any active stamp globally
|
||||||
|
// 4) Any stamp globally
|
||||||
|
async getProfitPlanetSignatureTag({ companyId = null, maxW = 300, maxH = 300 } = {}) {
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
const result = { tag: '', reason: 'not_started' };
|
||||||
|
logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:enter', { companyId, maxW, maxH });
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const conn = uow.connection;
|
||||||
|
|
||||||
|
const safeRowMeta = (row) => {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
has_image: !!row.image_base64,
|
||||||
|
mime: row.mime_type,
|
||||||
|
image_len: row.image_base64 ? row.image_base64.length : 0,
|
||||||
|
image_head: row.image_base64 ? row.image_base64.slice(0, 30) : ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to run single-row query safely
|
||||||
|
const fetchOne = async (sql, params, reasonCode) => {
|
||||||
|
logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:query:start', { reasonCode, sql, params });
|
||||||
|
try {
|
||||||
|
const started = Date.now();
|
||||||
|
const [rows] = await conn.execute(sql, params);
|
||||||
|
const ms = Date.now() - started;
|
||||||
|
const rowCount = rows ? rows.length : 0;
|
||||||
|
logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:query:result', {
|
||||||
|
reasonCode,
|
||||||
|
duration_ms: ms,
|
||||||
|
rowCount,
|
||||||
|
firstRow: safeRowMeta(rows && rows[0])
|
||||||
|
});
|
||||||
|
if (rows && rows[0] && rows[0].image_base64) {
|
||||||
|
const mime = rows[0].mime_type || 'image/png';
|
||||||
|
const dataUri = `data:${mime};base64,${rows[0].image_base64}`;
|
||||||
|
result.tag = `<img src="${dataUri}" style="max-width:${maxW}px;max-height:${maxH}px;">`;
|
||||||
|
result.reason = reasonCode;
|
||||||
|
logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:query:match', {
|
||||||
|
reasonCode,
|
||||||
|
mime,
|
||||||
|
dataUri_len: dataUri.length,
|
||||||
|
dataUri_head: dataUri.slice(0, 45)
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('DocumentTemplateService.getProfitPlanetSignatureTag:query_error', { reason: reasonCode, error: e.message });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
if (await fetchOne(
|
||||||
|
'SELECT mime_type,image_base64 FROM company_stamps WHERE company_id = ? AND is_active = 1 ORDER BY id DESC LIMIT 1',
|
||||||
|
[companyId],
|
||||||
|
'company_active'
|
||||||
|
)) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; }
|
||||||
|
|
||||||
|
if (await fetchOne(
|
||||||
|
'SELECT mime_type,image_base64 FROM company_stamps WHERE company_id = ? ORDER BY is_active DESC, id DESC LIMIT 1',
|
||||||
|
[companyId],
|
||||||
|
'company_any'
|
||||||
|
)) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await fetchOne(
|
||||||
|
'SELECT mime_type,image_base64 FROM company_stamps WHERE is_active = 1 ORDER BY id DESC LIMIT 1',
|
||||||
|
[],
|
||||||
|
'global_active'
|
||||||
|
)) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; }
|
||||||
|
|
||||||
|
if (await fetchOne(
|
||||||
|
'SELECT mime_type,image_base64 FROM company_stamps ORDER BY is_active DESC, id DESC LIMIT 1',
|
||||||
|
[],
|
||||||
|
'global_any'
|
||||||
|
)) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; }
|
||||||
|
|
||||||
|
result.reason = 'not_found';
|
||||||
|
await uow.commit();
|
||||||
|
logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:not_found', { companyId });
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
result.reason = 'error';
|
||||||
|
logger.error('DocumentTemplateService.getProfitPlanetSignatureTag:error', { error: err.message, companyId });
|
||||||
|
try { await uow.rollback(err); } catch (rbErr) {
|
||||||
|
logger.error('DocumentTemplateService.getProfitPlanetSignatureTag:rollback_error', { error: rbErr.message });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:final', { reason: result.reason, hasTag: !!result.tag });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new DocumentTemplateService();
|
||||||
90
services/EmailVerificationService.js
Normal file
90
services/EmailVerificationService.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
const EmailVerificationRepository = require('../repositories/EmailVerificationRepository');
|
||||||
|
const MailService = require('./MailService');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class EmailVerificationService {
|
||||||
|
static async sendVerificationEmail(user, unitOfWork) {
|
||||||
|
logger.info('EmailVerificationService.sendVerificationEmail:start', { userId: user.id, email: user.email });
|
||||||
|
const emailVerificationRepo = new EmailVerificationRepository(unitOfWork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if already verified
|
||||||
|
const status = await unitOfWork.connection.query(
|
||||||
|
'SELECT email_verified FROM user_status WHERE user_id = ?', [user.id]
|
||||||
|
);
|
||||||
|
if (status[0][0] && status[0][0].email_verified) {
|
||||||
|
logger.warn('EmailVerificationService.sendVerificationEmail:already_verified', { userId: user.id });
|
||||||
|
throw new Error('Email already verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 6-digit code and expiry
|
||||||
|
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||||
|
|
||||||
|
// Upsert code in DB
|
||||||
|
await emailVerificationRepo.upsertCode(user.id, code, expiresAt);
|
||||||
|
logger.info('EmailVerificationService.sendVerificationEmail:code_upserted', { userId: user.id });
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
await MailService.sendVerificationCodeEmail({
|
||||||
|
email: user.email,
|
||||||
|
code,
|
||||||
|
expiresAt
|
||||||
|
});
|
||||||
|
logger.info('EmailVerificationService.sendVerificationEmail:email_sent', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
|
logger.info('EmailVerificationService.sendVerificationEmail:success', { userId: user.id });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EmailVerificationService.sendVerificationEmail:error', { userId: user.id, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifyCode(userId, code, unitOfWork) {
|
||||||
|
logger.info('EmailVerificationService.verifyCode:start', { userId, code });
|
||||||
|
const emailVerificationRepo = new EmailVerificationRepository(unitOfWork);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get latest, unexpired, unverified code
|
||||||
|
const record = await emailVerificationRepo.getByUserId(userId);
|
||||||
|
if (
|
||||||
|
!record ||
|
||||||
|
record.verified_at ||
|
||||||
|
new Date(record.expires_at) < new Date() ||
|
||||||
|
record.verification_code !== code
|
||||||
|
) {
|
||||||
|
logger.warn('EmailVerificationService.verifyCode:invalid_or_expired', { userId, code });
|
||||||
|
// Optionally increment attempts
|
||||||
|
if (record) {
|
||||||
|
await emailVerificationRepo.incrementAttempts(record.id);
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Invalid or expired code' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as verified
|
||||||
|
await emailVerificationRepo.setVerified(record.id);
|
||||||
|
logger.info('EmailVerificationService.verifyCode:code_verified', { userId });
|
||||||
|
|
||||||
|
// Update user_status
|
||||||
|
await unitOfWork.connection.query(
|
||||||
|
`UPDATE user_status SET email_verified = 1, email_verified_at = NOW() WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('EmailVerificationService.verifyCode:user_status_updated', { userId });
|
||||||
|
|
||||||
|
// Check if all steps are complete and set status to 'pending' if so
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
|
||||||
|
logger.info('EmailVerificationService.verifyCode:pending_check_complete', { userId });
|
||||||
|
|
||||||
|
logger.info('EmailVerificationService.verifyCode:success', { userId });
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('EmailVerificationService.verifyCode:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = EmailVerificationService;
|
||||||
230
services/LoginService.js
Normal file
230
services/LoginService.js
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
const UserRepository = require('../repositories/UserRepository');
|
||||||
|
const LoginRepository = require('../repositories/LoginRepository');
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class LoginService {
|
||||||
|
static async login(email, password) {
|
||||||
|
logger.info('LoginService.login:start', { email });
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('user', new UserRepository(unitOfWork));
|
||||||
|
unitOfWork.registerRepository('login', new LoginRepository(unitOfWork));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find user by email
|
||||||
|
const userRepo = unitOfWork.getRepository('user');
|
||||||
|
const loginRepo = unitOfWork.getRepository('login');
|
||||||
|
const user = await userRepo.findUserByEmail(email);
|
||||||
|
if (!user) {
|
||||||
|
logger.warn('LoginService.login:user_not_found', { email });
|
||||||
|
await unitOfWork.rollback();
|
||||||
|
const error = new Error('Invalid credentials');
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password
|
||||||
|
const valid = await user.comparePassword(password);
|
||||||
|
if (!valid) {
|
||||||
|
logger.warn('LoginService.login:invalid_password', { email });
|
||||||
|
await unitOfWork.rollback();
|
||||||
|
const error = new Error('Invalid credentials');
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate access token (JWT)
|
||||||
|
const accessToken = jwt.sign(
|
||||||
|
{ userId: user.id, email: user.email, userType: user.userType, role: user.role },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: process.env.JWT_EXPIRES_IN || '15m' }
|
||||||
|
);
|
||||||
|
logger.info('LoginService.login:access_token_issued', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
|
// Generate refresh token (random string)
|
||||||
|
const refreshToken = crypto.randomBytes(64).toString('hex');
|
||||||
|
// Use REFRESH_TOKEN_EXPIRES_IN from env, fallback to 7d
|
||||||
|
const refreshExpiresMs = (() => {
|
||||||
|
const envVal = process.env.REFRESH_TOKEN_EXPIRES_IN || '7d';
|
||||||
|
if (envVal.endsWith('d')) return parseInt(envVal) * 24 * 60 * 60 * 1000;
|
||||||
|
if (envVal.endsWith('h')) return parseInt(envVal) * 60 * 60 * 1000;
|
||||||
|
if (envVal.endsWith('m')) return parseInt(envVal) * 60 * 1000;
|
||||||
|
return 7 * 24 * 60 * 60 * 1000;
|
||||||
|
})();
|
||||||
|
const expiresAt = new Date(Date.now() + refreshExpiresMs);
|
||||||
|
|
||||||
|
// Store refresh token in DB (atomic) via LoginRepository
|
||||||
|
await loginRepo.insertRefreshToken(user.id, refreshToken, expiresAt);
|
||||||
|
logger.info('LoginService.login:refresh_token_issued', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
|
// Update last_login_at in users table via LoginRepository
|
||||||
|
await loginRepo.updateLastLogin(user.id);
|
||||||
|
logger.info('LoginService.login:last_login_updated', { userId: user.id });
|
||||||
|
|
||||||
|
// Fetch user permissions via LoginRepository
|
||||||
|
const permissions = await loginRepo.getUserPermissions(user.id);
|
||||||
|
logger.info('LoginService.login:permissions_fetched', { userId: user.id, count: permissions.length });
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('LoginService.login:success', { userId: user.id });
|
||||||
|
|
||||||
|
// Send access token in response
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
refreshTokenExpires: expiresAt,
|
||||||
|
user: {
|
||||||
|
...user.getPublicData(),
|
||||||
|
role: user.role,
|
||||||
|
permissions // Add permissions array to response
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginService.login:error', { email, error: error.message });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async refresh(refreshToken) {
|
||||||
|
logger.info('LoginService.refresh:start');
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('login', new LoginRepository(unitOfWork));
|
||||||
|
unitOfWork.registerRepository('user', new UserRepository(unitOfWork));
|
||||||
|
try {
|
||||||
|
const loginRepo = unitOfWork.getRepository('login');
|
||||||
|
// Find refresh token in DB via LoginRepository
|
||||||
|
const tokenRecord = await loginRepo.findRefreshToken(refreshToken);
|
||||||
|
if (!tokenRecord) {
|
||||||
|
logger.warn('LoginService.refresh:token_not_found');
|
||||||
|
await unitOfWork.rollback();
|
||||||
|
const error = new Error('Invalid or expired refresh token');
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { user_id, expires_at } = tokenRecord;
|
||||||
|
if (new Date(expires_at) < new Date()) {
|
||||||
|
logger.warn('LoginService.refresh:token_expired');
|
||||||
|
await unitOfWork.rollback();
|
||||||
|
const error = new Error('Refresh token expired');
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate refresh token: revoke old, issue new via LoginRepository
|
||||||
|
await loginRepo.revokeRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
const newRefreshToken = crypto.randomBytes(64).toString('hex');
|
||||||
|
const refreshExpiresMs = (() => {
|
||||||
|
const envVal = process.env.REFRESH_TOKEN_EXPIRES_IN || '7d';
|
||||||
|
if (envVal.endsWith('d')) return parseInt(envVal) * 24 * 60 * 60 * 1000;
|
||||||
|
if (envVal.endsWith('h')) return parseInt(envVal) * 60 * 60 * 1000;
|
||||||
|
if (envVal.endsWith('m')) return parseInt(envVal) * 60 * 1000;
|
||||||
|
return 7 * 24 * 60 * 60 * 1000;
|
||||||
|
})();
|
||||||
|
const newExpiresAt = new Date(Date.now() + refreshExpiresMs);
|
||||||
|
|
||||||
|
await loginRepo.insertRefreshToken(user_id, newRefreshToken, newExpiresAt);
|
||||||
|
logger.info('LoginService.refresh:refresh_token_rotated', { userId: user_id });
|
||||||
|
|
||||||
|
// Get user via UserRepository
|
||||||
|
const userRepo = unitOfWork.getRepository('user');
|
||||||
|
const user = await userRepo.findUserByEmailOrId(user_id);
|
||||||
|
|
||||||
|
// Fetch user role directly from DB via LoginRepository
|
||||||
|
const role = await loginRepo.getUserRole(user.id);
|
||||||
|
|
||||||
|
// Fetch user permissions via LoginRepository
|
||||||
|
const permissions = await loginRepo.getUserPermissions(user.id);
|
||||||
|
logger.info('LoginService.refresh:user_fetched', { userId: user.id });
|
||||||
|
logger.info('LoginService.refresh:role_fetched', { userId: user.id, role });
|
||||||
|
logger.info('LoginService.refresh:permissions_fetched', { userId: user.id, count: permissions.length });
|
||||||
|
|
||||||
|
// Generate new access token
|
||||||
|
const accessToken = jwt.sign(
|
||||||
|
{ userId: user.id, email: user.email, userType: user.userType, role },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: process.env.JWT_EXPIRES_IN || '15m' }
|
||||||
|
);
|
||||||
|
logger.info('LoginService.refresh:access_token_issued', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('LoginService.refresh:success', { userId: user.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
refreshTokenExpires: newExpiresAt,
|
||||||
|
user: {
|
||||||
|
...user.getPublicData(),
|
||||||
|
role,
|
||||||
|
permissions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginService.refresh:error', { error: error.message });
|
||||||
|
// Improved error logging for refresh token rotation failures
|
||||||
|
console.error('💥 Error during refresh token rotation:', error);
|
||||||
|
if (error && error.stack) {
|
||||||
|
console.error('💥 Error stack:', error.stack);
|
||||||
|
}
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async logout(refreshToken) {
|
||||||
|
logger.info('LoginService.logout:start');
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('login', new LoginRepository(unitOfWork));
|
||||||
|
try {
|
||||||
|
const loginRepo = unitOfWork.getRepository('login');
|
||||||
|
// Mark refresh token as revoked via LoginRepository
|
||||||
|
await loginRepo.revokeRefreshToken(refreshToken);
|
||||||
|
logger.info('LoginService.logout:refresh_token_revoked');
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('LoginService.logout:success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginService.logout:error', { error: error.message });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for finding user by id or email
|
||||||
|
LoginService.findUserByEmailOrId = async function(userIdOrEmail, unitOfWork) {
|
||||||
|
logger.info('LoginService.findUserByEmailOrId:start', { userIdOrEmail });
|
||||||
|
const userRepo = new UserRepository(unitOfWork);
|
||||||
|
try {
|
||||||
|
if (typeof userIdOrEmail === 'number') {
|
||||||
|
// Find by id
|
||||||
|
const query = `SELECT * FROM users WHERE id = ?`;
|
||||||
|
const [rows] = await unitOfWork.connection.query(query, [userIdOrEmail]);
|
||||||
|
if (!rows.length) {
|
||||||
|
logger.warn('LoginService.findUserByEmailOrId:not_found', { userIdOrEmail });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const row = rows[0];
|
||||||
|
// You may want to instantiate the correct user type here
|
||||||
|
logger.info('LoginService.findUserByEmailOrId:found', { userIdOrEmail });
|
||||||
|
return userRepo.findUserByEmail(row.email);
|
||||||
|
} else {
|
||||||
|
// Find by email
|
||||||
|
const user = await userRepo.findUserByEmail(userIdOrEmail);
|
||||||
|
logger.info('LoginService.findUserByEmailOrId:found', { userIdOrEmail, found: !!user });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LoginService.findUserByEmailOrId:error', { userIdOrEmail, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = LoginService;
|
||||||
183
services/MailService.js
Normal file
183
services/MailService.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
const brevo = require('@getbrevo/brevo');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class MailService {
|
||||||
|
constructor() {
|
||||||
|
this.brevo = new brevo.TransactionalEmailsApi();
|
||||||
|
this.brevo.setApiKey(
|
||||||
|
brevo.TransactionalEmailsApiApiKeys.apiKey,
|
||||||
|
process.env.BREVO_API_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
this.sender = {
|
||||||
|
email: process.env.BREVO_SENDER_EMAIL,
|
||||||
|
name: process.env.BREVO_SENDER_NAME,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.loginUrl = process.env.LOGIN_URL;
|
||||||
|
this.templatesDir = path.join(__dirname, '../mailTemplates');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to load and render a template
|
||||||
|
renderTemplate(templateName, variables, lang = 'en') {
|
||||||
|
logger.info('MailService.renderTemplate:start', { templateName, lang });
|
||||||
|
// Supported languages
|
||||||
|
const supportedLangs = ['en', 'de'];
|
||||||
|
// Fallback to 'en' if unsupported or missing
|
||||||
|
const chosenLang = supportedLangs.includes(lang) ? lang : 'en';
|
||||||
|
const templatePath = path.join(this.templatesDir, chosenLang, templateName);
|
||||||
|
let template;
|
||||||
|
try {
|
||||||
|
template = fs.readFileSync(templatePath, 'utf8');
|
||||||
|
logger.info('MailService.renderTemplate:template_loaded', { templatePath, lang: chosenLang });
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback to English if template not found
|
||||||
|
if (chosenLang !== 'en') {
|
||||||
|
const fallbackPath = path.join(this.templatesDir, 'en', templateName);
|
||||||
|
template = fs.readFileSync(fallbackPath, 'utf8');
|
||||||
|
logger.warn('MailService.renderTemplate:fallback_to_en', { fallbackPath, lang });
|
||||||
|
} else {
|
||||||
|
logger.error('MailService.renderTemplate:error', { templatePath, error: err.message });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
template = template.replace(/{{(\w+)}}/g, (_, key) => variables[key] || '');
|
||||||
|
logger.info('MailService.renderTemplate:success', { templateName, lang: chosenLang });
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendRegistrationEmail({ email, firstName, lastName, userType, companyName, lang }) {
|
||||||
|
logger.info('MailService.sendRegistrationEmail:start', { email, userType, lang });
|
||||||
|
let subject, text;
|
||||||
|
const chosenLang = lang || 'en';
|
||||||
|
if (userType === 'personal') {
|
||||||
|
subject = chosenLang === 'de'
|
||||||
|
? `Willkommen bei ProfitPlanet, ${firstName}!`
|
||||||
|
: `Welcome to ProfitPlanet, ${firstName}!`;
|
||||||
|
text = this.renderTemplate('registrationPersonal.txt', {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
loginUrl: this.loginUrl
|
||||||
|
}, chosenLang);
|
||||||
|
} else if (userType === 'company') {
|
||||||
|
subject = chosenLang === 'de'
|
||||||
|
? `Willkommen bei ProfitPlanet, ${companyName}!`
|
||||||
|
: `Welcome to ProfitPlanet, ${companyName}!`;
|
||||||
|
text = this.renderTemplate('registrationCompany.txt', {
|
||||||
|
companyName,
|
||||||
|
loginUrl: this.loginUrl
|
||||||
|
}, chosenLang);
|
||||||
|
} else {
|
||||||
|
subject = chosenLang === 'de'
|
||||||
|
? 'Willkommen bei ProfitPlanet!'
|
||||||
|
: 'Welcome to ProfitPlanet!';
|
||||||
|
text = chosenLang === 'de'
|
||||||
|
? `Danke für Ihre Registrierung bei ProfitPlanet!\nSie können sich jetzt hier anmelden: ${this.loginUrl}\n\nMit freundlichen Grüßen,\nProfitPlanet Team`
|
||||||
|
: `Thank you for registering at ProfitPlanet!\nYou can now log in here: ${this.loginUrl}\n\nBest regards,\nProfitPlanet Team`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = new brevo.SendSmtpEmail();
|
||||||
|
payload.sender = this.sender;
|
||||||
|
payload.to = [{ email }];
|
||||||
|
payload.subject = subject;
|
||||||
|
payload.textContent = text;
|
||||||
|
|
||||||
|
const data = await this.brevo.sendTransacEmail(payload);
|
||||||
|
logger.info('MailService.sendRegistrationEmail:email_sent', { email, userType, lang: chosenLang });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('MailService.sendRegistrationEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendVerificationCodeEmail({ email, code, expiresAt, lang }) {
|
||||||
|
logger.info('MailService.sendVerificationCodeEmail:start', { email, code, expiresAt, lang });
|
||||||
|
const chosenLang = lang || 'en';
|
||||||
|
const subject = chosenLang === 'de'
|
||||||
|
? 'Ihr ProfitPlanet E-Mail-Verifizierungscode'
|
||||||
|
: 'Your ProfitPlanet Email Verification Code';
|
||||||
|
const text = this.renderTemplate('verificationCode.txt', {
|
||||||
|
code,
|
||||||
|
expiresAt: expiresAt.toLocaleTimeString()
|
||||||
|
}, chosenLang);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = new brevo.SendSmtpEmail();
|
||||||
|
payload.sender = this.sender;
|
||||||
|
payload.to = [{ email }];
|
||||||
|
payload.subject = subject;
|
||||||
|
payload.textContent = text;
|
||||||
|
|
||||||
|
const data = await this.brevo.sendTransacEmail(payload);
|
||||||
|
logger.info('MailService.sendVerificationCodeEmail:email_sent', { email, code, lang: chosenLang });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('MailService.sendVerificationCodeEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendLoginNotificationEmail({ email, ip, loginTime, userAgent, lang }) {
|
||||||
|
logger.info('MailService.sendLoginNotificationEmail:start', { email, ip, loginTime, userAgent, lang });
|
||||||
|
const chosenLang = lang || 'en';
|
||||||
|
const subject = chosenLang === 'de'
|
||||||
|
? 'ProfitPlanet: Neue Login-Benachrichtigung'
|
||||||
|
: 'ProfitPlanet: New Login Notification';
|
||||||
|
const text = this.renderTemplate('loginNotification.txt', {
|
||||||
|
email,
|
||||||
|
ip,
|
||||||
|
loginTime: loginTime.toLocaleString(),
|
||||||
|
userAgent
|
||||||
|
}, chosenLang);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = new brevo.SendSmtpEmail();
|
||||||
|
payload.sender = this.sender;
|
||||||
|
payload.to = [{ email }];
|
||||||
|
payload.subject = subject;
|
||||||
|
payload.textContent = text;
|
||||||
|
|
||||||
|
const data = await this.brevo.sendTransacEmail(payload);
|
||||||
|
logger.info('MailService.sendLoginNotificationEmail:email_sent', { email, ip, lang: chosenLang });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('MailService.sendLoginNotificationEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPasswordResetEmail({ email, firstName, companyName, token, lang }) {
|
||||||
|
logger.info('MailService.sendPasswordResetEmail:start', { email, token, lang });
|
||||||
|
const chosenLang = lang || 'en';
|
||||||
|
const subject = chosenLang === 'de'
|
||||||
|
? 'ProfitPlanet: Passwort zurücksetzen'
|
||||||
|
: 'ProfitPlanet: Password Reset';
|
||||||
|
const resetUrl = `${process.env.PASSWORD_RESET_URL || 'https://profit-planet.partners/password-reset-set'}?token=${token}`;
|
||||||
|
const text = this.renderTemplate('passwordReset.txt', {
|
||||||
|
firstName: firstName || '',
|
||||||
|
companyName: companyName || '',
|
||||||
|
resetUrl
|
||||||
|
}, chosenLang);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = new brevo.SendSmtpEmail();
|
||||||
|
payload.sender = this.sender;
|
||||||
|
payload.to = [{ email }];
|
||||||
|
payload.subject = subject;
|
||||||
|
payload.textContent = text;
|
||||||
|
|
||||||
|
const data = await this.brevo.sendTransacEmail(payload);
|
||||||
|
logger.info('MailService.sendPasswordResetEmail:email_sent', { email, token, lang: chosenLang });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('MailService.sendPasswordResetEmail:error', { email, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new MailService();
|
||||||
32
services/PermissionService.js
Normal file
32
services/PermissionService.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const PermissionRepository = require('../repositories/PermissionRepository');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class PermissionService {
|
||||||
|
static async getAllPermissions(unitOfWork) {
|
||||||
|
logger.info('PermissionService.getAllPermissions:start');
|
||||||
|
try {
|
||||||
|
const repo = new PermissionRepository(unitOfWork);
|
||||||
|
const permissions = await repo.getAllPermissions();
|
||||||
|
logger.info('PermissionService.getAllPermissions:success', { count: permissions.length });
|
||||||
|
return permissions;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PermissionService.getAllPermissions:error', { error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createPermission(data, userId, unitOfWork) {
|
||||||
|
logger.info('PermissionService.createPermission:start', { userId, name: data.name });
|
||||||
|
try {
|
||||||
|
const repo = new PermissionRepository(unitOfWork);
|
||||||
|
const permission = await repo.createPermission({ ...data, created_by: userId });
|
||||||
|
logger.info('PermissionService.createPermission:success', { id: permission.id, name: permission.name });
|
||||||
|
return permission;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PermissionService.createPermission:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PermissionService;
|
||||||
86
services/PersonalDocumentService.js
Normal file
86
services/PersonalDocumentService.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
const UserDocumentRepository = require('../repositories/UserDocumentRepository');
|
||||||
|
const { uploadBuffer } = require('../utils/exoscaleUploader');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class PersonalDocumentService {
|
||||||
|
static async uploadPersonalId({ userId, idType, idNumber, expiryDate, files, unitOfWork }) {
|
||||||
|
logger.info('PersonalDocumentService.uploadPersonalId:start', { userId, idType, idNumber, expiryDate });
|
||||||
|
try {
|
||||||
|
// Validate required fields
|
||||||
|
if (!idType || !idNumber || !expiryDate || !files || !files.front) {
|
||||||
|
logger.warn('PersonalDocumentService.uploadPersonalId:missing_fields', { userId });
|
||||||
|
throw new Error('Missing required fields or front image');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file types (images only)
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
if (!allowedTypes.includes(files.front.mimetype)) {
|
||||||
|
logger.warn('PersonalDocumentService.uploadPersonalId:invalid_front_type', { userId, mimetype: files.front.mimetype });
|
||||||
|
throw new Error('Invalid file type for front image');
|
||||||
|
}
|
||||||
|
if (files.back && !allowedTypes.includes(files.back.mimetype)) {
|
||||||
|
logger.warn('PersonalDocumentService.uploadPersonalId:invalid_back_type', { userId, mimetype: files.back.mimetype });
|
||||||
|
throw new Error('Invalid file type for back image');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('PersonalDocumentService.uploadPersonalId:uploading_front', { userId });
|
||||||
|
// Upload front image
|
||||||
|
const frontUpload = await uploadBuffer(
|
||||||
|
files.front.buffer,
|
||||||
|
files.front.originalname,
|
||||||
|
files.front.mimetype,
|
||||||
|
`personal-id/${userId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload back image if present
|
||||||
|
let backUpload = null;
|
||||||
|
if (files.back) {
|
||||||
|
logger.info('PersonalDocumentService.uploadPersonalId:uploading_back', { userId });
|
||||||
|
backUpload = await uploadBuffer(
|
||||||
|
files.back.buffer,
|
||||||
|
files.back.originalname,
|
||||||
|
files.back.mimetype,
|
||||||
|
`personal-id/${userId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a single row in user_id_documents for both images (back may be null)
|
||||||
|
const repo = new UserDocumentRepository(unitOfWork);
|
||||||
|
await repo.insertIdDocument({
|
||||||
|
userId,
|
||||||
|
documentType: 'personal_id',
|
||||||
|
frontObjectStorageId: frontUpload.objectKey,
|
||||||
|
backObjectStorageId: backUpload ? backUpload.objectKey : null,
|
||||||
|
idType,
|
||||||
|
idNumber,
|
||||||
|
expiryDate,
|
||||||
|
originalFilenameFront: files.front.originalname,
|
||||||
|
originalFilenameBack: files.back ? files.back.originalname : null
|
||||||
|
});
|
||||||
|
logger.info('PersonalDocumentService.uploadPersonalId:id_document_inserted', { userId });
|
||||||
|
|
||||||
|
// Set documents_uploaded in user_status
|
||||||
|
await unitOfWork.connection.query(
|
||||||
|
`UPDATE user_status SET documents_uploaded = 1, documents_uploaded_at = NOW() WHERE user_id = ?`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
logger.info('PersonalDocumentService.uploadPersonalId:user_status_updated', { userId });
|
||||||
|
|
||||||
|
// Check if all steps are complete and set status to 'pending' if so
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
|
||||||
|
logger.info('PersonalDocumentService.uploadPersonalId:pending_check_complete', { userId });
|
||||||
|
|
||||||
|
logger.info('PersonalDocumentService.uploadPersonalId:success', { userId });
|
||||||
|
return {
|
||||||
|
front: frontUpload,
|
||||||
|
back: backUpload
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalDocumentService.uploadPersonalId:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalDocumentService;
|
||||||
25
services/PersonalProfileService.js
Normal file
25
services/PersonalProfileService.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const PersonalUserRepository = require('../repositories/PersonalUserRepository');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class PersonalProfileService {
|
||||||
|
static async completeProfile(userId, profileData, unitOfWork) {
|
||||||
|
logger.info('PersonalProfileService.completeProfile:start', { userId });
|
||||||
|
try {
|
||||||
|
const repo = new PersonalUserRepository(unitOfWork);
|
||||||
|
await repo.updateAdditionalProfileAndMarkCompleted(userId, profileData);
|
||||||
|
logger.info('PersonalProfileService.completeProfile:profile_completed', { userId });
|
||||||
|
|
||||||
|
// Check if all steps are complete and set status to 'pending' if so
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
|
||||||
|
logger.info('PersonalProfileService.completeProfile:pending_check_complete', { userId });
|
||||||
|
logger.info('PersonalProfileService.completeProfile:success', { userId });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalProfileService.completeProfile:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalProfileService;
|
||||||
84
services/PersonalUserService.js
Normal file
84
services/PersonalUserService.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
const PersonalUserRepository = require('../repositories/PersonalUserRepository');
|
||||||
|
const ReferralService = require('./ReferralService');
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
const UnitOfWork = require('../repositories/UnitOfWork');
|
||||||
|
const MailService = require('./MailService');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class PersonalUserService {
|
||||||
|
static async createPersonalUser({ email, password, firstName, lastName, phone, referralEmail }) {
|
||||||
|
logger.info('PersonalUserService.createPersonalUser:start', { email, firstName, lastName, hasReferral: !!referralEmail });
|
||||||
|
console.log('📝 PersonalUserService: Creating personal user...');
|
||||||
|
console.log('📋 Data to be saved:', { email, firstName, lastName, phone, hasReferral: !!referralEmail });
|
||||||
|
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
|
||||||
|
// Register repositories
|
||||||
|
unitOfWork.registerRepository('personalUser', new PersonalUserRepository(unitOfWork));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create user and profile via repository
|
||||||
|
const personalRepo = unitOfWork.getRepository('personalUser');
|
||||||
|
const newUser = await personalRepo.create({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
referralEmail
|
||||||
|
});
|
||||||
|
logger.info('PersonalUserService.createPersonalUser:user_created', { userId: newUser.id });
|
||||||
|
|
||||||
|
// Initialize user status
|
||||||
|
await UserStatusService.initializeUserStatus(newUser.id, 'personal', unitOfWork, 'inactive');
|
||||||
|
logger.info('PersonalUserService.createPersonalUser:user_status_initialized', { userId: newUser.id });
|
||||||
|
|
||||||
|
// Handle referral if provided
|
||||||
|
if (referralEmail) {
|
||||||
|
logger.info('PersonalUserService.createPersonalUser:processing_referral', { userId: newUser.id, referralEmail });
|
||||||
|
console.log('🔗 Processing referral email:', referralEmail);
|
||||||
|
await ReferralService.processReferral(newUser.id, referralEmail, unitOfWork);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send registration email to the new user
|
||||||
|
await MailService.sendRegistrationEmail({
|
||||||
|
email: newUser.email,
|
||||||
|
firstName: newUser.firstName,
|
||||||
|
lastName: newUser.lastName,
|
||||||
|
userType: 'personal'
|
||||||
|
});
|
||||||
|
logger.info('PersonalUserService.createPersonalUser:registration_email_sent', { email: newUser.email });
|
||||||
|
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('PersonalUserService.createPersonalUser:success', { userId: newUser.id });
|
||||||
|
return newUser;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalUserService.createPersonalUser:error', { email, error: error.message });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
console.error('💥 PersonalUserService: Error creating user:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findPersonalUserByEmail(email) {
|
||||||
|
logger.info('PersonalUserService.findPersonalUserByEmail:start', { email });
|
||||||
|
// For read-only, you can use a short-lived UnitOfWork or refactor as needed
|
||||||
|
const unitOfWork = new UnitOfWork();
|
||||||
|
await unitOfWork.start();
|
||||||
|
unitOfWork.registerRepository('personalUser', new PersonalUserRepository(unitOfWork));
|
||||||
|
try {
|
||||||
|
const personalRepo = unitOfWork.getRepository('personalUser');
|
||||||
|
const user = await personalRepo.findByEmail(email);
|
||||||
|
await unitOfWork.commit();
|
||||||
|
logger.info('PersonalUserService.findPersonalUserByEmail:success', { email, found: !!user });
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PersonalUserService.findPersonalUserByEmail:error', { email, error: error.message });
|
||||||
|
await unitOfWork.rollback(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = PersonalUserService;
|
||||||
575
services/ReferralService.js
Normal file
575
services/ReferralService.js
Normal file
@ -0,0 +1,575 @@
|
|||||||
|
const ReferralTokenRepository = require('../repositories/ReferralTokenRepository');
|
||||||
|
const PersonalUserRepository = require('../repositories/PersonalUserRepository');
|
||||||
|
const CompanyUserRepository = require('../repositories/CompanyUserRepository');
|
||||||
|
const UserStatusService = require('./UserStatusService');
|
||||||
|
const UserSettingsRepository = require('../repositories/UserSettingsRepository');
|
||||||
|
const MailService = require('./MailService');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class ReferralService {
|
||||||
|
// --- Token creation and management ---
|
||||||
|
static async createReferralToken({ userId, expiresInDays, maxUses, unitOfWork }) {
|
||||||
|
logger.info('ReferralService:createReferralToken:start', { userId, expiresInDays, maxUses });
|
||||||
|
|
||||||
|
const normalizeUnlimited = (val, label) => {
|
||||||
|
if (val === undefined || val === null || val === '') return -1;
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
const trimmed = val.trim().toLowerCase();
|
||||||
|
if (['unlimited','∞','-1'].includes(trimmed)) return -1;
|
||||||
|
const num = Number(val);
|
||||||
|
if (!Number.isNaN(num)) val = num;
|
||||||
|
}
|
||||||
|
// Treat 0 as unlimited (frontend currently sends 0 for unlimited)
|
||||||
|
if (val === 0) {
|
||||||
|
logger.debug(`ReferralService:createReferralToken:interpreting_0_as_unlimited`, { field: label });
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
|
||||||
|
expiresInDays = normalizeUnlimited(expiresInDays, 'expiresInDays');
|
||||||
|
maxUses = normalizeUnlimited(maxUses, 'maxUses');
|
||||||
|
|
||||||
|
if (!(expiresInDays === -1 || (Number.isInteger(expiresInDays) && expiresInDays >= 1 && expiresInDays <= 7))) {
|
||||||
|
throw new Error('Expiry must be between 1 and 7 days or unlimited');
|
||||||
|
}
|
||||||
|
if (!(maxUses === -1 || (Number.isInteger(maxUses) && maxUses >= 1))) {
|
||||||
|
throw new Error('maxUses must be a positive integer or unlimited');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlimited = maxUses === -1;
|
||||||
|
if (unlimited) logger.info('ReferralService:createReferralToken:unlimited', { userId });
|
||||||
|
|
||||||
|
const UNLIMITED_EXPIRY_DATE = new Date('2999-12-31T23:59:59Z');
|
||||||
|
const expiresAt = (expiresInDays === -1)
|
||||||
|
? UNLIMITED_EXPIRY_DATE
|
||||||
|
: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
const token = await repo.createToken({
|
||||||
|
createdByUserId: userId,
|
||||||
|
expiresAt,
|
||||||
|
maxUses
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('ReferralService:createReferralToken:success', {
|
||||||
|
token: token.token,
|
||||||
|
unlimited,
|
||||||
|
storedMaxUses: token.maxUses,
|
||||||
|
storedUsesRemaining: token.usesRemaining
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.presentToken({
|
||||||
|
id: token.id,
|
||||||
|
token: token.token,
|
||||||
|
created_by_user_id: token.createdByUserId,
|
||||||
|
expires_at: token.expiresAt,
|
||||||
|
status: token.status,
|
||||||
|
max_uses: token.maxUses,
|
||||||
|
uses_remaining: token.usesRemaining
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserReferralTokens(userId, unitOfWork) {
|
||||||
|
logger.info('ReferralService:getUserReferralTokens:start', { userId });
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
const rows = await repo.getTokensByUser(userId);
|
||||||
|
|
||||||
|
const base = process.env.REFERRAL_PUBLIC_BASE_URL || 'https://profit-planet.partners/register?ref=';
|
||||||
|
|
||||||
|
const tokens = rows.map(r => {
|
||||||
|
const max = r.max_uses;
|
||||||
|
const remaining = r.uses_remaining;
|
||||||
|
const isUnlimited = (max === -1) || (remaining === -1);
|
||||||
|
let used;
|
||||||
|
|
||||||
|
if (isUnlimited) {
|
||||||
|
used = 0;
|
||||||
|
} else if (typeof r.used_count === 'number') {
|
||||||
|
used = r.used_count;
|
||||||
|
} else if (Number.isFinite(max) && Number.isFinite(remaining)) {
|
||||||
|
used = max - remaining;
|
||||||
|
} else {
|
||||||
|
used = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(used) || used < 0) used = 0;
|
||||||
|
if (!isUnlimited && used > max) used = max;
|
||||||
|
|
||||||
|
const maxUsesCanonical = isUnlimited ? null : max;
|
||||||
|
const usage = isUnlimited ? 'unlimited' : `${used}/${maxUsesCanonical}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
token: r.token,
|
||||||
|
link: `${base}${r.token}`,
|
||||||
|
status: r.status,
|
||||||
|
created: r.created_at ? new Date(r.created_at).toISOString() : null,
|
||||||
|
expires: isUnlimited ? null : (r.expires_at ? new Date(r.expires_at).toISOString() : null),
|
||||||
|
used,
|
||||||
|
maxUses: maxUsesCanonical,
|
||||||
|
isUnlimited,
|
||||||
|
usage,
|
||||||
|
progress: usage,
|
||||||
|
// Aliases (defensive)
|
||||||
|
useCount: used,
|
||||||
|
usedCount: used
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
tokens.slice(0, 5).forEach(t => {
|
||||||
|
logger.debug('ReferralService:getUserReferralTokens:canonical', {
|
||||||
|
id: t.id, usage: t.usage, used: t.used, maxUses: t.maxUses, isUnlimited: t.isUnlimited
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('ReferralService:getUserReferralTokens:success', { count: tokens.length });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserReferralStats(userId, unitOfWork) {
|
||||||
|
logger.info('ReferralService:getUserReferralStats:start', { userId });
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
const stats = await repo.getStatsByUser(userId);
|
||||||
|
logger.info('ReferralService:getUserReferralStats:success', { stats });
|
||||||
|
// Pass through all fields including new ones
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deactivateReferralToken(userId, tokenId, unitOfWork) {
|
||||||
|
logger.info('ReferralService:deactivateReferralToken:start', { userId, tokenId });
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
await repo.deactivateToken(tokenId, userId);
|
||||||
|
logger.info('ReferralService:deactivateReferralToken:success', { userId, tokenId });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async countActiveReferralTokens(userId, unitOfWork) {
|
||||||
|
logger.info('ReferralService:countActiveReferralTokens:start', { userId });
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
const count = await repo.countActiveTokensByUser(userId);
|
||||||
|
logger.info('ReferralService:countActiveReferralTokens:success', { userId, count });
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static presentToken(row) {
|
||||||
|
if (!row) return row;
|
||||||
|
|
||||||
|
// Raw DB fields
|
||||||
|
const maxNum = (row.max_uses !== undefined ? row.max_uses : row.maxUses);
|
||||||
|
const remNum = (row.uses_remaining !== undefined ? row.uses_remaining : row.usesRemaining);
|
||||||
|
const createdRaw = row.created_at || row.createdAt;
|
||||||
|
const expiresRaw = row.expires_at || row.expiresAt;
|
||||||
|
|
||||||
|
const createdDate = createdRaw ? new Date(createdRaw) : null;
|
||||||
|
const createdValid = createdDate && !isNaN(createdDate.getTime());
|
||||||
|
const createdISO = createdValid ? createdDate.toISOString() : null;
|
||||||
|
|
||||||
|
const expiresDate = expiresRaw ? new Date(expiresRaw) : null;
|
||||||
|
const expiresValid = expiresDate && !isNaN(expiresDate.getTime());
|
||||||
|
|
||||||
|
const unlimited = (maxNum === -1) || (remNum === -1);
|
||||||
|
const maxDisplay = row.max_uses_display || row.max_uses_label || (unlimited ? 'unlimited' : String(maxNum));
|
||||||
|
const remDisplay = row.uses_remaining_display || row.uses_remaining_label || (unlimited ? 'unlimited' : String(remNum));
|
||||||
|
|
||||||
|
// Numeric total & used
|
||||||
|
let totalCount = unlimited ? -1 : (
|
||||||
|
typeof row.total_count === 'number'
|
||||||
|
? row.total_count
|
||||||
|
: (Number.isFinite(maxNum) ? maxNum : 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
let usedCount = unlimited ? 0 : (
|
||||||
|
typeof row.used_count === 'number'
|
||||||
|
? row.used_count
|
||||||
|
: (Number.isFinite(maxNum) && Number.isFinite(remNum) ? (maxNum - remNum) : 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!unlimited) {
|
||||||
|
if (!Number.isFinite(usedCount) || usedCount < 0) usedCount = 0;
|
||||||
|
if (Number.isFinite(totalCount) && usedCount > totalCount) usedCount = totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingCount = unlimited
|
||||||
|
? -1
|
||||||
|
: (Number.isFinite(remNum)
|
||||||
|
? remNum
|
||||||
|
: (Number.isFinite(totalCount) ? (totalCount - usedCount) : 0));
|
||||||
|
|
||||||
|
// Build usage string deterministically (do NOT trust usage_display if present)
|
||||||
|
const usageString = unlimited
|
||||||
|
? 'unlimited'
|
||||||
|
: (totalCount > 0 ? `${usedCount}/${totalCount}` : '0/0');
|
||||||
|
|
||||||
|
const infinitySymbol = '∞';
|
||||||
|
const denominatorDisplay = unlimited ? infinitySymbol : String(totalCount);
|
||||||
|
const numeratorDisplay = unlimited ? '0' : String(usedCount);
|
||||||
|
|
||||||
|
// Expiry display (Never for unlimited OR far future year >= 2999)
|
||||||
|
const expiresAtISO = unlimited
|
||||||
|
? null
|
||||||
|
: (expiresValid ? expiresDate.toISOString() : null);
|
||||||
|
const expiresAtDisplay = unlimited
|
||||||
|
? 'Never'
|
||||||
|
: (expiresValid ? expiresDate.toISOString() : 'Unknown');
|
||||||
|
|
||||||
|
// Numeric maxUses for FE (for unlimited keep -1 sentinel)
|
||||||
|
const maxUsesNumber = unlimited ? -1 : (Number.isFinite(totalCount) ? totalCount : 0);
|
||||||
|
|
||||||
|
// NEW: derive referrer identity (email & name) early so it can be inserted into tokenObj
|
||||||
|
const referrerEmailDerived =
|
||||||
|
row.referrer_email ||
|
||||||
|
row.referrerEmail ||
|
||||||
|
row.email ||
|
||||||
|
row.created_by_email ||
|
||||||
|
row.createdByEmail ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
// Try to build a display name if first/last name fields exist (silent if not)
|
||||||
|
const refFirst = row.referrer_first_name || row.first_name || row.firstName;
|
||||||
|
const refLast = row.referrer_last_name || row.last_name || row.lastName;
|
||||||
|
let referrerNameDerived = row.referrer_name || row.referrerName || null;
|
||||||
|
if (!referrerNameDerived && (refFirst || refLast)) {
|
||||||
|
referrerNameDerived = [refFirst, refLast].filter(Boolean).join(' ').trim() || null;
|
||||||
|
}
|
||||||
|
if (!referrerNameDerived) {
|
||||||
|
// fallback to email for display if no name
|
||||||
|
referrerNameDerived = referrerEmailDerived || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenObj = {
|
||||||
|
id: row.id,
|
||||||
|
token: row.token,
|
||||||
|
status: row.status,
|
||||||
|
|
||||||
|
// Core time fields
|
||||||
|
createdAt: createdISO,
|
||||||
|
created_at: createdISO,
|
||||||
|
createdAtISO: createdISO,
|
||||||
|
createdAtEpoch: createdValid ? createdDate.getTime() : null,
|
||||||
|
|
||||||
|
expiresAt: expiresAtISO,
|
||||||
|
expires_at: expiresAtISO,
|
||||||
|
expiresAtISO,
|
||||||
|
expiresAtEpoch: (expiresAtISO ? expiresDate.getTime() : null),
|
||||||
|
expiresAtDisplay,
|
||||||
|
|
||||||
|
// Display & numeric usage
|
||||||
|
usage: usageString,
|
||||||
|
usageDisplay: usageString,
|
||||||
|
progress: usageString,
|
||||||
|
|
||||||
|
// Display values
|
||||||
|
maxUsesDisplay: unlimited ? 'unlimited' : String(maxUsesNumber),
|
||||||
|
usesRemainingDisplay: unlimited ? 'unlimited' : String(remainingCount),
|
||||||
|
|
||||||
|
// Primary numeric values (camelCase)
|
||||||
|
maxUses: maxUsesNumber,
|
||||||
|
usesRemaining: remainingCount,
|
||||||
|
used: usedCount,
|
||||||
|
isUnlimited: unlimited,
|
||||||
|
|
||||||
|
// Raw helpers
|
||||||
|
maxUsesRaw: maxUsesNumber,
|
||||||
|
usesRemainingRaw: remainingCount,
|
||||||
|
usesUsedRaw: usedCount,
|
||||||
|
|
||||||
|
// Additional alias set to satisfy unknown FE expectations
|
||||||
|
limit: maxUsesNumber,
|
||||||
|
total: maxUsesNumber,
|
||||||
|
totalCount: maxUsesNumber,
|
||||||
|
max: maxUsesNumber,
|
||||||
|
capacity: maxUsesNumber,
|
||||||
|
remaining: remainingCount,
|
||||||
|
remainingCount,
|
||||||
|
left: remainingCount,
|
||||||
|
usedCount,
|
||||||
|
currentUses: usedCount,
|
||||||
|
progressUsed: usedCount,
|
||||||
|
totalUses: maxUsesNumber,
|
||||||
|
remainingUses: remainingCount,
|
||||||
|
usedUses: usedCount,
|
||||||
|
|
||||||
|
// Snake_case (legacy)
|
||||||
|
max_uses: maxUsesNumber,
|
||||||
|
uses_remaining: remainingCount,
|
||||||
|
|
||||||
|
// Displays for numerator/denominator parts
|
||||||
|
numeratorDisplay,
|
||||||
|
denominatorDisplay,
|
||||||
|
infinitySymbol,
|
||||||
|
|
||||||
|
// Structured usage object
|
||||||
|
usageData: {
|
||||||
|
used: usedCount,
|
||||||
|
remaining: remainingCount,
|
||||||
|
max: maxUsesNumber,
|
||||||
|
limit: maxUsesNumber,
|
||||||
|
total: maxUsesNumber,
|
||||||
|
isUnlimited: unlimited,
|
||||||
|
display: usageString,
|
||||||
|
numerator: numeratorDisplay,
|
||||||
|
denominator: denominatorDisplay
|
||||||
|
},
|
||||||
|
|
||||||
|
// Keep original creation if present (for FE that lists columns generically)
|
||||||
|
created_by_user_id: row.created_by_user_id || row.createdByUserId,
|
||||||
|
createdByUserId: row.created_by_user_id || row.createdByUserId,
|
||||||
|
|
||||||
|
// Count of individual usage rows
|
||||||
|
usageCount: row.usage_count,
|
||||||
|
|
||||||
|
// NEW: referrer identity fields (added near end to avoid interfering with existing logic)
|
||||||
|
referrerEmail: referrerEmailDerived,
|
||||||
|
referrerName: referrerNameDerived,
|
||||||
|
// legacy / alias fields some FE might probe
|
||||||
|
ownerEmail: referrerEmailDerived,
|
||||||
|
createdByEmail: referrerEmailDerived,
|
||||||
|
userEmail: referrerEmailDerived
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tokenObj.referrerEmail) {
|
||||||
|
logger.debug('ReferralService:presentToken:referrer_email_missing', { tokenId: row.id, hasRawField: !!row.referrer_email });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Registration via referral ---
|
||||||
|
static evaluateTokenRecord(rec) {
|
||||||
|
if (!rec) return { valid: false, reason: 'not_found' };
|
||||||
|
|
||||||
|
// Defensive normalization
|
||||||
|
const rawMax = (rec.max_uses === null || rec.max_uses === undefined) ? -1 : rec.max_uses;
|
||||||
|
const rawRemaining = (rec.uses_remaining === null || rec.uses_remaining === undefined) ? -1 : rec.uses_remaining;
|
||||||
|
|
||||||
|
const isUnlimited = (rawMax === -1) || (rawRemaining === -1);
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
logger.debug('ReferralService:evaluateTokenRecord:raw', {
|
||||||
|
status: rec.status,
|
||||||
|
expires_at: rec.expires_at,
|
||||||
|
max_uses: rawMax,
|
||||||
|
uses_remaining: rawRemaining,
|
||||||
|
isUnlimited
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rec.status !== 'active') return { valid: false, reason: 'inactive' };
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const expMs = rec.expires_at ? new Date(rec.expires_at).getTime() : NaN;
|
||||||
|
|
||||||
|
if (!isUnlimited) {
|
||||||
|
if (isNaN(expMs) || expMs <= now) return { valid: false, reason: 'expired' };
|
||||||
|
if (rawRemaining === 0) return { valid: false, reason: 'exhausted' };
|
||||||
|
if (rawRemaining < 0) {
|
||||||
|
// Inconsistent negative (not -1 sentinel) -> treat as internal issue; allow but log
|
||||||
|
logger.warn('ReferralService:evaluateTokenRecord:negative_remaining_non_unlimited', {
|
||||||
|
uses_remaining: rawRemaining, max_uses: rawMax
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
reason: null,
|
||||||
|
isUnlimited,
|
||||||
|
usesRemaining: isUnlimited ? null : rawRemaining
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getReferrerInfo(token, unitOfWork) {
|
||||||
|
logger.info('ReferralService:getReferrerInfo:start', { token });
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
const raw = await repo.getReferrerInfoByToken(token);
|
||||||
|
if (!raw) {
|
||||||
|
logger.warn('ReferralService:getReferrerInfo:not_found', { token });
|
||||||
|
return { valid: false, reason: 'not_found' };
|
||||||
|
}
|
||||||
|
const evalResult = this.evaluateTokenRecord(raw);
|
||||||
|
if (!evalResult.valid) {
|
||||||
|
logger.warn('ReferralService:getReferrerInfo:invalid', {
|
||||||
|
token, reason: evalResult.reason, max_uses: raw.max_uses, uses_remaining: raw.uses_remaining
|
||||||
|
});
|
||||||
|
return { valid: false, reason: evalResult.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.presentToken(raw);
|
||||||
|
|
||||||
|
const refEmail = normalized.referrerEmail || raw.referrer_email || raw.email || null;
|
||||||
|
const refName = normalized.referrerName || refEmail;
|
||||||
|
|
||||||
|
if (!refEmail) {
|
||||||
|
logger.warn('ReferralService:getReferrerInfo:referrer_email_unresolved', { token, tokenId: raw.id });
|
||||||
|
} else {
|
||||||
|
logger.debug('ReferralService:getReferrerInfo:referrer_email_resolved', { token, refEmail });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
referrerId: raw.referrer_id || normalized.referrerId || null,
|
||||||
|
referrerName: refName,
|
||||||
|
referrerEmail: refEmail,
|
||||||
|
isUnlimited: normalized.isUnlimited,
|
||||||
|
usesRemaining: normalized.isUnlimited ? -1 : normalized.usesRemaining,
|
||||||
|
maxUses: normalized.maxUses,
|
||||||
|
usage: normalized.usage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async registerPersonalWithReferral(registrationData, refToken, unitOfWork) {
|
||||||
|
logger.info('ReferralService:registerPersonalWithReferral:start', { refToken });
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
const raw = await repo.getReferrerInfoByToken(refToken);
|
||||||
|
const evalResult = this.evaluateTokenRecord(raw);
|
||||||
|
if (!evalResult.valid) {
|
||||||
|
logger.warn('ReferralService:registerPersonalWithReferral:token_invalid', {
|
||||||
|
refToken, reason: evalResult.reason, max_uses: raw && raw.max_uses, uses_remaining: raw && raw.uses_remaining
|
||||||
|
});
|
||||||
|
throw new Error(evalResult.reason || 'invalid_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const personalRepo = new PersonalUserRepository(unitOfWork);
|
||||||
|
const user = await personalRepo.create(registrationData);
|
||||||
|
|
||||||
|
await UserStatusService.initializeUserStatus(user.id, 'personal', unitOfWork, 'inactive');
|
||||||
|
|
||||||
|
if (UserSettingsRepository) {
|
||||||
|
const settingsRepo = new UserSettingsRepository(unitOfWork);
|
||||||
|
await settingsRepo.createDefaultSettings(user.id, unitOfWork);
|
||||||
|
} else if (unitOfWork.connection) {
|
||||||
|
await unitOfWork.connection.query(`
|
||||||
|
INSERT INTO user_settings (user_id) VALUES (?)
|
||||||
|
ON DUPLICATE KEY UPDATE user_id = user_id
|
||||||
|
`, [user.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.markReferralTokenUsed(raw.token_id, user.id, unitOfWork);
|
||||||
|
|
||||||
|
await MailService.sendRegistrationEmail({
|
||||||
|
email: registrationData.email,
|
||||||
|
firstName: registrationData.firstName,
|
||||||
|
lastName: registrationData.lastName,
|
||||||
|
userType: 'personal'
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('ReferralService:registerPersonalWithReferral:success', { userId: user.id, email: user.email });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async registerCompanyWithReferral(registrationData, refToken, unitOfWork) {
|
||||||
|
logger.info('ReferralService:registerCompanyWithReferral:start', { refToken });
|
||||||
|
const repo = new ReferralTokenRepository(unitOfWork);
|
||||||
|
const raw = await repo.getReferrerInfoByToken(refToken);
|
||||||
|
const evalResult = this.evaluateTokenRecord(raw);
|
||||||
|
if (!evalResult.valid) {
|
||||||
|
logger.warn('ReferralService:registerCompanyWithReferral:token_invalid', {
|
||||||
|
refToken, reason: evalResult.reason, max_uses: raw && raw.max_uses, uses_remaining: raw && raw.uses_remaining
|
||||||
|
});
|
||||||
|
throw new Error(evalResult.reason || 'invalid_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyRepo = new CompanyUserRepository(unitOfWork);
|
||||||
|
const { companyEmail, password, companyName, companyPhone, contactPersonName, contactPersonPhone } = registrationData;
|
||||||
|
const user = await companyRepo.create({
|
||||||
|
companyEmail,
|
||||||
|
password,
|
||||||
|
companyName,
|
||||||
|
companyPhone,
|
||||||
|
contactPersonName,
|
||||||
|
contactPersonPhone
|
||||||
|
});
|
||||||
|
|
||||||
|
await UserStatusService.initializeUserStatus(user.id, 'company', unitOfWork, 'inactive');
|
||||||
|
|
||||||
|
if (UserSettingsRepository) {
|
||||||
|
const settingsRepo = new UserSettingsRepository(unitOfWork);
|
||||||
|
await settingsRepo.createDefaultSettings(user.id, unitOfWork);
|
||||||
|
} else if (unitOfWork.connection) {
|
||||||
|
await unitOfWork.connection.query(`
|
||||||
|
INSERT INTO user_settings (user_id) VALUES (?)
|
||||||
|
ON DUPLICATE KEY UPDATE user_id = user_id
|
||||||
|
`, [user.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.markReferralTokenUsed(raw.token_id, user.id, unitOfWork);
|
||||||
|
|
||||||
|
await MailService.sendRegistrationEmail({
|
||||||
|
email: registrationData.companyEmail,
|
||||||
|
companyName: registrationData.companyName,
|
||||||
|
userType: 'company'
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('ReferralService:registerCompanyWithReferral:success', { userId: user.id, email: user.email });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Referral usage processing ---
|
||||||
|
static async processReferral(userId, referralEmail, unitOfWork) {
|
||||||
|
logger.info('ReferralService:processReferral:start', { userId, referralEmail });
|
||||||
|
try {
|
||||||
|
const referrer = await this.findReferrerByEmail(referralEmail, unitOfWork);
|
||||||
|
if (!referrer) {
|
||||||
|
logger.warn('ReferralService:processReferral:referrer_not_found', { referralEmail });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referralToken = await this.findActiveReferralToken(referrer.id, unitOfWork);
|
||||||
|
if (!referralToken) {
|
||||||
|
logger.warn('ReferralService:processReferral:no_active_token', { referrerId: referrer.id });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.createReferralUsage(referralToken.id, userId, unitOfWork);
|
||||||
|
await this.updateTokenUsage(referralToken.id, unitOfWork);
|
||||||
|
|
||||||
|
logger.info('ReferralService:processReferral:success', { userId, referralTokenId: referralToken.id });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('ReferralService:processReferral:error', { userId, referralEmail, error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findReferrerByEmail(email, unitOfWork) {
|
||||||
|
logger.debug('ReferralService:findReferrerByEmail', { email });
|
||||||
|
const conn = unitOfWork.connection;
|
||||||
|
const query = `SELECT id FROM users WHERE email = ?`;
|
||||||
|
const [rows] = await conn.query(query, [email]);
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findActiveReferralToken(userId, unitOfWork) {
|
||||||
|
logger.debug('ReferralService:findActiveReferralToken', { userId });
|
||||||
|
const conn = unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM referral_tokens
|
||||||
|
WHERE created_by_user_id = ? AND status = 'active' AND expires_at > NOW()
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const [rows] = await conn.query(query, [userId]);
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createReferralUsage(tokenId, userId, unitOfWork) {
|
||||||
|
logger.debug('ReferralService:createReferralUsage', { tokenId, userId });
|
||||||
|
const conn = unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
INSERT INTO referral_token_usage (referral_token_id, used_by_user_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`;
|
||||||
|
await conn.query(query, [tokenId, userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateTokenUsage(tokenId, unitOfWork) {
|
||||||
|
logger.debug('ReferralService:updateTokenUsage', { tokenId });
|
||||||
|
const conn = unitOfWork.connection;
|
||||||
|
const query = `
|
||||||
|
UPDATE referral_tokens
|
||||||
|
SET uses_remaining = uses_remaining - 1
|
||||||
|
WHERE id = ? AND uses_remaining > 0
|
||||||
|
`;
|
||||||
|
await conn.query(query, [tokenId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ReferralService;
|
||||||
88
services/UserStatusService.js
Normal file
88
services/UserStatusService.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
const UserStatusRepository = require('../repositories/UserStatusRepository');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
class UserStatusService {
|
||||||
|
static async initializeUserStatus(userId, userType, unitOfWork, status = 'inactive') {
|
||||||
|
logger.info('UserStatusService.initializeUserStatus:start', { userId, userType, status });
|
||||||
|
try {
|
||||||
|
const repo = new UserStatusRepository(unitOfWork);
|
||||||
|
await repo.initializeUserStatus(userId, status);
|
||||||
|
logger.info('UserStatusService.initializeUserStatus:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusService.initializeUserStatus:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateEmailVerified(userId, unitOfWork) {
|
||||||
|
logger.info('UserStatusService.updateEmailVerified:start', { userId });
|
||||||
|
try {
|
||||||
|
const repo = new UserStatusRepository(unitOfWork);
|
||||||
|
await repo.updateEmailVerified(userId);
|
||||||
|
logger.info('UserStatusService.updateEmailVerified:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusService.updateEmailVerified:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markRegistrationComplete(userId, unitOfWork) {
|
||||||
|
logger.info('UserStatusService.markRegistrationComplete:start', { userId });
|
||||||
|
try {
|
||||||
|
const repo = new UserStatusRepository(unitOfWork);
|
||||||
|
await repo.markRegistrationComplete(userId);
|
||||||
|
logger.info('UserStatusService.markRegistrationComplete:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusService.markRegistrationComplete:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async checkAndSetPendingIfComplete(userId, unitOfWork) {
|
||||||
|
logger.info('UserStatusService.checkAndSetPendingIfComplete:start', { userId });
|
||||||
|
try {
|
||||||
|
const repo = new UserStatusRepository(unitOfWork);
|
||||||
|
// Only set to 'pending' if all quickactions are complete and status is 'inactive'
|
||||||
|
await repo.setPendingIfComplete(userId);
|
||||||
|
logger.info('UserStatusService.checkAndSetPendingIfComplete:success', { userId });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusService.checkAndSetPendingIfComplete:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getStatusProgress(userId, unitOfWork) {
|
||||||
|
logger.info('UserStatusService.getStatusProgress:start', { userId });
|
||||||
|
try {
|
||||||
|
const repo = new UserStatusRepository(unitOfWork);
|
||||||
|
const status = await repo.getStatusByUserId(userId);
|
||||||
|
if (!status) return null;
|
||||||
|
|
||||||
|
// Calculate progress steps
|
||||||
|
const steps = [
|
||||||
|
{ key: 'email_verified', label: 'Email Verified' },
|
||||||
|
{ key: 'profile_completed', label: 'Profile Completed' },
|
||||||
|
{ key: 'documents_uploaded', label: 'Documents Uploaded' },
|
||||||
|
{ key: 'contract_signed', label: 'Contract Signed' }
|
||||||
|
];
|
||||||
|
const completedSteps = steps.filter(s => !!status[s.key]);
|
||||||
|
const progressPercent = Math.round((completedSteps.length / steps.length) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: status.status,
|
||||||
|
steps: steps.map(s => ({
|
||||||
|
key: s.key,
|
||||||
|
label: s.label,
|
||||||
|
completed: !!status[s.key]
|
||||||
|
})),
|
||||||
|
completedSteps: completedSteps.map(s => s.label),
|
||||||
|
progressPercent
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('UserStatusService.getStatusProgress:error', { userId, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserStatusService;
|
||||||
229
templates/company/compDSGVO.html
Normal file
229
templates/company/compDSGVO.html
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>SUB-AUFTRAGSVERARBEITUNGS-VERTRAG</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin:15mm 12mm 18mm 12mm; } /* was 20mm 17mm 22mm 17mm */
|
||||||
|
body { counter-reset:page; font-size:13px; }
|
||||||
|
h1 { font-size:18px; margin:0 0 6px; }
|
||||||
|
h2 { font-size:13px; }
|
||||||
|
.page { page-break-after:always; }
|
||||||
|
.page:last-child { page-break-after:auto; }
|
||||||
|
.page-header {
|
||||||
|
display:flex;
|
||||||
|
justify-content:flex-end;
|
||||||
|
font-size:0.65em; /* slightly smaller */
|
||||||
|
counter-increment:page;
|
||||||
|
}
|
||||||
|
.page-header:after { content:"Seite " counter(page); }
|
||||||
|
.heading-block { text-align:center; margin:0 0 10px; }
|
||||||
|
.page-header-block{display:flex;flex-direction:column;align-items:flex-end;gap:3px;margin-bottom:4px;}
|
||||||
|
.print-date{font-size:0.7em;}
|
||||||
|
/* ADDED signature sizing fix */
|
||||||
|
.sig-block { min-height:120px !important; }
|
||||||
|
.pp-stamp,
|
||||||
|
.pp-stamp img {
|
||||||
|
max-width:none !important;
|
||||||
|
max-height:none !important;
|
||||||
|
width:auto !important;
|
||||||
|
height:auto !important;
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
.sig-date { margin-top:4px; display:block; }
|
||||||
|
@media print {
|
||||||
|
.page { padding:12px 20px 18px !important; } /* was 25px 35px 35px */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;line-height:1.35;-webkit-print-color-adjust:exact;print-color-adjust:exact;">
|
||||||
|
<div class="document" style="margin:0;">
|
||||||
|
<!-- PAGE 1 -->
|
||||||
|
<div class="page" style="page-break-after:always;padding:12px 20px 18px;">
|
||||||
|
<!-- CHANGED header/date grouping -->
|
||||||
|
<div class="page-header-block">
|
||||||
|
<div class="page-header" style="display:flex;justify-content:flex-end;font-size:0.75em;"></div>
|
||||||
|
<div class="print-date" style="font-size:0.7em;text-align:right;margin-top:4px;margin-bottom:8px;">Erstellt am: {{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="heading-block">
|
||||||
|
<h1 style="margin:0 0 8px;text-align:center;">SUB-AUFTRAGSVERARBEITUNGS-VERTRAG</h1>
|
||||||
|
<p style="margin:0 0 6px;text-align:center;">i.S.d. Art. 28 Abs. 3 Datenschutz-Grundverordnung (DS-GVO)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin:0 0 6px;">abgeschlossen zwischen</p>
|
||||||
|
<p style="margin:0 0 6px;">Profit Planet GmbH (kurz Auftraggeber)<br>
|
||||||
|
FN 649474i<br>
|
||||||
|
Liebenauer Hauptstraße 82c<br>
|
||||||
|
A-8041 Graz</p>
|
||||||
|
<p style="margin:0 0 6px;">und</p>
|
||||||
|
<p style="margin:0 0 6px;">Vertriebspartner (kurz Auftragnehmer)</p>
|
||||||
|
|
||||||
|
<div class="meta-info" style="border:1px solid #000;padding:8px 12px;margin:12px 0 25px;font-size:0.9em;">
|
||||||
|
<table class="meta-grid" style="width:100%;border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;width:28%;white-space:nowrap;">Vertriebspartner</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{companyCompanyName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;width:28%;white-space:nowrap;">Registration No.:</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{companyRegistrationNumber}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;white-space:nowrap;">Adresse</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{companyAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;white-space:nowrap;">PLZ / Ort</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{companyZipCode}} {{companyCity}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;white-space:nowrap;">Vollständige Adresse</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{companyFullAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="meta-info">
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">1. PRÄAMBEL</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">1.1. Diese Anlage konkretisiert die Verpflichtungen der Vertragsparteien zum Datenschutz, die sich aus der im bestehenden Vertriebspartner-Vertrag („Hauptvertrag“) und seinen Anlagen in ihren Einzelheiten beschriebenen Auftragsverarbeitung ergeben. Sie findet Anwendung auf alle Tätigkeiten, die mit dem Vertrag in Zusammenhang stehen, und bei denen Beschäftigte des Auftragnehmers oder durch den Auftragnehmer Beauftragte personenbezogene Daten („Daten“) des Auftraggebers verarbeiten.</p>
|
||||||
|
<p style="margin:0 0 6px;">1.2. Der Auftragnehmer ist sich bewusst, dass der Auftraggeber als Auftragsverarbeiter für Dritte („Verantwortliche“ im Sinne des Art. 4 Nr. 7 DS-GVO) tätig ist. Im Rahmen des vorbezeichneten Hauptvertrags nimmt der Auftraggeber die Dienste des Auftragnehmers als „weiteren Auftragsverarbeiter“ im Sinne von Art. 28 Nr. 4 DS-GVO in Anspruch, um bestimmte Verarbeitungstätigkeiten im Namen des Dritten („Verantwortlicher“ iSd Art. 4 Nr. 7 DS-GVO) auszuführen.</p>
|
||||||
|
<p style="margin:0 0 6px;">1.3. Der Auftragnehmer ist sich bewusst, dass der Auftraggeber gegenüber Dritten für die Einhaltung der Pflichten des Auftragnehmers haftet, falls der Auftragnehmer seinen Datenschutzpflichten nach diesem Vertrag und nach dem Gesetz nicht nachkommt.</p>
|
||||||
|
<p style="margin:0 0 6px;">1.4. Die Laufzeit dieser Anlage richtet sich nach der Laufzeit des Vertriebspartner-Vertrages, sofern sich aus den Bestimmungen dieser Anlage nicht darüber hinausgehende Verpflichtungen ergeben.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">2. DAUER, GEGENSTAND UND SPEZIFIZIERUNG DER AUFTRAGSVERARBEITUNG</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">2.1. Alle Daten dürfen nur so lange verarbeitet werden, als das durch die Vertragserfüllung oder den Zweck der Datenverarbeitung erforderlich ist.</p>
|
||||||
|
<p style="margin:0 0 6px;">2.2. Aus dem Vertrag ergeben sich Gegenstand und Dauer des Auftrags sowie Art und Zweck der Verarbeitung.</p>
|
||||||
|
<p style="margin:0 0 6px;">2.3. Im Einzelnen sind insbesondere die folgenden Daten Bestandteil der Datenverarbeitung:</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAGE 2 -->
|
||||||
|
<div class="page" style="page-break-after:always;padding:12px 20px 18px;">
|
||||||
|
<div class="page-header" style="display:flex;justify-content:flex-end;font-size:0.75em;"></div>
|
||||||
|
|
||||||
|
<!-- UPDATED: force table to next page if not already -->
|
||||||
|
<table class="data-table" style="page-break-before:always;width:100%;border-collapse:collapse;border:1px solid #000;table-layout:fixed;font-size:0.85em;margin:0 0 12px;">
|
||||||
|
<tr>
|
||||||
|
<th style="border:1px solid #000;padding:6px 8px;vertical-align:top;background:#f5f5f5;width:180px;">Art der Daten</th>
|
||||||
|
<td style="border:1px solid #000;padding:6px 8px;vertical-align:top;">Interessenten- und Kundendaten; Kontaktdaten beim Auftraggeber; Kontaktdaten des jeweiligen Datenverantwortlichen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="border:1px solid #000;padding:6px 8px;vertical-align:top;background:#f5f5f5;">Art und Zweck der Datenverarbeitung</th>
|
||||||
|
<td style="border:1px solid #000;padding:6px 8px;vertical-align:top;">Datenerfassung beim Interessenten (potenziellen Kunden); Datenübermittlung (auch elektronisch via E-Mail bzw. falls vorhanden über elektronische Schnittstellen der Verantwortlichen) an Auftraggeber bzw. Datenverantwortliche zur Legung eines Angebots bzw. zur Verwirklichung der Kundenbestellung; ggf. telefonischer Nachkontakt zur Qualitätskontrolle</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="border:1px solid #000;padding:6px 8px;vertical-align:top;background:#f5f5f5;">Kategorien betroffener Daten</th>
|
||||||
|
<td style="border:1px solid #000;padding:6px 8px;vertical-align:top;">Name, Vorname, Adresse, Geburtsdatum, SV-Nr., E-Mail, Kontodaten Ausweiskopie; Daten zur Energieversorgung (z.B. Zählpunkt, Zählernummer, Kilowattprognose, Jahresverbrauch); Aufzeichnung etwaiger Qualitätskontrollen; Aufzeichnung etwaiger Interessensgebiete im Bereich Versicherung, Kreditwirtschaft, Telekommunikation, Energieeffizienz (PV, Speicher, LED, Infrarotheizung, Kalkschutz…).</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">3. ANWENDUNGSBEREICH UND VERANTWORTLICHKEIT</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">3.1. Der Auftragnehmer verarbeitet personenbezogene Daten im Auftrag des Auftraggebers. Dies umfasst Tätigkeiten, die im Vertrag und in der Leistungsbeschreibung konkretisiert sind.</p>
|
||||||
|
<p style="margin:0 0 6px;">3.2. Der Auftraggeber ist gegenüber dem/den Dritten als („Verantwortliche Person“ iSd Art. 4 Nr. 7 DS-GVO) für die Einhaltung der gesetzlichen Bestimmungen der Datenschutzgesetze, insbesondere für die Rechtmäßigkeit der Datenweitergabe an den Auftragnehmer sowie für die Rechtmäßigkeit der Datenverarbeitung verantwortlich.</p>
|
||||||
|
<p style="margin:0 0 6px;">3.3. Der Auftragnehmer ist gegenüber dem Auftraggeber im Rahmen dieses Vertrages für die Einhaltung der gesetzlichen Bestimmungen der Datenschutzgesetze, insbesondere für die Rechtmäßigkeit der Datenweitergabe sowie der Datenverarbeitung verantwortlich.</p>
|
||||||
|
<p style="margin:0 0 6px;">3.4. Die Weisungen werden anfänglich durch diese Vertragsanlage festgelegt und können vom Auftraggeber danach in schriftlicher Form oder in einem elektronischen Format (Textform) an die vom Auftragnehmer bezeichnete Stelle durch einzelne Weisungen geändert, ergänzt oder ersetzt werden (Einzelweisung). Weisungen, die in der Vertragsanlage nicht vorgesehen sind, werden als Antrag auf Leistungsänderung behandelt. Mündliche Weisungen sind unverzüglich schriftlich oder in Textform zu bestätigen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">4. PFLICHTEN DES AUFTRAGNEHMERS</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">4.1. Der Auftragnehmer darf Daten von betroffenen Personen nur im Rahmen des Auftrages und der Weisungen des Auftraggebers verarbeiten, außer es liegt ein Ausnahmefall iSd Art 28 Abs. 3 a) DS-GVO vor. Der Auftragnehmer informiert den Auftraggeber unverzüglich, wenn er der Auffassung ist, dass eine Weisung gegen anwendbare Gesetze verstößt. Der Auftragnehmer darf die Umsetzung der Weisung solange aussetzen, bis sie vom Auftraggeber bestätigt oder abgeändert wurde.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.2. Der Auftragnehmer wird in seinem Verantwortungsbereich die innerbetriebliche Organisation so gestalten, dass sie den besonderen Anforderungen des Datenschutzes gerecht wird. Er wird technische und organisatorische Maßnahmen zum angemessenen Schutz der Daten des Auftraggebers treffen, die den Anforderungen der Datenschutz- Grundverordnung (Art. 32 DS-GVO) genügen. Der Auftragnehmer hat technische und organisatorische Maßnahmen zu treffen, die die Vertraulichkeit, Integrität, Verfügbarkeit und Belastbarkeit der Systeme und Dienste im Zusammenhang mit der Verarbeitung auf Dauer sicherstellen. Der Auftraggeber ist berechtigt, diese technischen und organisatorischen Maßnahmen dahingehend zu überprüfen, ob sie für die Risiken der zu verarbeitenden Daten ein angemessenes Schutzniveau bieten. Eine Änderung der getroffenen Sicherheitsmaßnahmen bleibt dem Auftragnehmer vorbehalten, wobei jedoch sichergestellt sein muss, dass das vertraglich vereinbarte Schutzniveau nicht unterschritten wird.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.3. Der Auftragnehmer gewährleistet, seinen Pflichten nach Art. 32 Abs. 1 lit. d) DS-GVO nachzukommen, ein Verfahren zur regelmäßigen Überprüfung der Wirksamkeit der technischen und organisatorischen Maßnahmen zur Gewährleistung der Sicherheit der Verarbeitung einzusetzen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.4. Der Auftragnehmer unterstützt den Auftraggeber im Rahmen seiner Möglichkeiten bei der Erfüllung der Anfragen und Ansprüche betroffener Personen gem. Kapitel III der DS-GVO sowie bei der Einhaltung der in Art. 33 bis 36 DS-GVO genannten Pflichten.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.5. Der Auftragnehmer gewährleistet, dass es den mit der Verarbeitung der Daten des Auftraggebers befassten Mitarbeiter und andere für den Auftragnehmer tätigen Personen untersagt ist, die Daten außerhalb der Weisung zu verarbeiten. Ferner gewährleistet der Auftragnehmer, dass sich die zur Verarbeitung der personenbezogenen Daten befugten Personen zur Vertraulichkeit verpflichtet haben oder einer angemessenen gesetzlichen Verschwiegenheitspflicht unterliegen. Die Vertraulichkeits-/ Verschwiegenheitspflicht besteht auch nach Beendigung des Auftrages fort.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<!-- FIX: reopen subsection and place paragraphs inside -->
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">4.6. Der Auftragnehmer unterrichtet den Auftraggeber unverzüglich, wenn ihm Verletzungen des Schutzes personenbezogener Daten des Auftraggebers bekannt werden. Der Auftragnehmer trifft die erforderlichen Maßnahmen zur Sicherung der Daten und zur Minderung möglicher nachteiliger Folgen der betroffenen Personen und spricht sich hierzu unverzüglich mit dem Auftraggeber ab.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.7. Der Auftragnehmer nennt dem Auftraggeber den Ansprechpartner für im Rahmen des Vertrages anfallende Datenschutzfragen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.8. Der Auftragnehmer berichtigt oder löscht die vertragsgegenständlichen Daten, wenn der Auftraggeber dies anweist und dies vom Weisungsrahmen umfasst ist. Ist eine datenschutzkonforme Löschung oder eine entsprechende Einschränkung der Datenverarbeitung nicht möglich, übernimmt der Auftragnehmer die datenschutzkonforme Vernichtung von Datenträgern und sonstigen Materialien auf Grund einer Einzelbeauftragung durch den Auftraggeber oder gibt diese Datenträger an den Auftraggeber zurück, sofern nicht im Vertrag bereits vereinbart.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.9. Daten, Datenträger sowie sämtliche sonstige Materialien sind nach Auftragsende auf Verlangen des Auftraggebers entweder herauszugeben oder zu löschen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.10. Im Falle einer Inanspruchnahme des Auftraggebers oder des Dritten durch eine betroffene Person hinsichtlich etwaiger Ansprüche nach Art. 82 DS-GVO, verpflichtet sich der Auftragnehmer den Auftraggeber bei der Abwehr des Anspruches im Rahmen seiner Möglichkeiten zu unterstützen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.11. Im Falle einer Inanspruchnahme des Auftraggebers durch den Dritten, verpflichtet sich der Auftragnehmer den Auftraggeber bei der Abwehr des Anspruches im Rahmen seiner Möglichkeiten zu unterstützen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">5. PFLICHTEN DES AUFTRAGGEBERS</h2>
|
||||||
|
<div class="subsection" style="margin-left:20px;">
|
||||||
|
<p style="margin:0 0 6px;">5.1. Der Auftraggeber hat den Auftragnehmer unverzüglich und vollständig zu informieren, wenn er in den Auftragsergebnissen Fehler oder Unregelmäßigkeiten bzgl. datenschutzrechtlicher Bestimmungen feststellt.</p>
|
||||||
|
<p style="margin:0 0 6px;">5.2. Im Falle einer Inanspruchnahme des Auftraggebers oder des Dritten durch eine betroffene Person hinsichtlich etwaiger Ansprüche nach Art. 82 DS-GVO, gilt §3 Abs. 10 entsprechend.</p>
|
||||||
|
<p style="margin:0 0 6px;">5.3. Der Auftraggeber nennt dem Auftragnehmer den Ansprechpartner für im Rahmen des Vertrages anfallende Datenschutzfragen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">6. ANFRAGEN BETROFFENER PERSONEN</h2>
|
||||||
|
<div class="subsection" style="margin-left:20px;">
|
||||||
|
<p style="margin:0 0 6px;">6.1. Wendet sich eine betroffene Person mit Forderungen zur Berichtigung, Löschung oder Auskunft an den Auftragnehmer, wird der Auftragnehmer die betroffene Person an den Auftraggeber verweisen und ggf. den Antrag der betroffenen Person unverzüglich an den Auftraggeber weiterleiten. Der Auftragnehmer unterstützt den Auftraggeber im Rahmen seiner Möglichkeiten bei der Erfüllung der jeweiligen Forderung.</p>
|
||||||
|
<p style="margin:0 0 6px;">6.2. Der Auftragnehmer haftet nicht, wenn das Ersuchen der betroffenen Person vom Auftraggeber nicht, nicht richtig oder nicht fristgerecht beantwortet wird.</p>
|
||||||
|
<p style="margin:0 0 6px;">6.3. Der Auftraggeber haftet nicht für Forderungen betroffener Personen, die dadurch entstehen, dass der Auftragnehmer das entsprechende Anliegen nicht zeitgerecht an den Auftraggeber übermittelt hat.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">7. NACHWEISMÖGLICHKEITEN</h2>
|
||||||
|
<div class="subsection" style="margin-left:20px;">
|
||||||
|
<p style="margin:0 0 6px;">7.1. Der Auftragnehmer weist dem Auftraggeber die Einhaltung der in diesem Vertrag niedergelegten Pflichten mit geeigneten Mitteln nach.</p>
|
||||||
|
<p style="margin:0 0 6px;">7.2. Sollten im Einzelfall Inspektionen durch den Auftraggeber oder einen von diesem beauftragten Prüfer erforderlich sein, werden diese zu den üblichen Geschäftszeiten ohne Störung des Betriebsablaufs nach Anmeldung unter Berücksichtigung einer angemessenen Vorlaufzeit durchgeführt. Der Auftragnehmer darf diese von der Unterzeichnung einer Verschwiegenheitserklärung hinsichtlich der Daten anderer Kunden und der eingerichteten technischen und organisatorischen Maßnahmen abhängig machen. Sollte der durch den Auftraggeber beauftragte Prüfer in einem Wettbewerbsverhältnis zu dem Auftragnehmer stehen, hat der Auftragnehmer gegen diesen ein Einspruchsrecht</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MOVED UP: Sections 8 & 9 and signatures (formerly PAGE 4) -->
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">8. SUBUNTERNEHMER (WEITERE AUFTRAGSVERARBEITER)</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">8.1. Der Einsatz von Subunternehmern als weitere Auftragsverarbeiter ist nur zulässig, wenn der Auftraggeber vorher zugestimmt hat.</p>
|
||||||
|
<p style="margin:0 0 6px;">8.2. Ein zustimmungspflichtiges Subunternehmerverhältnis liegt vor, wenn der Auftragnehmer weitere Auftragnehmer mit der ganzen oder einer Teilleistung der im Vertrag vereinbarten Leistung beauftragt. Der Auftragnehmer wird mit diesen Dritten im erforderlichen Umfang Vereinbarungen treffen, um angemessene Datenschutz- und Informationssicherheitsmaßnahmen zu gewährleisten.</p>
|
||||||
|
<p style="margin:0 0 6px;">8.3. Erteilt der Auftragnehmer Aufträge an Subunternehmer, so obliegt es dem Auftragnehmer, seine datenschutzrechtlichen Pflichten aus diesem Vertrag dem Subunternehmer zu überbinden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">9. INFORMATIONSPFLICHTEN, SCHRIFTFORMKLAUSEL, RECHTSWAHL</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">9.1. Sollten die Daten des Auftraggebers beim Auftragnehmer durch Pfändung oder Beschlagnahme, durch ein Insolvenz- oder Vergleichsverfahren oder durch sonstige Ereignisse oder Maßnahmen Dritter gefährdet werden, so hat der Auftragnehmer den Auftraggeber unverzüglich darüber zu informieren. Der Auftragnehmer wird alle in diesem Zusammenhang Verantwortlichen unverzüglich darüber informieren, dass die Hoheit und das Eigentum an den Daten ausschließlich beim Dritten als verantwortliche Person im Sinne der Datenschutz-Grundverordnung liegen.</p>
|
||||||
|
<p style="margin:0 0 6px;">9.2. Änderungen und Ergänzungen dieser Anlage und aller ihrer Bestandteile – einschließlich etwaiger Zusicherungen des Auftragnehmers – bedürfen einer schriftlichen Vereinbarung, die auch in einem elektronischen Format (Textform) erfolgen kann, und des ausdrücklichen Hinweises darauf, dass es sich um eine Änderung bzw. Ergänzung dieser Bedingungen handelt. Dies gilt auch für den Verzicht auf dieses Formerfordernis.</p>
|
||||||
|
<p style="margin:0 0 6px;">9.3. Bei etwaigen Widersprüchen gehen Regelungen dieser Anlage zum Datenschutz den Regelungen des Vertrages vor. Sollten einzelne Teile dieser Anlage unwirksam sein, so berührt dies die Wirksamkeit der Anlage im Übrigen nicht.</p>
|
||||||
|
<p style="margin:0 0 6px;">9.4. Es gilt das auf dem Hauptvertrag anwendbare Recht sowie Gerichtsstand.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures" style="display:flex;gap:30px;margin-top:38px;">
|
||||||
|
<div class="signature" style="flex:1;text-align:center;">
|
||||||
|
<p style="margin:0 0 6px;">Für PROFIT PLANET (Auftraggeber)</p>
|
||||||
|
<!-- CHANGED: wrap stamp + date in a flex column with spacing to avoid overlap -->
|
||||||
|
<div class="sig-block" style="display:flex;flex-direction:column;align-items:center;gap:6px;min-height:140px;">
|
||||||
|
<div class="pp-stamp" style="display:block;max-width:220px;margin:0 auto 6px;">{{profitplanetSignature}}</div>
|
||||||
|
<div class="sig-date" style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature" style="flex:1;text-align:center;">
|
||||||
|
<p style="margin:0 0 6px;">Für den VP (Auftragnehmer)</p>
|
||||||
|
<div class="sig-block" style="display:flex;flex-direction:column;align-items:center;gap:4px;min-height:110px;">
|
||||||
|
<span style="display:block;max-width:100%;max-height:80px;">{{signatureImage}}</span>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{fullName}}</div>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Name, Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
templates/company/compVERTRAG.html
Normal file
209
templates/company/compVERTRAG.html
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VERTRIEBSPARTNER / BUSINESSPARTNER / AFFILIATE - VERTRAG</title>
|
||||||
|
<style>
|
||||||
|
/* PDF friendly page setup */
|
||||||
|
@page { size:A4; margin:15mm 12mm 18mm 12mm; }
|
||||||
|
body { font-family:Arial,Helvetica,sans-serif; line-height:1.4; font-size:13px; counter-reset: page; margin:0; }
|
||||||
|
h1 { text-align:center; font-size:18px; margin:0 0 8px; }
|
||||||
|
h2 { margin:16px 0 6px; font-size:13px; }
|
||||||
|
.page { page-break-after:always; padding:12px 20px 18px; }
|
||||||
|
.page:last-child { page-break-after:auto; }
|
||||||
|
.page-header { display:flex; justify-content:flex-end; font-size:0.65em; counter-increment:page; }
|
||||||
|
.page-header:after { content:"Seite " counter(page); }
|
||||||
|
.meta-info { border:1px solid #000; padding:8px 12px; margin:12px 0 25px; font-size:0.9em; }
|
||||||
|
.meta-info table { width:100%; border-collapse:collapse; }
|
||||||
|
.meta-info td { padding:3px 6px; vertical-align:top; border-bottom:1px solid #ccc; }
|
||||||
|
.meta-info td:first-child { font-weight:bold; width:28%; white-space:nowrap; }
|
||||||
|
.meta-info tr:last-child td { border-bottom:0; }
|
||||||
|
/* Unified signatures */
|
||||||
|
.signatures { display:flex; gap:30px; margin-top:38px; }
|
||||||
|
.signature { flex:1; text-align:center; }
|
||||||
|
/* Added: extra space below company stamp so date sits clearly underneath */
|
||||||
|
.signature-company .sig-image { margin-bottom:22px !important; }
|
||||||
|
.signature-company .sig-date { margin-top:0 !important; }
|
||||||
|
.sig-block { display:flex; flex-direction:column; align-items:center; gap:6px; min-height:120px; }
|
||||||
|
.sig-image { display:block; max-width:180px !important; max-height:70px !important; height:auto !important; margin:0 auto 6px; }
|
||||||
|
.sig-date { margin-top:4px; display:block; }
|
||||||
|
.page-header-block{display:flex;flex-direction:column;align-items:flex-end;gap:3px;margin-bottom:6px;}
|
||||||
|
.print-date{font-size:0.7em;}
|
||||||
|
/* ADDED: full size Profit Planet signature */
|
||||||
|
.pp-stamp,
|
||||||
|
.pp-stamp img {
|
||||||
|
max-width:none !important;
|
||||||
|
max-height:none !important;
|
||||||
|
width:auto !important;
|
||||||
|
height:auto !important;
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust:exact; print-color-adjust:exact; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header-block">
|
||||||
|
<div class="page-header"></div>
|
||||||
|
<div class="print-date">Erstellt am: {{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<h1>VERTRIEBSPARTNER / BUSINESSPARTNER / AFFILIATE - VERTRAG</h1>
|
||||||
|
<p style="text-align:center;margin:0 0 6px;">idF 21.05.2025</p>
|
||||||
|
<p>abgeschlossen zwischen</p>
|
||||||
|
<p>Profit Planet GmbH (kurz PROFIT PLANET)<br>FN 649474i<br>Liebenauer Hauptstraße 82c<br>A-8041 Graz</p>
|
||||||
|
<p>und</p>
|
||||||
|
<p>Vertriebspartner / Businesspartner / Affiliate (kurz VP)</p>
|
||||||
|
<div class="meta-info">
|
||||||
|
<table class="meta-grid">
|
||||||
|
<tr>
|
||||||
|
<td>Vertriebspartner</td>
|
||||||
|
<td>{{companyCompanyName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Registration No.:</td>
|
||||||
|
<td>{{companyRegistrationNumber}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Adresse</td>
|
||||||
|
<td>{{companyAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>PLZ / Ort</td>
|
||||||
|
<td>{{companyZipCode}} {{companyCity}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Vollständige Adresse</td>
|
||||||
|
<td>{{companyFullAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<h2>1. Präambel und Vertragsgegenstand</h2>
|
||||||
|
<p>1.1. Dieser Vertrag regelt die Zusammenarbeit zwischen PROFIT PLANET und VP als Grundlage einer fairen, langfristigen und erfolgreichen Kooperation. Die VP unterstützen einander im Sinne der Ziele der Zusammenarbeit und unterrichten sich gegenseitig über alle Vorgänge, die für ihre Leistungen im Rahmen der Kooperation von Interesse sind.</p>
|
||||||
|
<p>1.2. PROFIT PLANET bietet über ein Vertriebspartner / Businesspartner / Affiliate-Netzwerk den Vertrieb verschiedener Dienstleistungen und Produkte, vornehmlich aus den Bereichen Nachhaltigkeit, Energie, Handel sowie Consulting und Coaching an.</p>
|
||||||
|
<p>1.3. Der VP vermittelt die jeweiligen Dienstleistungen, Produkte oder qualifizierten Leads, die zu einem Abschluss führen, und erhält dafür eine Provision. Für die Tätigkeit als VP ist es nicht erforderlich, weitere VP zu werben.</p>
|
||||||
|
<p>1.4. Der VP ist berechtigt, weitere Vertriebspartner / Businesspartner / Affiliate für den Vertrieb der Dienstleistungen und Produkte zu gewinnen. Für die Vermittlung und Betreuung der von ihm akquirierten Vertriebspartner / Businesspartner / Affiliate erhält der werbende VP eine Provision, die sich aus den erwirtschafteten Umsätzen der geworbenen VP ermittelt. Die Höhe der Provision ergibt sich aus der Provisionsübersicht.</p>
|
||||||
|
<p>1.5. Die Vertragsabschlüsse kommen nur zwischen dem Endkunden und dem jeweiligen Dienstleister und/oder Produktgeber (Energieversorgungs-, Handels-, Dienstleistungs- oder Coachingunternehmen) zustande, ohne dass dadurch eine Vertragsbeziehung zwischen dem VP und dem Endkunden entsteht. Ein Anspruch auf Abschluss des jeweiligen Vertrags seitens des Endkunden gegenüber PROFIT PLANET oder dem VP entsteht nicht; der Vertragsabschluss ist von der Annahme des entsprechenden Antrags durch den Dienstleister bzw. Produktgeber abhängig. PROFIT PLANET hat darauf keinen Einfluss.</p>
|
||||||
|
<p>1.6. PROFIT PLANET behält sich vor, die angebotenen Produkte zurückzuziehen, zu ändern, neue hinzuzufügen oder sonstige Anpassungen des Produktangebots vorzunehmen. PROFIT PLANET wird den VP über Änderungen von Produkten oder Tarifen nach Maßgabe der Möglichkeiten rechtzeitig vor Wirksamkeit der Änderungen informieren.</p>
|
||||||
|
<p>1.7. Die genauen Produktbestandteile und Konditionen ergeben sich aus dem jeweiligen Produktpartnerinformationsblatt, welches auf der Online-Plattform hinterlegt wird.</p>
|
||||||
|
<p>1.8. PROFIT PLANET ist berechtigt, nach eigenem Ermessen andere Personen und Unternehmen mit der Vermittlung von Produkten und Dienstleistungen von PROFIT PLANET bzw. Produktpartnern von PROFIT PLANET zu beauftragen. Es bestehen grundsätzlich keine Alleinvermittlungsaufträge und keine Exklusivität.</p>
|
||||||
|
|
||||||
|
<h2>2. Vertriebspartner / Businesspartner / Affiliate werden</h2>
|
||||||
|
<p>2.1. Kapitalgesellschaften, Personengesellschaften und volljährige natürliche Personen können Vertriebspartner / Businesspartner / Affiliate des PROFIT PLANET werden; pro Entität ist die Registrierung nur eines VP-Vertrags vorgesehen. Natürliche Personen, die bloß als Verbraucher handeln (wollen), können nicht Vertriebspartner / Businesspartner / Affiliate von PROFIT PLANET werden.</p>
|
||||||
|
<p>2.2. Kapitalgesellschaften müssen ihrem VP-Antrag die Firmenbuchnummer und gegebenenfalls die Umsatzsteuer-Identifikationsnummer (UID) beilegen. Der Antrag muss von allen Zeichnungsbereichten der Gesellschaft derart gezeichnet werden, dass eine rechtwirksame Vertretung sichergestellt ist. Die Gesellschafter haften gegenüber PROFIT PLANET jeweils persönlich für das Verhalten der Gesellschaft.</p>
|
||||||
|
<p>2.3. Absatz 2.2 gilt inhaltsgemäß auch für Personengesellschaften.</p>
|
||||||
|
<p>2.4. Der VP ist verpflichtet, Änderungen seiner unternehmens- oder personenbezogenen Daten unverzüglich an PROFIT PLANET zu melden.</p>
|
||||||
|
<p>2.5. Für die Verwendung des Online-Systems gelten die allgemeinen Geschäftsbedingungen.</p>
|
||||||
|
<p>2.6. PROFIT PLANET kann Vertriebspartner / Businesspartner / Affiliate ohne Angabe von Gründen ablehnen.</p>
|
||||||
|
|
||||||
|
<h2>3. Leistungen / Pflichten des VP</h2>
|
||||||
|
<p>3.1. Der VP handelt unabhängig als selbständiger Unternehmer, er ist weder Arbeitnehmer noch Handelsvertreter oder Makler von PROFIT PLANET. Er ist bei der Vermittlung von Produktverträgen eigenverantwortlich tätig, handelt abgesehen von den Pflichten aus diesem Vertrag frei von Weisungen und ist nicht mit der ständigen Vermittlung von Geschäften betraut. Es bestehen seitens PROFIT PLANET keine Umsatzvorgaben und keine Abnahme- oder Vertriebspflichten. Der VP trägt alle mit der Kundenakquisition verbundenen Kosten und Risiken selbst und verwendet eigene Betriebsmittel. Er stellt im geschäftlichen Verkehr klar, dass er nicht im Auftrag oder im Namen von PROFIT PLANET handelt, sondern als unabhängiger Vertriebspartner / Businesspartner / Affiliate.</p>
|
||||||
|
<p>3.2. Der VP betreibt sein Unternehmen mit der Sorgfalt eines ordentlichen Kaufmanns und ist für die Einhaltung aller gesetzlichen sowie der steuer- und sozialrechtlichen Vorgaben selbst verantwortlich.</p>
|
||||||
|
<p>3.3. Der VP hält sich insbesondere auch an das Wettbewerbsrecht und nimmt Abstand von ungenehmigter, irreführender oder sonst unlauterer Werbung. Der VP verpflichtet sich auch, falsche oder irreführende Aussagen über Dienstleistungen, Produkte und Vertriebssystem der PROFIT PLANET zu unterlassen.</p>
|
||||||
|
<p>3.4. Grundsätzlich steht es dem VP frei, Produkte / Dienstleistungen auch für andere Unternehmen zu vertreiben. Falls es allerdings in der Zusammenarbeit mit einem anderen Dienstleister oder Produktgeber in räumlicher oder zeitlicher Nähe zu Überschneidungen im Vertrieb, insbesondere bei Terminisierungen, Promotion-Auftritten (POS) oder anderen dienstleistungs- oder produktspezifischen Werbetätigkeiten kommen, so wäre dies nur nach ausdrücklicher Zustimmung durch PROFIT PLANET zulässig.</p>
|
||||||
|
<p>3.5. Beim Abschluss von Kundenverträgen ist der VP verpflichtet, die von PROFIT PLANET zur Verfügung gestellten Originalunterlagen (zB Antragsformulare, AGB, sonstige Unterlagen der Dienstleister oder Produktgeber) in der jeweils aktuellen Version zu verwenden und dem Kunden bei Vertragsabschluss vorzulegen bzw. auszuhändigen. Die Originalunterlagen sind durch den VP nicht zu verändern, missbräuchliche Verwendung ist zu verhindern.</p>
|
||||||
|
<p>3.6. Kundenverträge in Papierform sind vom VP unverzüglich, spätestens jedoch binnen 1 Woche nach Aufforderung durch PROFIT PLANET oder den Produktgeber an PROFIT PLANET auszuhändigen.</p>
|
||||||
|
<p>3.7. Sämtliche Präsentations-, Werbe- und Schulungsmaterialien sowie label von PROFIT PLANET sind urheberrechtlich geschützt und dürfen ohne ausdrückliches Einverständnis von PROFIT PLANET weder ganz noch teilweise vervielfältigt, verbreitet oder öffentlich zugänglich gemacht werden. Die Herstellung, Verwendung und Verbreitung eigener Werbemittel, Schulungsmaterialien oder Produktbroschüren ist nur nach schriftlicher Genehmigung und Freigabe durch PROFIT PLANET gestattet.</p>
|
||||||
|
<p>3.8. Der VP ist während der Dauer dieser Vereinbarung und für die Dauer von 36 Monaten nach Beendigung dieses Vertrags aus welchem Grund immer, nicht berechtigt, unmittelbar selbst bzw. mittelbar über Dritte Kunden von PROFIT PLANET und ihrer Produktpartner, einschließlich der vom VP vermittelten Endkunden, durch direkte Ansprache abzuwerben. Als Abwerben gilt jede Form des direkten Herantretens an den Kunden mit der Absicht, ihn zum Wechsel zu einem anderen Energieversorgungs-, Dienstleistungs-, Handels-, und/oder Coachingunternehmen zu bewegen (beispielsweise etwa durch Anrufe beim Kunden, Direktmailing mit Absicht der Abwerbung, Haustürgeschäfte etc.).</p>
|
||||||
|
<h2>4. Geheimhaltung</h2>
|
||||||
|
<p>4.1. Der VP verpflichtet sich, Geschäfts- und Betriebsgeheimnisse und sonstige vertrauliche Informationen von PROFIT PLANET und dessen Struktur, Geschäftspartner, Vertriebspartner / Businesspartner / Affiliate, Produktgeber, Provisionen und Endkunden unter äußerster Geheimhaltung zu behandeln und zu verwahren und diese Daten nur nach erfolgter schriftlicher Zustimmung durch den PROFIT PLANET an Dritte weiterzugeben.</p>
|
||||||
|
<p>4.2. Diese Verpflichtung gilt auch für Mitarbeiter und Unter-Vertriebspartner / Businesspartner / Affiliate des VP. Der VP hat für das Verhalten allfälliger Erfüllungsgehilfen und/oder Subpartner einzustehen.</p>
|
||||||
|
<p>4.3. Zu den Geschäftsgeheimnissen gehören insbesondere auch Informationen zu internen Betriebsabläufen, Provisionen und Provisionsstrukturen, Produkt- und Preiskalkulationen, Vertriebspartner / Businesspartner / Affiliate-strukturen und -aktivitäten.</p>
|
||||||
|
<p>4.4. Dem VP ist es nicht gestattet, auf Presseanfragen zu PROFIT PLANET, dessen Provisionspläne, Produkte oder andere Leistungen zu antworten. Presseanfragen sind immer an PROFIT PLANET weiterzuleiten.</p>
|
||||||
|
<h2>5. Datenschutz</h2>
|
||||||
|
<p>5.1. Die Vertragspartner sind verpflichtet, die gesetzlichen Datenschutzbestimmungen vollumfänglich einzuhalten. Für Verstöße gegen datenschutzrechtliche Schutzbestimmungen haftet ausschließlich der jeweils die Bestimmung verletzende Vertragspartner, dieser wird den schuldlos handelnden Vertragspartner von allen entsprechenden Ansprüchen freistellen und schad- und klaglos halten.</p>
|
||||||
|
<p>5.2. Im Regelfall ist der VP ist hinsichtlich der Daten der von ihm vermittelten Endkunden und Akquisitionskontakte Subauftragsverarbeiter im Sinne der Datenschutzgesetze (DSG, DSGVO); PROFIT PLANET ist Auftragsverarbeiter im Sinne der DSGVO. Soweit durch die gesetzlichen Bestimmungen vorgesehen, werden zu dieser Vereinbarung entsprechende datenschutzrechtliche Zusatzverträge abgeschlossen.</p>
|
||||||
|
<p>5.3. PROFIT PLANET ist bezüglich der Daten des VP auf Datenschutz verpflichtet. Die Datenschutzerklärung ist Online jederzeit abrufbar.</p>
|
||||||
|
<h2>6. VP-Schutz</h2>
|
||||||
|
<p>6.1. Ein neu geworbener VP wird in die Struktur desjenigen VP zugewiesen, der ihn geworben hat (VP-Schutz). Wenn mehrere VP denselben VP neu melden, wird seitens PROFIT PLANET nur die zuerst erfolgte Meldung berücksichtigt, wobei das Eingangsdatum des Registrierungsantrags bei PROFIT PLANET für die Zuteilung maßgeblich ist.</p>
|
||||||
|
<p>6.2. Der meldende VP ist verantwortlich dafür, die Daten des geworbenen VP vollständig und ordentlich zu übermitteln. PROFIT PLANET ist berechtigt, die Daten eines geworbenen VP aus ihrem System zu löschen, wenn von diesem innerhalb einer angemessenen Frist keine Umsätze oder Rückmeldungen kommen.</p>
|
||||||
|
<p>6.3. Ein Wechsel von der Struktur eines VP in die eines anderen ist grundsätzlich ausgeschlossen und nur ausnahmsweise möglich, wenn der wechselwillige VP nachweist, dass der in der Struktur über ihm stehende VP versucht hat, ihn zu einem gesetzes- oder vertragswidrigen Verhalten zu veranlassen oder sonst schwerwiegende Vorfälle die weitere Zusammenarbeit in der Struktur dieses VP untragbar machen. Über einen entsprechenden schriftlichen Antrag entscheidet PROFIT PLANET nach freiem Ermessen.</p>
|
||||||
|
<p>6.4. Ein VP, der innerhalb der letzten 12 Monate bereits einen VP-Vertrag mit PROFIT PLANET hatte, kann nicht geworben werden.</p>
|
||||||
|
<p>6.5. Eine Umgehung des VP-Schutzes etwa durch Verwendung der Namen von Strohnamen, -personen oder -firmen ist untersagt.</p>
|
||||||
|
<p>6.6. PROFIT PLANET räumt ihren VP ausdrücklich keinen Gebietsschutz ein. Alle VP können europaweit ohne Einschränkungen tätig sein.</p>
|
||||||
|
<h2>7. Provision</h2>
|
||||||
|
<p>7.1. Für jedes vom VP erfolgreich vermittelte Vertragsverhältnis zwischen Produktgeber und Endkunden erwirbt der VP Anspruch auf Provision als Bearbeitungs- und Aufwandspauschale</p>
|
||||||
|
<p>7.2. Die Höhe der Provision richtet sich nach der jeweils aktuell gültigen Provisionsübersicht laut Marketingkonzept. Die jeweils gültige Fassung dieser Provisionsübersicht ist jederzeit auf der Website von PROFIT PLANET (www.profit-planet.com) im internen Bereich abrufbar, einsehbar, downloadbar und kann dort auch auf Anfrage zur Verfügung gestellt werden. Änderungen der Provisionsübersicht werden dem VP rechtzeitig bekannt gegeben. Es gelten jeweils die zum Zeitpunkt der Vermittlung gültigen Provisionssätze.</p>
|
||||||
|
<p>7.3. Als erfolgreiche Vermittlung im Sinne dieses Vertrages gilt, wenn das Vertragsverhältnis zwischen Endkunden und Produktpartner tatsächlich zustande gekommen ist. Insbesondere entsteht kein Provisionsanspruch, wenn</p>
|
||||||
|
<ul>
|
||||||
|
<li>der Kunde von seinen Widerrufs- oder Rücktrittsrechten Gebrauch macht,</li>
|
||||||
|
<li>der Vertrag rechtswirksam angefochten wird,</li>
|
||||||
|
<li>der Kunde vom Dienstleister oder Produktpartner aus welchem Grund auch immer nicht angenommen wird,</li>
|
||||||
|
<li>fehlerhafte oder unvollständige Kundenanträge eingereicht werden,</li>
|
||||||
|
<li>der Vertrag widerrechtlich zustande gekommen ist oder</li>
|
||||||
|
<li>der Dienstleister oder Produktgeber die Auszahlung der Provision an PROFIT PLANET aus Gründen, die nicht von PROFIT PLANET zu verantworten sind, verweigert.</li>
|
||||||
|
</ul>
|
||||||
|
<p>7.4. Anspruch auf Auszahlung der Provision entsteht gegenüber PROFIT PLANET grundsätzlich erst dann, wenn die Zahlungen seitens des Geschäftspartners / Produktgebers bei PROFIT PLANET eingelangt sind und alle sonstigen Auszahlungsvoraussetzungen vorliegen. Der VP nimmt zur Kenntnis, dass die exakten Zahlungsmodalitäten bei den verschiedenen Dienstleistern oder Produktgebern voneinander abweichen können und PROFIT PLANET diese Unterschiede bei der Auszahlung berücksichtigt. Die unterschiedlichen Zeitspannen divergieren je nach Partnerunternehmen derzeit durchschnittlich zwischen 30 bis 100 Tage. Die genauen Anforderungen und Konditionen ergeben sich aus dem jeweiligen Produktpartnerinformationsblatt und dem Marketingkonzept.</p>
|
||||||
|
<p>7.5. Die Auszahlung durch PROFIT PLANET erfolgt einmal monatlich, ungefähr um den 20. des auf den Zahlungseingang bei PROFIT PLANET folgenden Monats. Die Auszahlung erfolgt bargeldlos per Überweisung auf das vom VP genannte Konto. PROFIT PLANET kann Zahlungen bis zu einer Höhe von EUR 100,00 von der Auszahlung ausschließen (Mindestauszahlungshöhe); die nicht ausbezahlten Provisionsansprüche werden auf dem Provisionskonto des VP rechnerisch fortgeführt und im Folgemonat nach Erreichen der Mindestauszahlungshöhe ausbezahlt. Beträge unterhalb der Mindestauszahlungshöhe werden einmal jährlich zur Auszahlung gebracht.</p>
|
||||||
|
<p>7.6. Der Provisionsanspruch entfällt rückwirkend, wenn PROFIT PLANET, Provisionen an einen Produktgeber zurückzahlen muss, etwa weil ein Kunde den Vertrag widerruft oder andere Ausschlusskriterien seitens des Produktgebers vorliegen (Stornohaftung etc.). PROFIT PLANET ist berechtigt, Forderungen, die dem PROFIT PLANET gegen den VP zustehen, mit dessen Provisionsansprüchen ganz oder teilweise aufzurechnen.</p>
|
||||||
|
<p>7.7. Mit dieser Provision sind sämtliche Tätigkeiten des VP einschließlich aller ihm in Zusammenhang mit dieser Vereinbarung entstandenen Kosten, Auslagen und Aufwendungen, wie beispielsweise Fahrt- und Reisekosten, Bürokosten, Porto und Telefongebühren, abgegolten. Dasselbe gilt für Leistungen des VP in Hinblick auf Pflege und Herstellung eines VP-Bestandes und/oder Kundenstocks, sodass im Fall der Beendigung des Vertrags unbeachtet des Grundes der Auflösung keinesfalls Ansprüche auf Abfindungen oder Ausgleiche jedweder Art gegen PROFIT PLANET bestehen.</p>
|
||||||
|
<p>7.8. Fehlerhafte Provisionszahlungen oder sonstige Zahlungen sind vom VP binnen 60 Tagen schriftlich einzumahnen. Danach gelten die Zahlungen als genehmigt.</p>
|
||||||
|
<p>7.9. Wenn vom VP keine UID-Nummer bekannt gegeben wird, erfolgen alle Auszahlungen netto.</p>
|
||||||
|
<h2>8. Vertragsstrafe, Schadenersatz</h2>
|
||||||
|
<p>8.1. Bei einem ersten Verstoß gegen die in diesem Vertrag geregelten Pflichten durch den VP erfolgt eine schriftliche Abmahnung durch PROFIT PLANET. Die Pflichtverletzung ist unmittelbar zu beenden bzw. gegebenenfalls zu beheben.</p>
|
||||||
|
<p>8.2. Kommt es erneut zu einem Verstoß gegen diesen Vertrag oder wird der zuerst gemahnte Zustand nicht beseitigt, so verpflichtet sich der VP zur Zahlung einer verschuldensunabhängigen Vertragsstrafe für jeden jeweiligen Verstoß in Höhe von EUR 5.000,00.</p>
|
||||||
|
<p>8.3. Bei Verstößen gegen die Geheimhaltungs- und Datenschutzpflichten, sowie bei besonders schwerwiegenden Verstößen, insbesondere gegen Punkt 10.2 dieses Vertrags, ist PROFIT PLANET auch ohne vorhergehende Abmahnung zur Geltendmachung der jeweiligen Vertragsstrafe berechtigt.</p>
|
||||||
|
<p>8.4. Für jede Zuwiderhandlung gegen Punkt 3.8 verpflichtet sich der VP zur Zahlung einer verschuldens- und schadensunabhängigen Konventionalstrafe an den PROFIT PLANET von EUR 5.000,00 pro Verstoß (z.B. pro an ein anderes Unternehmen oder sonstigen Dritten vermittelten Vertrags oder pro abgeworbenen Kunden). Die Geltendmachung darüber hinausgehender sonstiger Schadenersatzansprüche, der Vertragsstrafe nach 8.2 oder etwa von Erfüllungsansprüchen bleibt dadurch unberührt.</p>
|
||||||
|
<p>8.5. Für jeden Verstoß gegen die in Punkt 4. dieses Vertrags (Geheimhaltungsverpflichtung) normierten Pflichten, verpflichtet sich der VP zur Zahlung einer verschuldensunabhängigen Vertragsstrafe in Höhe von EUR 7.000,00 pro Verstoß. Die Geltendmachung weitergehender zivilrechtlicher Ansprüche – insbesondere auf Unterlassung und Schadenersatz – bleibt davon unberührt.</p>
|
||||||
|
<p>8.6. Bei Handlungen, die dem Katalog außerordentlicher Kündigungsgründe gemäß Punkt 10.2 entsprechen, insbesondere bei treuwidrigem Verhalten im Sinne der dort beschriebenen Fallgruppen (z. B. unautorisierte Kaltakquise, rufschädigendes Verhalten, unbefugtes Auftreten im Namen von PROFIT PLANET), verpflichtet sich der VP zur Zahlung einer verschuldensunabhängigen Vertragsstrafe in Höhe von EUR 10.000,00 pro Verstoß. Auch in diesen Fällen bleiben darüber hinausgehende Ansprüche – insbesondere Schadenersatz oder außerordentliche Kündigung – ausdrücklich vorbehalten.</p>
|
||||||
|
|
||||||
|
<h2>9. Haftungsausschluss</h2>
|
||||||
|
<p>9.1. Der VP führt seine Tätigkeiten nach bestem Wissen und Gewissen und in eigener Verantwortung, insbesondere auch in Bezug auf die korrekte Beratung der Endkunden aus. Eine Haftungsübernahme von PROFIT PLANET für Falschberatungen oder sonstiges Fehlverhalten des VP ist explizit ausgeschlossen.</p>
|
||||||
|
<p>9.2. Für Schäden haftet PROFIT PLANET nur, soweit diese auf Vorsatz oder grober Fahrlässigkeit oder auf grob schuldhafter Verletzung einer wesentlichen Vertragspflicht durch PROFIT PLANET, ihrer Mitarbeiter oder Erfüllungsgehilfen beruhen.</p>
|
||||||
|
<p>9.3. Eine Haftung von PROFIT PLANET für mittelbare Schäden, Folgeschäden, entgangenen Gewinn oder erwartete Ersparnis ist jedenfalls ausgeschlossen.</p>
|
||||||
|
<p>9.4. PROFIT PLANET übernimmt keine Haftung für Schäden, die durch Datenverlust auf den Servern auftreten, außer der Schaden beruht auf Vorsatz oder grober Fahrlässigkeit seitens PROFIT PLANET, ihrer Mitarbeiter oder Erfüllungsgehilfen.</p>
|
||||||
|
<p>9.5. Der Eintritt eines Schadens ist PROFIT PLANET unverzüglich mitzuteilen.</p>
|
||||||
|
<h2>10. Vertragsdauer & Kündigung</h2>
|
||||||
|
<p>10.1. Der Vertrag tritt mit Unterzeichnung oder im Fall einer Online-Registrierung, Online mit der Annahme des Vertrags durch PROFIT PLANET in Kraft und wird auf unbestimmte Zeit geschlossen. Er kann von beiden Parteien unter Einhaltung einer Frist von drei Monaten zum Ende jedes Kalendermonats schriftlich gekündigt werden.</p>
|
||||||
|
<p>10.2. Dessen ungeachtet kann der Vertrag seitens PROFIT PLANET aus wichtigem Grund ohne Einhaltung einer Kündigungsfrist gekündigt werden. Das Recht zur außerordentlichen Kündigung besteht ungeachtet weiterer Ansprüche. Folgende Gründe berechtigen insbesondere zur außerordentlichen Kündigung, die Aufzählung ist nicht abschließend:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Akte treuwidrigen Verhaltens, die eine weitere Zusammenarbeit zw. den Vertragspartnern unzumutbar machen;</li>
|
||||||
|
<li>ein solches treuwidriges Verhalten liegt insbesondere etwa dann vor, wenn ein VP ohne ausdrückliche Zustimmung eines vertretungs- und zeichnungsbefugten Organs von PROFIT PLANET Handlungen setzt, welche nach außen den Anschein erwecken, im Namen oder Auftrag von PROFIT PLANET zu erfolgen – insbesondere etwa durch Kaltakquise, Verwendung von Geschäftsdrucksorten oder -signaturen, Auftritt unter Verwendung der Marke PROFIT PLANET oder vergleichbare ruf- bzw. imageschädigende Aktivitäten.</li>
|
||||||
|
<li>die Anwendung unlauterer Praktiken oder ein grober oder wiederholter Verstoß gegen diesen Vertrag sowie der Verstoß gegen zwingende Rechtsnormen;</li>
|
||||||
|
<li>wenn über das Vermögen des jeweils anderen Vertragspartners die Einleitung eines Insolvenzverfahrens beantragt oder wenn die Eröffnung eines Insolvenzverfahrens mangels Masse abgelehnt wird;</li>
|
||||||
|
<li>Verletzung der vereinbarten oder gesetzlichen Datenschutz- oder Geheimhaltungspflichten;</li>
|
||||||
|
<li>wenn die Kooperation durch das Verhalten eines Vertragspartners oder dessen Ruf in der Öffentlichkeit den anderen Vertragspartnern einen Imageschaden zufügen würde;</li>
|
||||||
|
<li>wenn die Kooperation aufgrund der Gesetzeslage oder von dritter Seite als unzulässig untersagt wird;</li>
|
||||||
|
<li>Unzulässige Nebenabsprachen mit am Vertrieb beteiligten Dritten;</li>
|
||||||
|
</ul>
|
||||||
|
<p>10.3. Abgesehen von 10.2 kann PROFIT PLANET den VP auch außerordentlich kündigen, wenn dieser in den letzten 6 Monaten keine neuen Umsätze erzielt hat oder bei den durch seine Vermittlung zustande gekommenen Verträgen zwischen Endkunden und Produktgebern über einen Zeitraum von 2 Monaten überdurchschnittliche Stornoquoten von mehr als 30% der vermittelten Verträge bestehen. PROFIT PLANET wird den VP vor einer außerordentlichen Kündigung nach diesem Passus einmalig schriftlich verwarnen, so dass der VP die Möglichkeit hat, innerhalb einer Frist von 30 Tagen die erforderlichen neuen Umsätze zu generieren oder seine Stornoquote zu verbessern.</p>
|
||||||
|
<p>10.4. Mit der Beendigung des Vertrags steht dem VP mit Ausnahme der Provision für zu diesem Zeitpunkt bereits erfolgreich vermittelte Verträge, kein Recht auf Provision mehr zu. Ein Anspruch auf Handelsvertreterausgleich ist ausdrücklich ausgeschlossen, da der VP nicht als Handelsvertreter für den PROFIT PLANET tätig wird. Etwaige Ansprüche auf Folgeprovisionen für vermittelte Produkte bestehen für 12 Monate nach Vertragsbeendigung fort; im Falle einer außerordentlichen Kündigung verfallen Ansprüche auf Folgeprovisionen unmittelbar mit der Vertragsbeendigung.</p>
|
||||||
|
<p>10.5. Nach Beendigung des Vertrags sind vom VP sämtliche überlassenen Unterlagen und Werbematerialien unaufgefordert binnen einem Monat an PROFIT PLANET zurückzugeben. Die Verwendung der Marke PROFIT PLANET und entsprechender Logos etwa auf Briefpapier oder in E-Mail-Signaturen ist nach Beendigung des Vertrags untersagt.</p>
|
||||||
|
<h2>11. Übertragung</h2>
|
||||||
|
<p>11.1. PROFIT PLANET ist jederzeit berechtigt, den Geschäftsbetrieb ganz oder teilweise auf Dritte zu übertragen.</p>
|
||||||
|
<p>11.2. Der VP ist nur mit ausdrücklicher Zustimmung von PROFIT PLANET berechtigt, seine Vertriebsstruktur an einen Dritten zu übertragen.</p>
|
||||||
|
<p>11.3. Wenn eine als VP registrierte Kapital- oder Personengesellschaft einen neuen Gesellschafter aufnimmt, hat dies auf diesen Vertrag keine Auswirkung, sofern der/die Gesellschafter, die den VP-Antrag ursprünglich unterzeichnet haben, als Gesellschafter in der Gesellschaft verbleiben. Wenn ein Gesellschafter aus einer registrierten Gesellschaft ausscheidet oder seine Anteile an einen Dritten überträgt, so ist dies in Bezug auf diesen Vertrag zulässig, sofern er dies PROFIT PLANET schriftlich unter Vorlage der entsprechenden rechtsgültigen Urkunden anzeigt, und der Vorgang keinen anderen Bestimmungen dieses Vertrags widerspricht; anderenfalls behält PROFIT PLANET sich das Recht vor, den VP-Vertrag der betreffenden Kapital- oder Personengesellschaft aufzukündigen.</p>
|
||||||
|
<p>11.4. Bei Auflösung einer als VP registrierten Gemeinschaft (Kapital- oder Personengesellschaft, aber auch z.B. Ehepartnerschaften oder ähnliches, die einen gemeinsamen VP-Vertrag haben), bleibt nur ein VP-Vertrag bestehen. Die Mitglieder der aufzulösenden Gemeinschaft haben sich intern zu einigen, durch welches Mitglied/Gesellschafter die Vertriebspartner / Businesspartner / Affiliateschaft fortgesetzt werden soll, und dies PROFIT PLANET schriftlich anzuzeigen. Falls sich die Mitglieder der Gemeinschaft in Bezug auf die Fortsetzung des VP-vertrags nicht gütlich einigen können, behält sich PROFIT PLANET das Recht einer außerordentlichen Kündigung vor, insbesondere, wenn es durch die Uneinigkeit über die Folgen zur Vernachlässigung der Pflichten des VP, einem Verstoß gegen diesen Vertrag oder geltendes Recht oder zu einer übermäßigen Belastung der Vertriebsstruktur des VP kommt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header"></div>
|
||||||
|
<h2>12. Schlussbestimmungen</h2>
|
||||||
|
<p>12.1. Änderungen und Ergänzungen dieser Vereinbarung bedürfen der Schriftform. Dies gilt auch für das Abgehen der Schriftformerfordernis. Mündliche Nebenabreden bestehen nicht.</p>
|
||||||
|
<p>12.2. Sollte eine Bestimmung dieser Vereinbarung unwirksam sein oder werden, gilt anstelle der unwirksamen Bestimmung jene Bestimmung als vereinbart, die dem wirtschaftlichen Zweck der unwirksamen Bestimmung am nächsten kommt.</p>
|
||||||
|
<p>12.3. Vereinbarter Gerichtsstand für alle Streitigkeiten aus oder in Zusammenhang mit dieser Vereinbarung ist das für Graz sachlich zuständige Gericht. Diese Vereinbarung unterliegt österreichischem Recht, nicht jedoch den nichtzwingenden Verweisungsnormen des IPR. Weiter- bzw. Rückverweisungen sind ausgeschlossen. Darüber hinaus steht es PROFIT PLANET frei, den VP auch seinem allgemeinen Gerichtsstand zu klagen.</p>
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="signature" style="flex:1;text-align:center;">
|
||||||
|
<p style="margin:0 0 6px;">Für PROFIT PLANET (Auftraggeber)</p>
|
||||||
|
<!-- CHANGED: wrap stamp + date in a flex column with spacing to avoid overlap -->
|
||||||
|
<div class="sig-block" style="display:flex;flex-direction:column;align-items:center;gap:6px;min-height:140px;">
|
||||||
|
<div class="pp-stamp" style="display:block;max-width:220px;margin:0 auto 6px;">{{profitplanetSignature}}</div>
|
||||||
|
<div class="sig-date" style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature">
|
||||||
|
<p style="margin:0 0 6px;">Für den VP (Auftragnehmer)</p>
|
||||||
|
<div class="sig-block">
|
||||||
|
<span class="sig-image">{{signatureImage}}</span>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{fullName}}</div>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Name, Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
229
templates/personal/persDSGVO.html
Normal file
229
templates/personal/persDSGVO.html
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>SUB-AUFTRAGSVERARBEITUNGS-VERTRAG</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin:15mm 12mm 18mm 12mm; } /* was 20mm 17mm 22mm 17mm */
|
||||||
|
body { counter-reset:page; font-size:13px; }
|
||||||
|
h1 { font-size:18px; margin:0 0 6px; }
|
||||||
|
h2 { font-size:13px; }
|
||||||
|
.page { page-break-after:always; }
|
||||||
|
.page:last-child { page-break-after:auto; }
|
||||||
|
.page-header {
|
||||||
|
display:flex;
|
||||||
|
justify-content:flex-end;
|
||||||
|
font-size:0.65em; /* slightly smaller */
|
||||||
|
counter-increment:page;
|
||||||
|
}
|
||||||
|
.page-header:after { content:"Seite " counter(page); }
|
||||||
|
.heading-block { text-align:center; margin:0 0 10px; }
|
||||||
|
.page-header-block{display:flex;flex-direction:column;align-items:flex-end;gap:3px;margin-bottom:4px;}
|
||||||
|
.print-date{font-size:0.7em;}
|
||||||
|
/* ADDED signature sizing fix */
|
||||||
|
.sig-block { min-height:120px !important; }
|
||||||
|
/* UPDATED: show Profit Planet signature full size */
|
||||||
|
.pp-stamp,
|
||||||
|
.pp-stamp img {
|
||||||
|
max-width:none !important;
|
||||||
|
max-height:none !important;
|
||||||
|
width:auto !important;
|
||||||
|
height:auto !important;
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
.sig-date { margin-top:4px; display:block; }
|
||||||
|
@media print {
|
||||||
|
.page { padding:12px 20px 18px !important; } /* was 25px 35px 35px */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;line-height:1.35;-webkit-print-color-adjust:exact;print-color-adjust:exact;">
|
||||||
|
<div class="document" style="margin:0;">
|
||||||
|
<!-- PAGE 1 -->
|
||||||
|
<div class="page" style="page-break-after:always;padding:12px 20px 18px;">
|
||||||
|
<!-- CHANGED header/date grouping -->
|
||||||
|
<div class="page-header-block">
|
||||||
|
<div class="page-header" style="display:flex;justify-content:flex-end;font-size:0.75em;"></div>
|
||||||
|
<div class="print-date" style="font-size:0.7em;text-align:right;margin-top:4px;margin-bottom:8px;">Erstellt am: {{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="heading-block">
|
||||||
|
<h1 style="margin:0 0 8px;text-align:center;">SUB-AUFTRAGSVERARBEITUNGS-VERTRAG</h1>
|
||||||
|
<p style="margin:0 0 6px;text-align:center;">i.S.d. Art. 28 Abs. 3 Datenschutz-Grundverordnung (DS-GVO)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin:0 0 6px;">abgeschlossen zwischen</p>
|
||||||
|
<p style="margin:0 0 6px;">Profit Planet GmbH (kurz Auftraggeber)<br>
|
||||||
|
FN 649474i<br>
|
||||||
|
Liebenauer Hauptstraße 82c<br>
|
||||||
|
A-8041 Graz</p>
|
||||||
|
<p style="margin:0 0 6px;">und</p>
|
||||||
|
<p style="margin:0 0 6px;">Vertriebspartner (kurz Auftragnehmer)</p>
|
||||||
|
|
||||||
|
<div class="meta-info" style="border:1px solid #000;padding:8px 12px;margin:12px 0 25px;font-size:0.9em;">
|
||||||
|
<table class="meta-grid" style="width:100%;border-collapse:collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;width:28%;white-space:nowrap;">Vertriebspartner</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{fullName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;white-space:nowrap;">Adresse</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{address}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;white-space:nowrap;">PLZ / Ort</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{zip_code}} {{city}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;font-weight:bold;white-space:nowrap;">Vollständige Adresse</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:1px solid #ccc;">{{fullAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;font-weight:bold;white-space:nowrap;border-bottom:0;">E-Mail / Telefon</td>
|
||||||
|
<td style="padding:3px 6px;vertical-align:top;border-bottom:0;">{{email}} / {{phone}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">1. PRÄAMBEL</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">1.1. Diese Anlage konkretisiert die Verpflichtungen der Vertragsparteien zum Datenschutz, die sich aus der im bestehenden Vertriebspartner-Vertrag („Hauptvertrag“) und seinen Anlagen in ihren Einzelheiten beschriebenen Auftragsverarbeitung ergeben. Sie findet Anwendung auf alle Tätigkeiten, die mit dem Vertrag in Zusammenhang stehen, und bei denen Beschäftigte des Auftragnehmers oder durch den Auftragnehmer Beauftragte personenbezogene Daten („Daten“) des Auftraggebers verarbeiten.</p>
|
||||||
|
<p style="margin:0 0 6px;">1.2. Der Auftragnehmer ist sich bewusst, dass der Auftraggeber als Auftragsverarbeiter für Dritte („Verantwortliche“ im Sinne des Art. 4 Nr. 7 DS-GVO) tätig ist. Im Rahmen des vorbezeichneten Hauptvertrags nimmt der Auftraggeber die Dienste des Auftragnehmers als „weiteren Auftragsverarbeiter“ im Sinne von Art. 28 Nr. 4 DS-GVO in Anspruch, um bestimmte Verarbeitungstätigkeiten im Namen des Dritten („Verantwortlicher“ iSd Art. 4 Nr. 7 DS-GVO) auszuführen.</p>
|
||||||
|
<p style="margin:0 0 6px;">1.3. Der Auftragnehmer ist sich bewusst, dass der Auftraggeber gegenüber Dritten für die Einhaltung der Pflichten des Auftragnehmers haftet, falls der Auftragnehmer seinen Datenschutzpflichten nach diesem Vertrag und nach dem Gesetz nicht nachkommt.</p>
|
||||||
|
<p style="margin:0 0 6px;">1.4. Die Laufzeit dieser Anlage richtet sich nach der Laufzeit des Vertriebspartner-Vertrages, sofern sich aus den Bestimmungen dieser Anlage nicht darüber hinausgehende Verpflichtungen ergeben.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">2. DAUER, GEGENSTAND UND SPEZIFIZIERUNG DER AUFTRAGSVERARBEITUNG</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">2.1. Alle Daten dürfen nur so lange verarbeitet werden, als das durch die Vertragserfüllung oder den Zweck der Datenverarbeitung erforderlich ist.</p>
|
||||||
|
<p style="margin:0 0 6px;">2.2. Aus dem Vertrag ergeben sich Gegenstand und Dauer des Auftrags sowie Art und Zweck der Verarbeitung.</p>
|
||||||
|
<p style="margin:0 0 6px;">2.3. Im Einzelnen sind insbesondere die folgenden Daten Bestandteil der Datenverarbeitung:</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PAGE 2 -->
|
||||||
|
<div class="page" style="page-break-after:always;padding:12px 20px 18px;">
|
||||||
|
<div class="page-header" style="display:flex;justify-content:flex-end;font-size:0.75em;"></div>
|
||||||
|
|
||||||
|
<table class="data-table" style="width:100%;border-collapse:collapse;border:1px solid #000;table-layout:fixed;font-size:0.85em;margin:0 0 12px;">
|
||||||
|
<tr>
|
||||||
|
<th style="border:1px solid #000;padding:6px 8px;vertical-align:top;background:#f5f5f5;width:180px;">Art der Daten</th>
|
||||||
|
<td style="border:1px solid #000;padding:6px 8px;vertical-align:top;">Interessenten- und Kundendaten; Kontaktdaten beim Auftraggeber; Kontaktdaten des jeweiligen Datenverantwortlichen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="border:1px solid #000;padding:6px 8px;vertical-align:top;background:#f5f5f5;">Art und Zweck der Datenverarbeitung</th>
|
||||||
|
<td style="border:1px solid #000;padding:6px 8px;vertical-align:top;">Datenerfassung beim Interessenten (potenziellen Kunden); Datenübermittlung (auch elektronisch via E-Mail bzw. falls vorhanden über elektronische Schnittstellen der Verantwortlichen) an Auftraggeber bzw. Datenverantwortliche zur Legung eines Angebots bzw. zur Verwirklichung der Kundenbestellung; ggf. telefonischer Nachkontakt zur Qualitätskontrolle</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="border:1px solid #000;padding:6px 8px;vertical-align:top;background:#f5f5f5;">Kategorien betroffener Daten</th>
|
||||||
|
<td style="border:1px solid #000;padding:6px 8px;vertical-align:top;">Name, Vorname, Adresse, Geburtsdatum, SV-Nr., E-Mail, Kontodaten Ausweiskopie; Daten zur Energieversorgung (z.B. Zählpunkt, Zählernummer, Kilowattprognose, Jahresverbrauch); Aufzeichnung etwaiger Qualitätskontrollen; Aufzeichnung etwaiger Interessensgebiete im Bereich Versicherung, Kreditwirtschaft, Telekommunikation, Energieeffizienz (PV, Speicher, LED, Infrarotheizung, Kalkschutz…).</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">3. ANWENDUNGSBEREICH UND VERANTWORTLICHKEIT</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">3.1. Der Auftragnehmer verarbeitet personenbezogene Daten im Auftrag des Auftraggebers. Dies umfasst Tätigkeiten, die im Vertrag und in der Leistungsbeschreibung konkretisiert sind.</p>
|
||||||
|
<p style="margin:0 0 6px;">3.2. Der Auftraggeber ist gegenüber dem/den Dritten als („Verantwortliche Person“ iSd Art. 4 Nr. 7 DS-GVO) für die Einhaltung der gesetzlichen Bestimmungen der Datenschutzgesetze, insbesondere für die Rechtmäßigkeit der Datenweitergabe an den Auftragnehmer sowie für die Rechtmäßigkeit der Datenverarbeitung verantwortlich.</p>
|
||||||
|
<p style="margin:0 0 6px;">3.3. Der Auftragnehmer ist gegenüber dem Auftraggeber im Rahmen dieses Vertrages für die Einhaltung der gesetzlichen Bestimmungen der Datenschutzgesetze, insbesondere für die Rechtmäßigkeit der Datenweitergabe sowie der Datenverarbeitung verantwortlich.</p>
|
||||||
|
<p style="margin:0 0 6px;">3.4. Die Weisungen werden anfänglich durch diese Vertragsanlage festgelegt und können vom Auftraggeber danach in schriftlicher Form oder in einem elektronischen Format (Textform) an die vom Auftragnehmer bezeichnete Stelle durch einzelne Weisungen geändert, ergänzt oder ersetzt werden (Einzelweisung). Weisungen, die in der Vertragsanlage nicht vorgesehen sind, werden als Antrag auf Leistungsänderung behandelt. Mündliche Weisungen sind unverzüglich schriftlich oder in Textform zu bestätigen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">4. PFLICHTEN DES AUFTRAGNEHMERS</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">4.1. Der Auftragnehmer darf Daten von betroffenen Personen nur im Rahmen des Auftrages und der Weisungen des Auftraggebers verarbeiten, außer es liegt ein Ausnahmefall iSd Art 28 Abs. 3 a) DS-GVO vor. Der Auftragnehmer informiert den Auftraggeber unverzüglich, wenn er der Auffassung ist, dass eine Weisung gegen anwendbare Gesetze verstößt. Der Auftragnehmer darf die Umsetzung der Weisung solange aussetzen, bis sie vom Auftraggeber bestätigt oder abgeändert wurde.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.2. Der Auftragnehmer wird in seinem Verantwortungsbereich die innerbetriebliche Organisation so gestalten, dass sie den besonderen Anforderungen des Datenschutzes gerecht wird. Er wird technische und organisatorische Maßnahmen zum angemessenen Schutz der Daten des Auftraggebers treffen, die den Anforderungen der Datenschutz- Grundverordnung (Art. 32 DS-GVO) genügen. Der Auftragnehmer hat technische und organisatorische Maßnahmen zu treffen, die die Vertraulichkeit, Integrität, Verfügbarkeit und Belastbarkeit der Systeme und Dienste im Zusammenhang mit der Verarbeitung auf Dauer sicherstellen. Der Auftraggeber ist berechtigt, diese technischen und organisatorischen Maßnahmen dahingehend zu überprüfen, ob sie für die Risiken der zu verarbeitenden Daten ein angemessenes Schutzniveau bieten. Eine Änderung der getroffenen Sicherheitsmaßnahmen bleibt dem Auftragnehmer vorbehalten, wobei jedoch sichergestellt sein muss, dass das vertraglich vereinbarte Schutzniveau nicht unterschritten wird.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.3. Der Auftragnehmer gewährleistet, seinen Pflichten nach Art. 32 Abs. 1 lit. d) DS-GVO nachzukommen, ein Verfahren zur regelmäßigen Überprüfung der Wirksamkeit der technischen und organisatorischen Maßnahmen zur Gewährleistung der Sicherheit der Verarbeitung einzusetzen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.4. Der Auftragnehmer unterstützt den Auftraggeber im Rahmen seiner Möglichkeiten bei der Erfüllung der Anfragen und Ansprüche betroffener Personen gem. Kapitel III der DS-GVO sowie bei der Einhaltung der in Art. 33 bis 36 DS-GVO genannten Pflichten.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.5. Der Auftragnehmer gewährleistet, dass es den mit der Verarbeitung der Daten des Auftraggebers befassten Mitarbeiter und andere für den Auftragnehmer tätigen Personen untersagt ist, die Daten außerhalb der Weisung zu verarbeiten. Ferner gewährleistet der Auftragnehmer, dass sich die zur Verarbeitung der personenbezogenen Daten befugten Personen zur Vertraulichkeit verpflichtet haben oder einer angemessenen gesetzlichen Verschwiegenheitspflicht unterliegen. Die Vertraulichkeits-/ Verschwiegenheitspflicht besteht auch nach Beendigung des Auftrages fort.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<!-- FIX: reopen subsection and place paragraphs inside -->
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">4.6. Der Auftragnehmer unterrichtet den Auftraggeber unverzüglich, wenn ihm Verletzungen des Schutzes personenbezogener Daten des Auftraggebers bekannt werden. Der Auftragnehmer trifft die erforderlichen Maßnahmen zur Sicherung der Daten und zur Minderung möglicher nachteiliger Folgen der betroffenen Personen und spricht sich hierzu unverzüglich mit dem Auftraggeber ab.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.7. Der Auftragnehmer nennt dem Auftraggeber den Ansprechpartner für im Rahmen des Vertrages anfallende Datenschutzfragen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.8. Der Auftragnehmer berichtigt oder löscht die vertragsgegenständlichen Daten, wenn der Auftraggeber dies anweist und dies vom Weisungsrahmen umfasst ist. Ist eine datenschutzkonforme Löschung oder eine entsprechende Einschränkung der Datenverarbeitung nicht möglich, übernimmt der Auftragnehmer die datenschutzkonforme Vernichtung von Datenträgern und sonstigen Materialien auf Grund einer Einzelbeauftragung durch den Auftraggeber oder gibt diese Datenträger an den Auftraggeber zurück, sofern nicht im Vertrag bereits vereinbart.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.9. Daten, Datenträger sowie sämtliche sonstige Materialien sind nach Auftragsende auf Verlangen des Auftraggebers entweder herauszugeben oder zu löschen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.10. Im Falle einer Inanspruchnahme des Auftraggebers oder des Dritten durch eine betroffene Person hinsichtlich etwaiger Ansprüche nach Art. 82 DS-GVO, verpflichtet sich der Auftragnehmer den Auftraggeber bei der Abwehr des Anspruches im Rahmen seiner Möglichkeiten zu unterstützen.</p>
|
||||||
|
<p style="margin:0 0 6px;">4.11. Im Falle einer Inanspruchnahme des Auftraggebers durch den Dritten, verpflichtet sich der Auftragnehmer den Auftraggeber bei der Abwehr des Anspruches im Rahmen seiner Möglichkeiten zu unterstützen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">5. PFLICHTEN DES AUFTRAGGEBERS</h2>
|
||||||
|
<div class="subsection" style="margin-left:20px;">
|
||||||
|
<p style="margin:0 0 6px;">5.1. Der Auftraggeber hat den Auftragnehmer unverzüglich und vollständig zu informieren, wenn er in den Auftragsergebnissen Fehler oder Unregelmäßigkeiten bzgl. datenschutzrechtlicher Bestimmungen feststellt.</p>
|
||||||
|
<p style="margin:0 0 6px;">5.2. Im Falle einer Inanspruchnahme des Auftraggebers oder des Dritten durch eine betroffene Person hinsichtlich etwaiger Ansprüche nach Art. 82 DS-GVO, gilt §3 Abs. 10 entsprechend.</p>
|
||||||
|
<p style="margin:0 0 6px;">5.3. Der Auftraggeber nennt dem Auftragnehmer den Ansprechpartner für im Rahmen des Vertrages anfallende Datenschutzfragen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">6. ANFRAGEN BETROFFENER PERSONEN</h2>
|
||||||
|
<div class="subsection" style="margin-left:20px;">
|
||||||
|
<p style="margin:0 0 6px;">6.1. Wendet sich eine betroffene Person mit Forderungen zur Berichtigung, Löschung oder Auskunft an den Auftragnehmer, wird der Auftragnehmer die betroffene Person an den Auftraggeber verweisen und ggf. den Antrag der betroffenen Person unverzüglich an den Auftraggeber weiterleiten. Der Auftragnehmer unterstützt den Auftraggeber im Rahmen seiner Möglichkeiten bei der Erfüllung der jeweiligen Forderung.</p>
|
||||||
|
<p style="margin:0 0 6px;">6.2. Der Auftragnehmer haftet nicht, wenn das Ersuchen der betroffenen Person vom Auftraggeber nicht, nicht richtig oder nicht fristgerecht beantwortet wird.</p>
|
||||||
|
<p style="margin:0 0 6px;">6.3. Der Auftraggeber haftet nicht für Forderungen betroffener Personen, die dadurch entstehen, dass der Auftragnehmer das entsprechende Anliegen nicht zeitgerecht an den Auftraggeber übermittelt hat.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">7. NACHWEISMÖGLICHKEITEN</h2>
|
||||||
|
<div class="subsection" style="margin-left:20px;">
|
||||||
|
<p style="margin:0 0 6px;">7.1. Der Auftragnehmer weist dem Auftraggeber die Einhaltung der in diesem Vertrag niedergelegten Pflichten mit geeigneten Mitteln nach.</p>
|
||||||
|
<p style="margin:0 0 6px;">7.2. Sollten im Einzelfall Inspektionen durch den Auftraggeber oder einen von diesem beauftragten Prüfer erforderlich sein, werden diese zu den üblichen Geschäftszeiten ohne Störung des Betriebsablaufs nach Anmeldung unter Berücksichtigung einer angemessenen Vorlaufzeit durchgeführt. Der Auftragnehmer darf diese von der Unterzeichnung einer Verschwiegenheitserklärung hinsichtlich der Daten anderer Kunden und der eingerichteten technischen und organisatorischen Maßnahmen abhängig machen. Sollte der durch den Auftraggeber beauftragte Prüfer in einem Wettbewerbsverhältnis zu dem Auftragnehmer stehen, hat der Auftragnehmer gegen diesen ein Einspruchsrecht</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MOVED UP: Sections 8 & 9 and signatures (formerly PAGE 4) -->
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">8. SUBUNTERNEHMER (WEITERE AUFTRAGSVERARBEITER)</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">8.1. Der Einsatz von Subunternehmern als weitere Auftragsverarbeiter ist nur zulässig, wenn der Auftraggeber vorher zugestimmt hat.</p>
|
||||||
|
<p style="margin:0 0 6px;">8.2. Ein zustimmungspflichtiges Subunternehmerverhältnis liegt vor, wenn der Auftragnehmer weitere Auftragnehmer mit der ganzen oder einer Teilleistung der im Vertrag vereinbarten Leistung beauftragt. Der Auftragnehmer wird mit diesen Dritten im erforderlichen Umfang Vereinbarungen treffen, um angemessene Datenschutz- und Informationssicherheitsmaßnahmen zu gewährleisten.</p>
|
||||||
|
<p style="margin:0 0 6px;">8.3. Erteilt der Auftragnehmer Aufträge an Subunternehmer, so obliegt es dem Auftragnehmer, seine datenschutzrechtlichen Pflichten aus diesem Vertrag dem Subunternehmer zu überbinden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-bottom:10px;">
|
||||||
|
<h2 style="margin:14px 0 6px;">9. INFORMATIONSPFLICHTEN, SCHRIFTFORMKLAUSEL, RECHTSWAHL</h2>
|
||||||
|
<div class="subsection" style="margin-left:16px;">
|
||||||
|
<p style="margin:0 0 6px;">9.1. Sollten die Daten des Auftraggebers beim Auftragnehmer durch Pfändung oder Beschlagnahme, durch ein Insolvenz- oder Vergleichsverfahren oder durch sonstige Ereignisse oder Maßnahmen Dritter gefährdet werden, so hat der Auftragnehmer den Auftraggeber unverzüglich darüber zu informieren. Der Auftragnehmer wird alle in diesem Zusammenhang Verantwortlichen unverzüglich darüber informieren, dass die Hoheit und das Eigentum an den Daten ausschließlich beim Dritten als verantwortliche Person im Sinne der Datenschutz-Grundverordnung liegen.</p>
|
||||||
|
<p style="margin:0 0 6px;">9.2. Änderungen und Ergänzungen dieser Anlage und aller ihrer Bestandteile – einschließlich etwaiger Zusicherungen des Auftragnehmers – bedürfen einer schriftlichen Vereinbarung, die auch in einem elektronischen Format (Textform) erfolgen kann, und des ausdrücklichen Hinweises darauf, dass es sich um eine Änderung bzw. Ergänzung dieser Bedingungen handelt. Dies gilt auch für den Verzicht auf dieses Formerfordernis.</p>
|
||||||
|
<p style="margin:0 0 6px;">9.3. Bei etwaigen Widersprüchen gehen Regelungen dieser Anlage zum Datenschutz den Regelungen des Vertrages vor. Sollten einzelne Teile dieser Anlage unwirksam sein, so berührt dies die Wirksamkeit der Anlage im Übrigen nicht.</p>
|
||||||
|
<p style="margin:0 0 6px;">9.4. Es gilt das auf dem Hauptvertrag anwendbare Recht sowie Gerichtsstand.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signatures" style="display:flex;gap:30px;margin-top:38px;">
|
||||||
|
<div class="signature" style="flex:1;text-align:center;">
|
||||||
|
<p style="margin:0 0 6px;">Für PROFIT PLANET (Auftraggeber)</p>
|
||||||
|
<!-- CHANGED: wrap stamp + date in a flex column with spacing to avoid overlap -->
|
||||||
|
<div class="sig-block" style="display:flex;flex-direction:column;align-items:center;gap:6px;min-height:140px;">
|
||||||
|
<div class="pp-stamp" style="display:block;max-width:220px;margin:0 auto 6px;">{{profitplanetSignature}}</div>
|
||||||
|
<div class="sig-date" style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature" style="flex:1;text-align:center;">
|
||||||
|
<p style="margin:0 0 6px;">Für den VP (Auftragnehmer)</p>
|
||||||
|
<div class="sig-block" style="display:flex;flex-direction:column;align-items:center;gap:4px;min-height:110px;">
|
||||||
|
<span style="display:block;max-width:100%;max-height:80px;">{{signatureImage}}</span>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{fullName}}</div>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Name, Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
templates/personal/persVERTRAG.html
Normal file
209
templates/personal/persVERTRAG.html
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VERTRIEBSPARTNER / BUSINESSPARTNER / AFFILIATE - VERTRAG</title>
|
||||||
|
<style>
|
||||||
|
/* PDF friendly page setup */
|
||||||
|
@page { size:A4; margin:15mm 12mm 18mm 12mm; }
|
||||||
|
body { font-family:Arial,Helvetica,sans-serif; line-height:1.4; font-size:13px; counter-reset: page; margin:0; }
|
||||||
|
h1 { text-align:center; font-size:18px; margin:0 0 8px; }
|
||||||
|
h2 { margin:16px 0 6px; font-size:13px; }
|
||||||
|
.page { page-break-after:always; padding:12px 20px 18px; }
|
||||||
|
.page:last-child { page-break-after:auto; }
|
||||||
|
.page-header { display:flex; justify-content:flex-end; font-size:0.65em; counter-increment:page; }
|
||||||
|
.page-header:after { content:"Seite " counter(page); }
|
||||||
|
.meta-info { border:1px solid #000; padding:8px 12px; margin:12px 0 25px; font-size:0.9em; }
|
||||||
|
.meta-info table { width:100%; border-collapse:collapse; }
|
||||||
|
.meta-info td { padding:3px 6px; vertical-align:top; border-bottom:1px solid #ccc; }
|
||||||
|
.meta-info td:first-child { font-weight:bold; width:28%; white-space:nowrap; }
|
||||||
|
.meta-info tr:last-child td { border-bottom:0; }
|
||||||
|
/* Unified signatures */
|
||||||
|
.signatures { display:flex; gap:30px; margin-top:38px; }
|
||||||
|
.signature { flex:1; text-align:center; }
|
||||||
|
/* Added: extra space below company stamp so date sits clearly underneath */
|
||||||
|
.signature-company .sig-image { margin-bottom:22px !important; }
|
||||||
|
.signature-company .sig-date { margin-top:0 !important; }
|
||||||
|
.sig-block { display:flex; flex-direction:column; align-items:center; gap:6px; min-height:120px; }
|
||||||
|
.sig-image { display:block; max-width:180px !important; max-height:70px !important; height:auto !important; margin:0 auto 6px; }
|
||||||
|
.sig-date { margin-top:4px; display:block; }
|
||||||
|
.page-header-block{display:flex;flex-direction:column;align-items:flex-end;gap:3px;margin-bottom:6px;}
|
||||||
|
.print-date{font-size:0.7em;}
|
||||||
|
@media print {
|
||||||
|
body { -webkit-print-color-adjust:exact; print-color-adjust:exact; }
|
||||||
|
}
|
||||||
|
/* ADDED: full size Profit Planet signature */
|
||||||
|
.pp-stamp,
|
||||||
|
.pp-stamp img {
|
||||||
|
max-width:none !important;
|
||||||
|
max-height:none !important;
|
||||||
|
width:auto !important;
|
||||||
|
height:auto !important;
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header-block">
|
||||||
|
<div class="page-header"></div>
|
||||||
|
<div class="print-date">Erstellt am: {{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<h1>VERTRIEBSPARTNER / BUSINESSPARTNER / AFFILIATE - VERTRAG</h1>
|
||||||
|
<p style="text-align:center;margin:0 0 6px;">idF 21.05.2025</p>
|
||||||
|
<p>abgeschlossen zwischen</p>
|
||||||
|
<p>Profit Planet GmbH (kurz PROFIT PLANET)<br>FN 649474i<br>Liebenauer Hauptstraße 82c<br>A-8041 Graz</p>
|
||||||
|
<p>und</p>
|
||||||
|
<p>Vertriebspartner / Businesspartner / Affiliate (kurz VP)</p>
|
||||||
|
<div class="meta-info">
|
||||||
|
<table class="meta-grid">
|
||||||
|
<tr>
|
||||||
|
<td>Vertriebspartner</td>
|
||||||
|
<td>{{fullName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Adresse</td>
|
||||||
|
<td>{{address}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>PLZ / Ort</td>
|
||||||
|
<td>{{zip_code}} {{city}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Vollständige Adresse</td>
|
||||||
|
<td>{{fullAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>E-Mail / Telefon</td>
|
||||||
|
<td>{{email}} / {{phone}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<h2>1. Präambel und Vertragsgegenstand</h2>
|
||||||
|
<p>1.1. Dieser Vertrag regelt die Zusammenarbeit zwischen PROFIT PLANET und VP als Grundlage einer fairen, langfristigen und erfolgreichen Kooperation. Die VP unterstützen einander im Sinne der Ziele der Zusammenarbeit und unterrichten sich gegenseitig über alle Vorgänge, die für ihre Leistungen im Rahmen der Kooperation von Interesse sind.</p>
|
||||||
|
<p>1.2. PROFIT PLANET bietet über ein Vertriebspartner / Businesspartner / Affiliate-Netzwerk den Vertrieb verschiedener Dienstleistungen und Produkte, vornehmlich aus den Bereichen Nachhaltigkeit, Energie, Handel sowie Consulting und Coaching an.</p>
|
||||||
|
<p>1.3. Der VP vermittelt die jeweiligen Dienstleistungen, Produkte oder qualifizierten Leads, die zu einem Abschluss führen, und erhält dafür eine Provision. Für die Tätigkeit als VP ist es nicht erforderlich, weitere VP zu werben.</p>
|
||||||
|
<p>1.4. Der VP ist berechtigt, weitere Vertriebspartner / Businesspartner / Affiliate für den Vertrieb der Dienstleistungen und Produkte zu gewinnen. Für die Vermittlung und Betreuung der von ihm akquirierten Vertriebspartner / Businesspartner / Affiliate erhält der werbende VP eine Provision, die sich aus den erwirtschafteten Umsätzen der geworbenen VP ermittelt. Die Höhe der Provision ergibt sich aus der Provisionsübersicht.</p>
|
||||||
|
<p>1.5. Die Vertragsabschlüsse kommen nur zwischen dem Endkunden und dem jeweiligen Dienstleister und/oder Produktgeber (Energieversorgungs-, Handels-, Dienstleistungs- oder Coachingunternehmen) zustande, ohne dass dadurch eine Vertragsbeziehung zwischen dem VP und dem Endkunden entsteht. Ein Anspruch auf Abschluss des jeweiligen Vertrags seitens des Endkunden gegenüber PROFIT PLANET oder dem VP entsteht nicht; der Vertragsabschluss ist von der Annahme des entsprechenden Antrags durch den Dienstleister bzw. Produktgeber abhängig. PROFIT PLANET hat darauf keinen Einfluss.</p>
|
||||||
|
<p>1.6. PROFIT PLANET behält sich vor, die angebotenen Produkte zurückzuziehen, zu ändern, neue hinzuzufügen oder sonstige Anpassungen des Produktangebots vorzunehmen. PROFIT PLANET wird den VP über Änderungen von Produkten oder Tarifen nach Maßgabe der Möglichkeiten rechtzeitig vor Wirksamkeit der Änderungen informieren.</p>
|
||||||
|
<p>1.7. Die genauen Produktbestandteile und Konditionen ergeben sich aus dem jeweiligen Produktpartnerinformationsblatt, welches auf der Online-Plattform hinterlegt wird.</p>
|
||||||
|
<p>1.8. PROFIT PLANET ist berechtigt, nach eigenem Ermessen andere Personen und Unternehmen mit der Vermittlung von Produkten und Dienstleistungen von PROFIT PLANET bzw. Produktpartnern von PROFIT PLANET zu beauftragen. Es bestehen grundsätzlich keine Alleinvermittlungsaufträge und keine Exklusivität.</p>
|
||||||
|
|
||||||
|
<h2>2. Vertriebspartner / Businesspartner / Affiliate werden</h2>
|
||||||
|
<p>2.1. Kapitalgesellschaften, Personengesellschaften und volljährige natürliche Personen können Vertriebspartner / Businesspartner / Affiliate des PROFIT PLANET werden; pro Entität ist die Registrierung nur eines VP-Vertrags vorgesehen. Natürliche Personen, die bloß als Verbraucher handeln (wollen), können nicht Vertriebspartner / Businesspartner / Affiliate von PROFIT PLANET werden.</p>
|
||||||
|
<p>2.2. Kapitalgesellschaften müssen ihrem VP-Antrag die Firmenbuchnummer und gegebenenfalls die Umsatzsteuer-Identifikationsnummer (UID) beilegen. Der Antrag muss von allen Zeichnungsbereichten der Gesellschaft derart gezeichnet werden, dass eine rechtwirksame Vertretung sichergestellt ist. Die Gesellschafter haften gegenüber PROFIT PLANET jeweils persönlich für das Verhalten der Gesellschaft.</p>
|
||||||
|
<p>2.3. Absatz 2.2 gilt inhaltsgemäß auch für Personengesellschaften.</p>
|
||||||
|
<p>2.4. Der VP ist verpflichtet, Änderungen seiner unternehmens- oder personenbezogenen Daten unverzüglich an PROFIT PLANET zu melden.</p>
|
||||||
|
<p>2.5. Für die Verwendung des Online-Systems gelten die allgemeinen Geschäftsbedingungen.</p>
|
||||||
|
<p>2.6. PROFIT PLANET kann Vertriebspartner / Businesspartner / Affiliate ohne Angabe von Gründen ablehnen.</p>
|
||||||
|
|
||||||
|
<h2>3. Leistungen / Pflichten des VP</h2>
|
||||||
|
<p>3.1. Der VP handelt unabhängig als selbständiger Unternehmer, er ist weder Arbeitnehmer noch Handelsvertreter oder Makler von PROFIT PLANET. Er ist bei der Vermittlung von Produktverträgen eigenverantwortlich tätig, handelt abgesehen von den Pflichten aus diesem Vertrag frei von Weisungen und ist nicht mit der ständigen Vermittlung von Geschäften betraut. Es bestehen seitens PROFIT PLANET keine Umsatzvorgaben und keine Abnahme- oder Vertriebspflichten. Der VP trägt alle mit der Kundenakquisition verbundenen Kosten und Risiken selbst und verwendet eigene Betriebsmittel. Er stellt im geschäftlichen Verkehr klar, dass er nicht im Auftrag oder im Namen von PROFIT PLANET handelt, sondern als unabhängiger Vertriebspartner / Businesspartner / Affiliate.</p>
|
||||||
|
<p>3.2. Der VP betreibt sein Unternehmen mit der Sorgfalt eines ordentlichen Kaufmanns und ist für die Einhaltung aller gesetzlichen sowie der steuer- und sozialrechtlichen Vorgaben selbst verantwortlich.</p>
|
||||||
|
<p>3.3. Der VP hält sich insbesondere auch an das Wettbewerbsrecht und nimmt Abstand von ungenehmigter, irreführender oder sonst unlauterer Werbung. Der VP verpflichtet sich auch, falsche oder irreführende Aussagen über Dienstleistungen, Produkte und Vertriebssystem der PROFIT PLANET zu unterlassen.</p>
|
||||||
|
<p>3.4. Grundsätzlich steht es dem VP frei, Produkte / Dienstleistungen auch für andere Unternehmen zu vertreiben. Falls es allerdings in der Zusammenarbeit mit einem anderen Dienstleister oder Produktgeber in räumlicher oder zeitlicher Nähe zu Überschneidungen im Vertrieb, insbesondere bei Terminisierungen, Promotion-Auftritten (POS) oder anderen dienstleistungs- oder produktspezifischen Werbetätigkeiten kommen, so wäre dies nur nach ausdrücklicher Zustimmung durch PROFIT PLANET zulässig.</p>
|
||||||
|
<p>3.5. Beim Abschluss von Kundenverträgen ist der VP verpflichtet, die von PROFIT PLANET zur Verfügung gestellten Originalunterlagen (zB Antragsformulare, AGB, sonstige Unterlagen der Dienstleister oder Produktgeber) in der jeweils aktuellen Version zu verwenden und dem Kunden bei Vertragsabschluss vorzulegen bzw. auszuhändigen. Die Originalunterlagen sind durch den VP nicht zu verändern, missbräuchliche Verwendung ist zu verhindern.</p>
|
||||||
|
<p>3.6. Kundenverträge in Papierform sind vom VP unverzüglich, spätestens jedoch binnen 1 Woche nach Aufforderung durch PROFIT PLANET oder den Produktgeber an PROFIT PLANET auszuhändigen.</p>
|
||||||
|
<p>3.7. Sämtliche Präsentations-, Werbe- und Schulungsmaterialien sowie label von PROFIT PLANET sind urheberrechtlich geschützt und dürfen ohne ausdrückliches Einverständnis von PROFIT PLANET weder ganz noch teilweise vervielfältigt, verbreitet oder öffentlich zugänglich gemacht werden. Die Herstellung, Verwendung und Verbreitung eigener Werbemittel, Schulungsmaterialien oder Produktbroschüren ist nur nach schriftlicher Genehmigung und Freigabe durch PROFIT PLANET gestattet.</p>
|
||||||
|
<p>3.8. Der VP ist während der Dauer dieser Vereinbarung und für die Dauer von 36 Monaten nach Beendigung dieses Vertrags aus welchem Grund immer, nicht berechtigt, unmittelbar selbst bzw. mittelbar über Dritte Kunden von PROFIT PLANET und ihrer Produktpartner, einschließlich der vom VP vermittelten Endkunden, durch direkte Ansprache abzuwerben. Als Abwerben gilt jede Form des direkten Herantretens an den Kunden mit der Absicht, ihn zum Wechsel zu einem anderen Energieversorgungs-, Dienstleistungs-, Handels-, und/oder Coachingunternehmen zu bewegen (beispielsweise etwa durch Anrufe beim Kunden, Direktmailing mit Absicht der Abwerbung, Haustürgeschäfte etc.).</p>
|
||||||
|
<h2>4. Geheimhaltung</h2>
|
||||||
|
<p>4.1. Der VP verpflichtet sich, Geschäfts- und Betriebsgeheimnisse und sonstige vertrauliche Informationen von PROFIT PLANET und dessen Struktur, Geschäftspartner, Vertriebspartner / Businesspartner / Affiliate, Produktgeber, Provisionen und Endkunden unter äußerster Geheimhaltung zu behandeln und zu verwahren und diese Daten nur nach erfolgter schriftlicher Zustimmung durch den PROFIT PLANET an Dritte weiterzugeben.</p>
|
||||||
|
<p>4.2. Diese Verpflichtung gilt auch für Mitarbeiter und Unter-Vertriebspartner / Businesspartner / Affiliate des VP. Der VP hat für das Verhalten allfälliger Erfüllungsgehilfen und/oder Subpartner einzustehen.</p>
|
||||||
|
<p>4.3. Zu den Geschäftsgeheimnissen gehören insbesondere auch Informationen zu internen Betriebsabläufen, Provisionen und Provisionsstrukturen, Produkt- und Preiskalkulationen, Vertriebspartner / Businesspartner / Affiliate-strukturen und -aktivitäten.</p>
|
||||||
|
<p>4.4. Dem VP ist es nicht gestattet, auf Presseanfragen zu PROFIT PLANET, dessen Provisionspläne, Produkte oder andere Leistungen zu antworten. Presseanfragen sind immer an PROFIT PLANET weiterzuleiten.</p>
|
||||||
|
<h2>5. Datenschutz</h2>
|
||||||
|
<p>5.1. Die Vertragspartner sind verpflichtet, die gesetzlichen Datenschutzbestimmungen vollumfänglich einzuhalten. Für Verstöße gegen datenschutzrechtliche Schutzbestimmungen haftet ausschließlich der jeweils die Bestimmung verletzende Vertragspartner, dieser wird den schuldlos handelnden Vertragspartner von allen entsprechenden Ansprüchen freistellen und schad- und klaglos halten.</p>
|
||||||
|
<p>5.2. Im Regelfall ist der VP ist hinsichtlich der Daten der von ihm vermittelten Endkunden und Akquisitionskontakte Subauftragsverarbeiter im Sinne der Datenschutzgesetze (DSG, DSGVO); PROFIT PLANET ist Auftragsverarbeiter im Sinne der DSGVO. Soweit durch die gesetzlichen Bestimmungen vorgesehen, werden zu dieser Vereinbarung entsprechende datenschutzrechtliche Zusatzverträge abgeschlossen.</p>
|
||||||
|
<p>5.3. PROFIT PLANET ist bezüglich der Daten des VP auf Datenschutz verpflichtet. Die Datenschutzerklärung ist Online jederzeit abrufbar.</p>
|
||||||
|
<h2>6. VP-Schutz</h2>
|
||||||
|
<p>6.1. Ein neu geworbener VP wird in die Struktur desjenigen VP zugewiesen, der ihn geworben hat (VP-Schutz). Wenn mehrere VP denselben VP neu melden, wird seitens PROFIT PLANET nur die zuerst erfolgte Meldung berücksichtigt, wobei das Eingangsdatum des Registrierungsantrags bei PROFIT PLANET für die Zuteilung maßgeblich ist.</p>
|
||||||
|
<p>6.2. Der meldende VP ist verantwortlich dafür, die Daten des geworbenen VP vollständig und ordentlich zu übermitteln. PROFIT PLANET ist berechtigt, die Daten eines geworbenen VP aus ihrem System zu löschen, wenn von diesem innerhalb einer angemessenen Frist keine Umsätze oder Rückmeldungen kommen.</p>
|
||||||
|
<p>6.3. Ein Wechsel von der Struktur eines VP in die eines anderen ist grundsätzlich ausgeschlossen und nur ausnahmsweise möglich, wenn der wechselwillige VP nachweist, dass der in der Struktur über ihm stehende VP versucht hat, ihn zu einem gesetzes- oder vertragswidrigen Verhalten zu veranlassen oder sonst schwerwiegende Vorfälle die weitere Zusammenarbeit in der Struktur dieses VP untragbar machen. Über einen entsprechenden schriftlichen Antrag entscheidet PROFIT PLANET nach freiem Ermessen.</p>
|
||||||
|
<p>6.4. Ein VP, der innerhalb der letzten 12 Monate bereits einen VP-Vertrag mit PROFIT PLANET hatte, kann nicht geworben werden.</p>
|
||||||
|
<p>6.5. Eine Umgehung des VP-Schutzes etwa durch Verwendung der Namen von Strohnamen, -personen oder -firmen ist untersagt.</p>
|
||||||
|
<p>6.6. PROFIT PLANET räumt ihren VP ausdrücklich keinen Gebietsschutz ein. Alle VP können europaweit ohne Einschränkungen tätig sein.</p>
|
||||||
|
<h2>7. Provision</h2>
|
||||||
|
<p>7.1. Für jedes vom VP erfolgreich vermittelte Vertragsverhältnis zwischen Produktgeber und Endkunden erwirbt der VP Anspruch auf Provision als Bearbeitungs- und Aufwandspauschale</p>
|
||||||
|
<p>7.2. Die Höhe der Provision richtet sich nach der jeweils aktuell gültigen Provisionsübersicht laut Marketingkonzept. Die jeweils gültige Fassung dieser Provisionsübersicht ist jederzeit auf der Website von PROFIT PLANET (www.profit-planet.com) im internen Bereich abrufbar, einsehbar, downloadbar und kann dort auch auf Anfrage zur Verfügung gestellt werden. Änderungen der Provisionsübersicht werden dem VP rechtzeitig bekannt gegeben. Es gelten jeweils die zum Zeitpunkt der Vermittlung gültigen Provisionssätze.</p>
|
||||||
|
<p>7.3. Als erfolgreiche Vermittlung im Sinne dieses Vertrages gilt, wenn das Vertragsverhältnis zwischen Endkunden und Produktpartner tatsächlich zustande gekommen ist. Insbesondere entsteht kein Provisionsanspruch, wenn</p>
|
||||||
|
<ul>
|
||||||
|
<li>der Kunde von seinen Widerrufs- oder Rücktrittsrechten Gebrauch macht,</li>
|
||||||
|
<li>der Vertrag rechtswirksam angefochten wird,</li>
|
||||||
|
<li>der Kunde vom Dienstleister oder Produktpartner aus welchem Grund auch immer nicht angenommen wird,</li>
|
||||||
|
<li>fehlerhafte oder unvollständige Kundenanträge eingereicht werden,</li>
|
||||||
|
<li>der Vertrag widerrechtlich zustande gekommen ist oder</li>
|
||||||
|
<li>der Dienstleister oder Produktgeber die Auszahlung der Provision an PROFIT PLANET aus Gründen, die nicht von PROFIT PLANET zu verantworten sind, verweigert.</li>
|
||||||
|
</ul>
|
||||||
|
<p>7.4. Anspruch auf Auszahlung der Provision entsteht gegenüber PROFIT PLANET grundsätzlich erst dann, wenn die Zahlungen seitens des Geschäftspartners / Produktgebers bei PROFIT PLANET eingelangt sind und alle sonstigen Auszahlungsvoraussetzungen vorliegen. Der VP nimmt zur Kenntnis, dass die exakten Zahlungsmodalitäten bei den verschiedenen Dienstleistern oder Produktgebern voneinander abweichen können und PROFIT PLANET diese Unterschiede bei der Auszahlung berücksichtigt. Die unterschiedlichen Zeitspannen divergieren je nach Partnerunternehmen derzeit durchschnittlich zwischen 30 bis 100 Tage. Die genauen Anforderungen und Konditionen ergeben sich aus dem jeweiligen Produktpartnerinformationsblatt und dem Marketingkonzept.</p>
|
||||||
|
<p>7.5. Die Auszahlung durch PROFIT PLANET erfolgt einmal monatlich, ungefähr um den 20. des auf den Zahlungseingang bei PROFIT PLANET folgenden Monats. Die Auszahlung erfolgt bargeldlos per Überweisung auf das vom VP genannte Konto. PROFIT PLANET kann Zahlungen bis zu einer Höhe von EUR 100,00 von der Auszahlung ausschließen (Mindestauszahlungshöhe); die nicht ausbezahlten Provisionsansprüche werden auf dem Provisionskonto des VP rechnerisch fortgeführt und im Folgemonat nach Erreichen der Mindestauszahlungshöhe ausbezahlt. Beträge unterhalb der Mindestauszahlungshöhe werden einmal jährlich zur Auszahlung gebracht.</p>
|
||||||
|
<p>7.6. Der Provisionsanspruch entfällt rückwirkend, wenn PROFIT PLANET, Provisionen an einen Produktgeber zurückzahlen muss, etwa weil ein Kunde den Vertrag widerruft oder andere Ausschlusskriterien seitens des Produktgebers vorliegen (Stornohaftung etc.). PROFIT PLANET ist berechtigt, Forderungen, die dem PROFIT PLANET gegen den VP zustehen, mit dessen Provisionsansprüchen ganz oder teilweise aufzurechnen.</p>
|
||||||
|
<p>7.7. Mit dieser Provision sind sämtliche Tätigkeiten des VP einschließlich aller ihm in Zusammenhang mit dieser Vereinbarung entstandenen Kosten, Auslagen und Aufwendungen, wie beispielsweise Fahrt- und Reisekosten, Bürokosten, Porto und Telefongebühren, abgegolten. Dasselbe gilt für Leistungen des VP in Hinblick auf Pflege und Herstellung eines VP-Bestandes und/oder Kundenstocks, sodass im Fall der Beendigung des Vertrags unbeachtet des Grundes der Auflösung keinesfalls Ansprüche auf Abfindungen oder Ausgleiche jedweder Art gegen PROFIT PLANET bestehen.</p>
|
||||||
|
<p>7.8. Fehlerhafte Provisionszahlungen oder sonstige Zahlungen sind vom VP binnen 60 Tagen schriftlich einzumahnen. Danach gelten die Zahlungen als genehmigt.</p>
|
||||||
|
<p>7.9. Wenn vom VP keine UID-Nummer bekannt gegeben wird, erfolgen alle Auszahlungen netto.</p>
|
||||||
|
<h2>8. Vertragsstrafe, Schadenersatz</h2>
|
||||||
|
<p>8.1. Bei einem ersten Verstoß gegen die in diesem Vertrag geregelten Pflichten durch den VP erfolgt eine schriftliche Abmahnung durch PROFIT PLANET. Die Pflichtverletzung ist unmittelbar zu beenden bzw. gegebenenfalls zu beheben.</p>
|
||||||
|
<p>8.2. Kommt es erneut zu einem Verstoß gegen diesen Vertrag oder wird der zuerst gemahnte Zustand nicht beseitigt, so verpflichtet sich der VP zur Zahlung einer verschuldensunabhängigen Vertragsstrafe für jeden jeweiligen Verstoß in Höhe von EUR 5.000,00.</p>
|
||||||
|
<p>8.3. Bei Verstößen gegen die Geheimhaltungs- und Datenschutzpflichten, sowie bei besonders schwerwiegenden Verstößen, insbesondere gegen Punkt 10.2 dieses Vertrags, ist PROFIT PLANET auch ohne vorhergehende Abmahnung zur Geltendmachung der jeweiligen Vertragsstrafe berechtigt.</p>
|
||||||
|
<p>8.4. Für jede Zuwiderhandlung gegen Punkt 3.8 verpflichtet sich der VP zur Zahlung einer verschuldens- und schadensunabhängigen Konventionalstrafe an den PROFIT PLANET von EUR 5.000,00 pro Verstoß (z.B. pro an ein anderes Unternehmen oder sonstigen Dritten vermittelten Vertrags oder pro abgeworbenen Kunden). Die Geltendmachung darüber hinausgehender sonstiger Schadenersatzansprüche, der Vertragsstrafe nach 8.2 oder etwa von Erfüllungsansprüchen bleibt dadurch unberührt.</p>
|
||||||
|
<p>8.5. Für jeden Verstoß gegen die in Punkt 4. dieses Vertrags (Geheimhaltungsverpflichtung) normierten Pflichten, verpflichtet sich der VP zur Zahlung einer verschuldensunabhängigen Vertragsstrafe in Höhe von EUR 7.000,00 pro Verstoß. Die Geltendmachung weitergehender zivilrechtlicher Ansprüche – insbesondere auf Unterlassung und Schadenersatz – bleibt davon unberührt.</p>
|
||||||
|
<p>8.6. Bei Handlungen, die dem Katalog außerordentlicher Kündigungsgründe gemäß Punkt 10.2 entsprechen, insbesondere bei treuwidrigem Verhalten im Sinne der dort beschriebenen Fallgruppen (z. B. unautorisierte Kaltakquise, rufschädigendes Verhalten, unbefugtes Auftreten im Namen von PROFIT PLANET), verpflichtet sich der VP zur Zahlung einer verschuldensunabhängigen Vertragsstrafe in Höhe von EUR 10.000,00 pro Verstoß. Auch in diesen Fällen bleiben darüber hinausgehende Ansprüche – insbesondere Schadenersatz oder außerordentliche Kündigung – ausdrücklich vorbehalten.</p>
|
||||||
|
|
||||||
|
<h2>9. Haftungsausschluss</h2>
|
||||||
|
<p>9.1. Der VP führt seine Tätigkeiten nach bestem Wissen und Gewissen und in eigener Verantwortung, insbesondere auch in Bezug auf die korrekte Beratung der Endkunden aus. Eine Haftungsübernahme von PROFIT PLANET für Falschberatungen oder sonstiges Fehlverhalten des VP ist explizit ausgeschlossen.</p>
|
||||||
|
<p>9.2. Für Schäden haftet PROFIT PLANET nur, soweit diese auf Vorsatz oder grober Fahrlässigkeit oder auf grob schuldhafter Verletzung einer wesentlichen Vertragspflicht durch PROFIT PLANET, ihrer Mitarbeiter oder Erfüllungsgehilfen beruhen.</p>
|
||||||
|
<p>9.3. Eine Haftung von PROFIT PLANET für mittelbare Schäden, Folgeschäden, entgangenen Gewinn oder erwartete Ersparnis ist jedenfalls ausgeschlossen.</p>
|
||||||
|
<p>9.4. PROFIT PLANET übernimmt keine Haftung für Schäden, die durch Datenverlust auf den Servern auftreten, außer der Schaden beruht auf Vorsatz oder grober Fahrlässigkeit seitens PROFIT PLANET, ihrer Mitarbeiter oder Erfüllungsgehilfen.</p>
|
||||||
|
<p>9.5. Der Eintritt eines Schadens ist PROFIT PLANET unverzüglich mitzuteilen.</p>
|
||||||
|
<h2>10. Vertragsdauer & Kündigung</h2>
|
||||||
|
<p>10.1. Der Vertrag tritt mit Unterzeichnung oder im Fall einer Online-Registrierung, Online mit der Annahme des Vertrags durch PROFIT PLANET in Kraft und wird auf unbestimmte Zeit geschlossen. Er kann von beiden Parteien unter Einhaltung einer Frist von drei Monaten zum Ende jedes Kalendermonats schriftlich gekündigt werden.</p>
|
||||||
|
<p>10.2. Dessen ungeachtet kann der Vertrag seitens PROFIT PLANET aus wichtigem Grund ohne Einhaltung einer Kündigungsfrist gekündigt werden. Das Recht zur außerordentlichen Kündigung besteht ungeachtet weiterer Ansprüche. Folgende Gründe berechtigen insbesondere zur außerordentlichen Kündigung, die Aufzählung ist nicht abschließend:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Akte treuwidrigen Verhaltens, die eine weitere Zusammenarbeit zw. den Vertragspartnern unzumutbar machen;</li>
|
||||||
|
<li>ein solches treuwidriges Verhalten liegt insbesondere etwa dann vor, wenn ein VP ohne ausdrückliche Zustimmung eines vertretungs- und zeichnungsbefugten Organs von PROFIT PLANET Handlungen setzt, welche nach außen den Anschein erwecken, im Namen oder Auftrag von PROFIT PLANET zu erfolgen – insbesondere etwa durch Kaltakquise, Verwendung von Geschäftsdrucksorten oder -signaturen, Auftritt unter Verwendung der Marke PROFIT PLANET oder vergleichbare ruf- bzw. imageschädigende Aktivitäten.</li>
|
||||||
|
<li>die Anwendung unlauterer Praktiken oder ein grober oder wiederholter Verstoß gegen diesen Vertrag sowie der Verstoß gegen zwingende Rechtsnormen;</li>
|
||||||
|
<li>wenn über das Vermögen des jeweils anderen Vertragspartners die Einleitung eines Insolvenzverfahrens beantragt oder wenn die Eröffnung eines Insolvenzverfahrens mangels Masse abgelehnt wird;</li>
|
||||||
|
<li>Verletzung der vereinbarten oder gesetzlichen Datenschutz- oder Geheimhaltungspflichten;</li>
|
||||||
|
<li>wenn die Kooperation durch das Verhalten eines Vertragspartners oder dessen Ruf in der Öffentlichkeit den anderen Vertragspartnern einen Imageschaden zufügen würde;</li>
|
||||||
|
<li>wenn die Kooperation aufgrund der Gesetzeslage oder von dritter Seite als unzulässig untersagt wird;</li>
|
||||||
|
<li>Unzulässige Nebenabsprachen mit am Vertrieb beteiligten Dritten;</li>
|
||||||
|
</ul>
|
||||||
|
<p>10.3. Abgesehen von 10.2 kann PROFIT PLANET den VP auch außerordentlich kündigen, wenn dieser in den letzten 6 Monaten keine neuen Umsätze erzielt hat oder bei den durch seine Vermittlung zustande gekommenen Verträgen zwischen Endkunden und Produktgebern über einen Zeitraum von 2 Monaten überdurchschnittliche Stornoquoten von mehr als 30% der vermittelten Verträge bestehen. PROFIT PLANET wird den VP vor einer außerordentlichen Kündigung nach diesem Passus einmalig schriftlich verwarnen, so dass der VP die Möglichkeit hat, innerhalb einer Frist von 30 Tagen die erforderlichen neuen Umsätze zu generieren oder seine Stornoquote zu verbessern.</p>
|
||||||
|
<p>10.4. Mit der Beendigung des Vertrags steht dem VP mit Ausnahme der Provision für zu diesem Zeitpunkt bereits erfolgreich vermittelte Verträge, kein Recht auf Provision mehr zu. Ein Anspruch auf Handelsvertreterausgleich ist ausdrücklich ausgeschlossen, da der VP nicht als Handelsvertreter für den PROFIT PLANET tätig wird. Etwaige Ansprüche auf Folgeprovisionen für vermittelte Produkte bestehen für 12 Monate nach Vertragsbeendigung fort; im Falle einer außerordentlichen Kündigung verfallen Ansprüche auf Folgeprovisionen unmittelbar mit der Vertragsbeendigung.</p>
|
||||||
|
<p>10.5. Nach Beendigung des Vertrags sind vom VP sämtliche überlassenen Unterlagen und Werbematerialien unaufgefordert binnen einem Monat an PROFIT PLANET zurückzugeben. Die Verwendung der Marke PROFIT PLANET und entsprechender Logos etwa auf Briefpapier oder in E-Mail-Signaturen ist nach Beendigung des Vertrags untersagt.</p>
|
||||||
|
<h2>11. Übertragung</h2>
|
||||||
|
<p>11.1. PROFIT PLANET ist jederzeit berechtigt, den Geschäftsbetrieb ganz oder teilweise auf Dritte zu übertragen.</p>
|
||||||
|
<p>11.2. Der VP ist nur mit ausdrücklicher Zustimmung von PROFIT PLANET berechtigt, seine Vertriebsstruktur an einen Dritten zu übertragen.</p>
|
||||||
|
<p>11.3. Wenn eine als VP registrierte Kapital- oder Personengesellschaft einen neuen Gesellschafter aufnimmt, hat dies auf diesen Vertrag keine Auswirkung, sofern der/die Gesellschafter, die den VP-Antrag ursprünglich unterzeichnet haben, als Gesellschafter in der Gesellschaft verbleiben. Wenn ein Gesellschafter aus einer registrierten Gesellschaft ausscheidet oder seine Anteile an einen Dritten überträgt, so ist dies in Bezug auf diesen Vertrag zulässig, sofern er dies PROFIT PLANET schriftlich unter Vorlage der entsprechenden rechtsgültigen Urkunden anzeigt, und der Vorgang keinen anderen Bestimmungen dieses Vertrags widerspricht; anderenfalls behält PROFIT PLANET sich das Recht vor, den VP-Vertrag der betreffenden Kapital- oder Personengesellschaft aufzukündigen.</p>
|
||||||
|
<p>11.4. Bei Auflösung einer als VP registrierten Gemeinschaft (Kapital- oder Personengesellschaft, aber auch z.B. Ehepartnerschaften oder ähnliches, die einen gemeinsamen VP-Vertrag haben), bleibt nur ein VP-Vertrag bestehen. Die Mitglieder der aufzulösenden Gemeinschaft haben sich intern zu einigen, durch welches Mitglied/Gesellschafter die Vertriebspartner / Businesspartner / Affiliateschaft fortgesetzt werden soll, und dies PROFIT PLANET schriftlich anzuzeigen. Falls sich die Mitglieder der Gemeinschaft in Bezug auf die Fortsetzung des VP-vertrags nicht gütlich einigen können, behält sich PROFIT PLANET das Recht einer außerordentlichen Kündigung vor, insbesondere, wenn es durch die Uneinigkeit über die Folgen zur Vernachlässigung der Pflichten des VP, einem Verstoß gegen diesen Vertrag oder geltendes Recht oder zu einer übermäßigen Belastung der Vertriebsstruktur des VP kommt.</p>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header"></div>
|
||||||
|
<h2>12. Schlussbestimmungen</h2>
|
||||||
|
<p>12.1. Änderungen und Ergänzungen dieser Vereinbarung bedürfen der Schriftform. Dies gilt auch für das Abgehen der Schriftformerfordernis. Mündliche Nebenabreden bestehen nicht.</p>
|
||||||
|
<p>12.2. Sollte eine Bestimmung dieser Vereinbarung unwirksam sein oder werden, gilt anstelle der unwirksamen Bestimmung jene Bestimmung als vereinbart, die dem wirtschaftlichen Zweck der unwirksamen Bestimmung am nächsten kommt.</p>
|
||||||
|
<p>12.3. Vereinbarter Gerichtsstand für alle Streitigkeiten aus oder in Zusammenhang mit dieser Vereinbarung ist das für Graz sachlich zuständige Gericht. Diese Vereinbarung unterliegt österreichischem Recht, nicht jedoch den nichtzwingenden Verweisungsnormen des IPR. Weiter- bzw. Rückverweisungen sind ausgeschlossen. Darüber hinaus steht es PROFIT PLANET frei, den VP auch seinem allgemeinen Gerichtsstand zu klagen.</p>
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="signature" style="flex:1;text-align:center;">
|
||||||
|
<p style="margin:0 0 6px;">Für PROFIT PLANET (Auftraggeber)</p>
|
||||||
|
<!-- CHANGED: wrap stamp + date in a flex column with spacing to avoid overlap -->
|
||||||
|
<div class="sig-block" style="display:flex;flex-direction:column;align-items:center;gap:6px;min-height:140px;">
|
||||||
|
<div class="pp-stamp" style="display:block;max-width:220px;margin:0 auto 6px;">{{profitplanetSignature}}</div>
|
||||||
|
<div class="sig-date" style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature">
|
||||||
|
<p style="margin:0 0 6px;">Für den VP (Auftragnehmer)</p>
|
||||||
|
<div class="sig-block">
|
||||||
|
<span class="sig-image">{{signatureImage}}</span>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{fullName}}</div>
|
||||||
|
<div style="font-size:0.75em;line-height:1.2;">{{currentDate}}</div>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0 0 6px;">Name, Datum, Unterschrift</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
utils/exoscaleUploader.js
Normal file
80
utils/exoscaleUploader.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { logger } = require('../middleware/logger');
|
||||||
|
|
||||||
|
const exoscaleClient = new S3Client({
|
||||||
|
region: process.env.EXOSCALE_REGION,
|
||||||
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.EXOSCALE_ACCESS_KEY,
|
||||||
|
secretAccessKey: process.env.EXOSCALE_SECRET_KEY,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const EXOSCALE_BUCKET = process.env.EXOSCALE_BUCKET;
|
||||||
|
|
||||||
|
async function uploadBuffer(buffer, originalName, mimeType, folder = 'user-uploads') {
|
||||||
|
logger.info('exoscaleUploader.uploadBuffer:start', { originalName, mimeType, folder });
|
||||||
|
try {
|
||||||
|
const ext = path.extname(originalName);
|
||||||
|
const key = `${folder}/${uuidv4()}${ext}`;
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: EXOSCALE_BUCKET,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: mimeType,
|
||||||
|
ACL: 'private',
|
||||||
|
});
|
||||||
|
|
||||||
|
await exoscaleClient.send(command);
|
||||||
|
|
||||||
|
logger.info('exoscaleUploader.uploadBuffer:success', { key, mimeType });
|
||||||
|
return {
|
||||||
|
objectKey: key,
|
||||||
|
url: `${process.env.EXOSCALE_ENDPOINT}/${EXOSCALE_BUCKET}/${key}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('exoscaleUploader.uploadBuffer:error', { originalName, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(filePath, originalName, mimeType, folder = 'user-uploads') {
|
||||||
|
logger.info('exoscaleUploader.uploadFile:start', { filePath, originalName, mimeType, folder });
|
||||||
|
try {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
const result = await uploadBuffer(buffer, originalName, mimeType, folder);
|
||||||
|
logger.info('exoscaleUploader.uploadFile:success', { filePath, key: result.objectKey });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('exoscaleUploader.uploadFile:error', { filePath, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteObject(objectKey) {
|
||||||
|
logger.info('exoscaleUploader.deleteObject:start', { objectKey });
|
||||||
|
try {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: EXOSCALE_BUCKET,
|
||||||
|
Key: objectKey,
|
||||||
|
});
|
||||||
|
await exoscaleClient.send(command);
|
||||||
|
logger.info('exoscaleUploader.deleteObject:success', { objectKey });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('exoscaleUploader.deleteObject:error', { objectKey, error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
uploadBuffer,
|
||||||
|
uploadFile,
|
||||||
|
s3: exoscaleClient, // Export the S3 client
|
||||||
|
deleteObject, // Export delete helper
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user