feat: profile backend link | NOT DONE

This commit is contained in:
DeathKaioken 2025-11-18 01:21:09 +01:00
parent b424e90e08
commit 20f69c272c
6 changed files with 532 additions and 193 deletions

View File

@ -3,6 +3,27 @@ const PersonalProfileService = require('../../services/profile/personal/Personal
const PersonalUserRepository = require('../../repositories/user/personal/PersonalUserRepository'); const PersonalUserRepository = require('../../repositories/user/personal/PersonalUserRepository');
const { logger } = require('../../middleware/logger'); const { logger } = require('../../middleware/logger');
// helpers
const MAX = {
name: 100,
email: 255,
phone: 32,
address: 255,
accountHolder: 140,
iban: 34
};
const trim = v => (typeof v === 'string' ? v.trim() : v);
const isEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
const isPhone = v => v ? /^\+?[1-9]\d{6,14}$/.test(v) : true;
const normalizeIban = v => (v || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); // sanitize only, no validation
const truncate = (s, n) => (typeof s === 'string' && s.length > n ? s.slice(0, n) : s);
const maskIban = v => {
const s = normalizeIban(v || '');
if (!s) return null;
const last4 = s.slice(-4);
return `**** **** **** **** **** **** **** ${last4}`.replace(/\s{2,}/g, ' ');
};
class PersonalProfileController { class PersonalProfileController {
static async completeProfile(req, res) { static async completeProfile(req, res) {
const userId = req.user.userId; const userId = req.user.userId;
@ -22,6 +43,201 @@ class PersonalProfileController {
res.status(400).json({ success: false, message: error.message }); res.status(400).json({ success: false, message: error.message });
} }
} }
static async updateBasic(req, res) {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthenticated' });
res.setHeader('X-Controller', 'PersonalProfileController:updateBasic'); // prove controller is hit
// prefer camelCase, then snake_case, then infer from route namespace
const inferredFromRoute = req.baseUrl?.includes('/profile/personal') || req.originalUrl?.includes('/profile/personal') ? 'personal' : undefined;
const userType = req.user.userType ?? req.user.user_type ?? inferredFromRoute;
const authDebugBasic = {
derivedUserType: userType,
reqUserKeys: Object.keys(req.user || {}),
id: req.user?.id,
userId: req.user?.userId
};
logger.info(`PersonalProfileController:updateBasic:auth userType=${userType} keys=${authDebugBasic.reqUserKeys.join(',')}`);
console.log('[PersonalProfileController:updateBasic] auth', authDebugBasic);
if (userType !== 'personal') {
const message = 'Personal user required';
const debug = process.env.NODE_ENV !== 'production'
? { expected: 'personal', got: userType, reqUserKeys: authDebugBasic.reqUserKeys }
: undefined;
return res.status(400).json({ success: false, message, ...(debug && { debug }) });
}
const userId = req.user.id || req.user.userId;
// Only take fields that are provided and non-empty after trim
let { firstName, lastName, email, phone, address } = req.body || {};
const updates = {};
if (firstName !== undefined) {
const v = truncate(trim(firstName), MAX.name);
if (v !== '') updates.firstName = v;
}
if (lastName !== undefined) {
const v = truncate(trim(lastName), MAX.name);
if (v !== '') updates.lastName = v;
}
if (email !== undefined) {
const v = truncate(trim(email), MAX.email);
if (v !== '') updates.email = v;
}
if (phone !== undefined) {
const v = truncate(trim(phone), MAX.phone);
if (v !== '') updates.phone = v;
}
if (address !== undefined) {
const v = truncate(trim(address), MAX.address);
if (v !== '') updates.address = v;
}
// Validate only provided non-empty fields
if (updates.email && !isEmail(updates.email)) {
return res.status(400).json({ success: false, message: 'Invalid email' });
}
if (updates.phone && !isPhone(updates.phone)) {
return res.status(400).json({ success: false, message: 'Invalid phone' });
}
const unitOfWork = new UnitOfWork();
await unitOfWork.start();
logger.info('PersonalProfileController:updateBasic:start', { userId });
try {
const updated = await PersonalProfileService.updateBasic(userId, updates, unitOfWork);
await unitOfWork.commit();
if (!updated) return res.status(404).json({ success: false, message: 'User not found' });
const response = { ...updated, iban: maskIban(updated.iban) };
logger.info('PersonalProfileController:updateBasic:success', { userId });
return res.status(200).json({ success: true, profile: response });
} catch (err) {
await unitOfWork.rollback(err);
// add explicit debug for missing user_id column
if (err?.message?.includes("Unknown column 'user_id'")) {
logger.error('PersonalProfileController:updateBasic:schemaMismatch', {
userId,
error: err.message,
sql: err.sql,
sqlState: err.sqlState,
code: err.code
});
res.setHeader('X-Error-Code', 'SCHEMA_MISMATCH_USER_ID');
return res.status(500).json({
success: false,
message: 'Database schema mismatch: column user_id is missing.',
debug: process.env.NODE_ENV !== 'production' ? {
hint: 'Update the table to include user_id or change the repository SQL to use the correct column name.',
repoFile: 'repositories/user/personal/PersonalUserRepository.js',
repoLine: 302
} : undefined
});
}
logger.error('PersonalProfileController:updateBasic:error', { userId, error: err.message });
if (err.code === 'EMAIL_CONFLICT') {
return res.status(409).json({ success: false, message: 'Email already in use' });
}
return res.status(400).json({ success: false, message: err.message || 'Invalid input' });
}
}
static async updateBank(req, res) {
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthenticated' });
res.setHeader('X-Controller', 'PersonalProfileController:updateBank'); // prove controller is hit
// prefer camelCase, then snake_case, then infer from route namespace
const inferredFromRoute = req.baseUrl?.includes('/profile/personal') || req.originalUrl?.includes('/profile/personal') ? 'personal' : undefined;
const userType = req.user.userType ?? req.user.user_type ?? inferredFromRoute;
const authDebugBank = {
derivedUserType: userType,
reqUserKeys: Object.keys(req.user || {}),
id: req.user?.id,
userId: req.user?.userId
};
logger.info(`PersonalProfileController:updateBank:auth userType=${userType} keys=${authDebugBank.reqUserKeys.join(',')}`);
console.log('[PersonalProfileController:updateBank] auth', authDebugBank);
if (userType !== 'personal') {
const message = 'Personal user required';
const debug = process.env.NODE_ENV !== 'production'
? { expected: 'personal', got: userType, reqUserKeys: authDebugBank.reqUserKeys }
: undefined;
return res.status(400).json({ success: false, message, ...(debug && { debug }) });
}
const userId = req.user.id || req.user.userId;
let { accountHolder, iban } = req.body || {};
const payload = {};
// IBAN debug (pre-sanitize)
const rawIban = iban ?? '';
const normalizedIban = truncate(normalizeIban(rawIban), MAX.iban);
// Log what we received vs what we will use
const ibanDebug = {
provided: iban !== undefined,
raw: rawIban,
rawLength: String(rawIban).length,
normalized: normalizedIban,
normalizedMasked: maskIban(normalizedIban),
normalizedLength: normalizedIban.length,
truncatedTo: MAX.iban
};
logger.info('PersonalProfileController:updateBank:iban_debug', { userId, ...ibanDebug });
console.log('[PersonalProfileController:updateBank] iban_debug', ibanDebug);
// Small headers for quick check in browser Network panel (avoid large payload)
res.setHeader('X-IBAN-Len', String(ibanDebug.normalizedLength));
if (ibanDebug.normalizedMasked) res.setHeader('X-IBAN-Masked', ibanDebug.normalizedMasked);
if (accountHolder !== undefined) {
const v = truncate(trim(accountHolder), MAX.accountHolder);
if (v !== '') payload.accountHolderName = v;
}
if (iban !== undefined) {
// sanitize only; do not validate IBAN format
if (normalizedIban !== '') {
payload.iban = normalizedIban;
}
}
// Log final payload forwarded to service (mask IBAN)
const payloadLog = {
...payload,
...(payload.iban ? { iban: maskIban(payload.iban) } : {})
};
logger.info('PersonalProfileController:updateBank:payload', { userId, payload: payloadLog });
console.log('[PersonalProfileController:updateBank] payload', payloadLog);
const unitOfWork = new UnitOfWork();
await unitOfWork.start();
logger.info('PersonalProfileController:updateBank:start', { userId });
try {
const updated = await PersonalProfileService.updateBank(userId, payload, unitOfWork);
await unitOfWork.commit();
if (!updated) return res.status(404).json({ success: false, message: 'User not found' });
const response = { ...updated, iban: maskIban(updated.iban) };
logger.info('PersonalProfileController:updateBank:success', { userId, responsePreview: { iban: response.iban, accountHolderName: response.accountHolderName } });
return res.status(200).json({ success: true, profile: response });
} catch (err) {
await unitOfWork.rollback(err);
logger.error('PersonalProfileController:updateBank:error', { userId, error: err.message });
res.setHeader('X-IBAN-Error', 'controller_catch');
return res.status(400).json({ success: false, message: err.message || 'Invalid input' });
}
}
} }
module.exports = PersonalProfileController; module.exports = PersonalProfileController;

View File

@ -126,19 +126,19 @@ async function createDatabase() {
nationality VARCHAR(255), nationality VARCHAR(255),
address TEXT, address TEXT,
zip_code VARCHAR(20), zip_code VARCHAR(20),
city VARCHAR(100), -- Added city column city VARCHAR(100),
country VARCHAR(100), country VARCHAR(100),
phone_secondary VARCHAR(255), phone_secondary VARCHAR(255),
emergency_contact_name VARCHAR(255), emergency_contact_name VARCHAR(255),
emergency_contact_phone VARCHAR(255), emergency_contact_phone VARCHAR(255),
account_holder_name VARCHAR(255), -- Added column account_holder_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
UNIQUE KEY unique_user_profile (user_id) UNIQUE KEY unique_user_profile (user_id)
); );
`); `);
console.log('✅ Personal profiles table updated'); console.log('✅ Personal profiles table created/verified');
// 3. company_profiles table: Details specific to company users // 3. company_profiles table: Details specific to company users
await connection.query(` await connection.query(`
@ -146,18 +146,18 @@ async function createDatabase() {
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
company_name VARCHAR(255) NOT NULL, company_name VARCHAR(255) NOT NULL,
registration_number VARCHAR(255) UNIQUE, -- allow NULL registration_number VARCHAR(255) UNIQUE,
phone VARCHAR(255) NULL, phone VARCHAR(255) NULL,
address TEXT, address TEXT,
zip_code VARCHAR(20), zip_code VARCHAR(20),
city VARCHAR(100), city VARCHAR(100),
country VARCHAR(100), -- Added country column after city country VARCHAR(100),
branch VARCHAR(255), branch VARCHAR(255),
number_of_employees INT, number_of_employees INT,
business_type VARCHAR(255), business_type VARCHAR(255),
contact_person_name VARCHAR(255), contact_person_name VARCHAR(255),
contact_person_phone VARCHAR(255), contact_person_phone VARCHAR(255),
account_holder_name VARCHAR(255), -- Added column account_holder_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
@ -165,24 +165,7 @@ async function createDatabase() {
UNIQUE KEY unique_registration_number (registration_number) UNIQUE KEY unique_registration_number (registration_number)
); );
`); `);
console.log('✅ Company profiles table updated'); console.log('✅ Company profiles table created/verified');
// 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 // 4. user_status table: Comprehensive tracking of user verification and completion steps
await connection.query(` await connection.query(`
@ -310,37 +293,15 @@ async function createDatabase() {
storageKey VARCHAR(255) NOT NULL, storageKey VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
lang VARCHAR(10) NOT NULL, lang VARCHAR(10) NOT NULL,
user_type ENUM('personal','company','both') DEFAULT 'both', -- NEW COLUMN user_type ENUM('personal','company','both') DEFAULT 'both',
version INT DEFAULT 1, version INT DEFAULT 1,
state ENUM('active','inactive') DEFAULT 'inactive', -- Added state column state ENUM('active','inactive') DEFAULT 'inactive',
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
); );
`); `);
console.log('✅ Document templates table created/verified'); 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) // 8b. user_id_documents table: Stores ID-specific metadata (front/back object storage IDs)
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS user_id_documents ( CREATE TABLE IF NOT EXISTS user_id_documents (
@ -349,8 +310,8 @@ async function createDatabase() {
document_type ENUM('personal_id', 'company_id') NOT NULL, document_type ENUM('personal_id', 'company_id') NOT NULL,
front_object_storage_id VARCHAR(255) NOT NULL, front_object_storage_id VARCHAR(255) NOT NULL,
back_object_storage_id VARCHAR(255) NULL, back_object_storage_id VARCHAR(255) NULL,
original_filename_front VARCHAR(255), -- NEW COLUMN original_filename_front VARCHAR(255),
original_filename_back VARCHAR(255), -- NEW COLUMN original_filename_back VARCHAR(255),
id_type VARCHAR(50), id_type VARCHAR(50),
id_number VARCHAR(100), id_number VARCHAR(100),
expiry_date DATE, expiry_date DATE,
@ -364,6 +325,7 @@ async function createDatabase() {
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS user_action_logs ( CREATE TABLE IF NOT EXISTS user_action_logs (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
affected_user_id INT NULL, affected_user_id INT NULL,
action VARCHAR(100) NOT NULL, action VARCHAR(100) NOT NULL,
performed_by_user_id INT NULL, performed_by_user_id INT NULL,
@ -371,8 +333,10 @@ async function createDatabase() {
ip_address VARCHAR(45), ip_address VARCHAR(45),
user_agent TEXT, user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, -- NEW FK
FOREIGN KEY (affected_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE, 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, FOREIGN KEY (performed_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
INDEX idx_user_id (user_id), -- NEW index
INDEX idx_affected_user (affected_user_id), INDEX idx_affected_user (affected_user_id),
INDEX idx_performed_by (performed_by_user_id), INDEX idx_performed_by (performed_by_user_id),
INDEX idx_action (action), INDEX idx_action (action),
@ -381,6 +345,57 @@ async function createDatabase() {
`); `);
console.log('✅ User action logs table created/verified'); console.log('✅ User action logs table created/verified');
// --- Add missing user_id column for existing databases + backfill ---
try {
// Add column if missing
await connection.query(`ALTER TABLE user_action_logs ADD COLUMN user_id INT NULL`);
console.log('🆕 Added user_action_logs.user_id column');
// Add FK if just added
try {
await connection.query(`
ALTER TABLE user_action_logs
ADD CONSTRAINT fk_user_action_logs_user
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
`);
console.log('🆕 Added FK fk_user_action_logs_user');
} catch (e) {
console.log(' FK fk_user_action_logs_user already exists or cannot add:', e.message);
}
// Add index if missing
try {
await connection.query(`CREATE INDEX idx_user_id ON user_action_logs (user_id)`);
console.log('🆕 Added index idx_user_id on user_action_logs.user_id');
} catch (e) {
console.log(' idx_user_id already exists or cannot add:', e.message);
}
// Backfill: prefer performed_by_user_id, else affected_user_id
try {
const [res1] = await connection.query(`
UPDATE user_action_logs
SET user_id = performed_by_user_id
WHERE user_id IS NULL AND performed_by_user_id IS NOT NULL
`);
const [res2] = await connection.query(`
UPDATE user_action_logs
SET user_id = affected_user_id
WHERE user_id IS NULL AND affected_user_id IS NOT NULL
`);
console.log('🧹 Backfilled user_action_logs.user_id from performed_by_user_id/affected_user_id');
} catch (e) {
console.warn('⚠️ Could not backfill user_action_logs.user_id:', e.message);
}
} catch (e) {
// Column may already exist; ignore
if (!/Duplicate column name|exists/i.test(e.message)) {
console.log(' user_action_logs.user_id add skipped or not required:', e.message);
} else {
console.log(' user_action_logs.user_id already exists');
}
}
// --- Email & Registration Flow Tables --- // --- Email & Registration Flow Tables ---
// 10. email_attempts table: For tracking email sending attempts // 10. email_attempts table: For tracking email sending attempts
@ -423,43 +438,6 @@ async function createDatabase() {
`); `);
console.log('✅ Referral tokens table created/verified'); 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 // 13. referral_token_usage table: Tracks each use of a referral token
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS referral_token_usage ( CREATE TABLE IF NOT EXISTS referral_token_usage (
@ -586,14 +564,6 @@ async function createDatabase() {
`); `);
console.log('✅ Coffee table created/verified'); console.log('✅ Coffee table created/verified');
// Ensure quantity column is removed for existing databases
try {
await connection.query(`ALTER TABLE coffee_table DROP COLUMN IF EXISTS quantity`);
console.log('🔧 Removed coffee_table.quantity');
} catch (e) {
console.log(' coffee_table.quantity already removed or ALTER not required');
}
// --- Matrix: Global 5-ary tree config and relations --- // --- Matrix: Global 5-ary tree config and relations ---
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS matrix_config ( CREATE TABLE IF NOT EXISTS matrix_config (
@ -654,103 +624,6 @@ async function createDatabase() {
`); `);
console.log('✅ User matrix metadata table created/verified'); console.log('✅ User matrix metadata table created/verified');
// Ensure new columns exist if table already existed
try {
await connection.query(`ALTER TABLE user_matrix_metadata ADD COLUMN name VARCHAR(255) NULL`);
console.log('🔧 Ensured user_matrix_metadata.name exists');
} catch (e) {
console.log(' user_matrix_metadata.name already exists or ALTER not required');
}
try {
await connection.query(`ALTER TABLE user_matrix_metadata ADD COLUMN is_active BOOLEAN DEFAULT TRUE`);
console.log('🔧 Ensured user_matrix_metadata.is_active exists');
} catch (e) {
console.log(' user_matrix_metadata.is_active already exists or ALTER not required');
}
// NEW: ensure max_depth column exists
try {
await connection.query(`ALTER TABLE user_matrix_metadata ADD COLUMN max_depth INT NULL`);
console.log('🔧 Ensured user_matrix_metadata.max_depth exists');
} catch (e) {
console.log(' user_matrix_metadata.max_depth already exists or ALTER not required');
}
// NEW: backfill max_depth policy
try {
// Master top node gets unlimited depth (NULL)
await connection.query(`
UPDATE user_matrix_metadata
SET max_depth = NULL
WHERE root_user_id IN (SELECT master_top_user_id FROM matrix_config)
`);
// All other matrices default to depth 5 where NULL
await connection.query(`
UPDATE user_matrix_metadata
SET max_depth = 5
WHERE max_depth IS NULL
AND root_user_id NOT IN (SELECT master_top_user_id FROM matrix_config)
`);
console.log('🧹 Backfilled user_matrix_metadata.max_depth (master=NULL, others=5)');
} catch (e) {
console.warn('⚠️ Could not backfill user_matrix_metadata.max_depth:', e.message);
}
// --- 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');
// Coffee products
await ensureIndex(connection, 'coffee_table', 'idx_coffee_state', 'state');
await ensureIndex(connection, 'coffee_table', 'idx_coffee_updated_at', 'updated_at');
await ensureIndex(connection, 'coffee_table', 'idx_coffee_billing', 'billing_interval, interval_count');
await ensureIndex(connection, 'coffee_table', 'idx_coffee_sku', 'sku');
// Matrix indexes
await ensureIndex(connection, 'user_tree_edges', 'idx_user_tree_edges_parent', 'parent_user_id');
// child_user_id already has a UNIQUE constraint; extra index not needed
await ensureIndex(connection, 'user_tree_closure', 'idx_user_tree_closure_ancestor_depth', 'ancestor_user_id, depth');
await ensureIndex(connection, 'user_tree_closure', 'idx_user_tree_closure_descendant', 'descendant_user_id');
await ensureIndex(connection, 'user_matrix_metadata', 'idx_user_matrix_is_active', 'is_active'); // NEW
await ensureIndex(connection, 'user_matrix_metadata', 'idx_user_matrix_max_depth', 'max_depth'); // NEW
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!'); console.log('🎉 Normalized database schema created/updated successfully!');
await connection.end(); await connection.end();

View File

@ -1,4 +1,5 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { logger } = require('./logger');
function authMiddleware(req, res, next) { function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@ -9,9 +10,43 @@ function authMiddleware(req, res, next) {
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
try { try {
const payload = jwt.verify(token, process.env.JWT_SECRET); const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload; // Attach user info to request
// edit profile context awareness
const isEditProfile =
req.originalUrl?.includes('/profile/personal') ||
req.baseUrl?.includes('/profile/personal');
// derive and log user type data
const derivedUserType = payload.userType ?? payload.user_type ?? payload.type;
const authDebug = {
context: isEditProfile ? 'edit-profile' : 'general',
method: req.method,
route: req.originalUrl,
id: payload.id ?? payload.userId ?? payload.sub,
email: payload.email,
userType: payload.userType,
user_type: payload.user_type,
derivedUserType,
payloadKeys: Object.keys(payload || {})
};
logger.info(`authMiddleware:verified context=${authDebug.context} userType=${authDebug.derivedUserType}`, authDebug);
// console fallback for local dev
console.log('[authMiddleware] verified', authDebug);
// Attach user info to request (with normalized userType for downstream checks)
req.user = {
...payload,
userType: payload.userType ?? payload.user_type,
user_type: payload.user_type ?? payload.userType
};
next(); next();
} catch (error) { } catch (error) {
logger.warn('authMiddleware:tokenInvalid', {
method: req.method,
route: req.originalUrl,
reason: error?.message
});
return res.status(401).json({ success: false, message: 'Invalid or expired access token' }); return res.status(401).json({ success: false, message: 'Invalid or expired access token' });
} }
} }

View File

@ -185,6 +185,196 @@ class PersonalUserRepository {
throw error; throw error;
} }
} }
// Returns merged profile shape used by "GET /api/me"-like responses
async getMergedProfileById(userId) {
const conn = this.unitOfWork.connection;
const [rows] = await conn.query(
`
SELECT
u.id, u.email, u.user_type, u.role, u.iban, u.created_at, u.updated_at,
pp.first_name, pp.last_name, pp.phone, pp.address, pp.date_of_birth, pp.account_holder_name
FROM users u
LEFT JOIN personal_profiles pp ON u.id = pp.user_id
WHERE u.id = ?
`,
[userId]
);
if (!rows.length) return null;
const r = rows[0];
return {
id: r.id,
email: r.email,
user_type: r.user_type,
role: r.role,
firstName: r.first_name,
lastName: r.last_name,
phone: r.phone,
address: r.address,
dateOfBirth: r.date_of_birth,
accountHolderName: r.account_holder_name,
iban: r.iban,
createdAt: r.created_at,
updatedAt: r.updated_at
};
}
async updateBasicInfo(userId, payload) {
logger.info('PersonalUserRepository.updateBasicInfo:start', { userId });
const conn = this.unitOfWork.connection;
// Lock current rows to compute diffs safely
const [[currentUser]] = await conn.query(
`SELECT id, email FROM users WHERE id = ? FOR UPDATE`,
[userId]
);
const [[currentProfile]] = await conn.query(
`SELECT first_name, last_name, phone, address FROM personal_profiles WHERE user_id = ? FOR UPDATE`,
[userId]
);
if (!currentUser) {
logger.warn('PersonalUserRepository.updateBasicInfo:not_found', { userId });
return null;
}
const { firstName, lastName, email, phone, address } = payload;
// Compute diffs
const changes = {};
const setProfile = {};
const setUser = {};
if (email !== undefined && email !== currentUser.email) {
// Uniqueness check
const [conflict] = await conn.query(
`SELECT id FROM users WHERE email = ? AND id <> ? LIMIT 1`,
[email, userId]
);
if (conflict.length) {
const err = new Error('Email already in use');
err.code = 'EMAIL_CONFLICT';
throw err;
}
setUser.email = email;
changes.email = { from: currentUser.email, to: email };
}
if (firstName !== undefined && firstName !== currentProfile?.first_name) {
setProfile.first_name = firstName;
changes.firstName = { from: currentProfile?.first_name ?? null, to: firstName };
}
if (lastName !== undefined && lastName !== currentProfile?.last_name) {
setProfile.last_name = lastName;
changes.lastName = { from: currentProfile?.last_name ?? null, to: lastName };
}
if (phone !== undefined && phone !== currentProfile?.phone) {
setProfile.phone = phone;
changes.phone = { from: currentProfile?.phone ?? null, to: phone };
}
if (address !== undefined && address !== currentProfile?.address) {
setProfile.address = address;
changes.address = { from: currentProfile?.address ?? null, to: address };
}
// Persist updates
if (Object.keys(setUser).length) {
await conn.query(
`UPDATE users SET ${Object.keys(setUser).map(k => `${k} = ?`).join(', ')} WHERE id = ?`,
[...Object.values(setUser), userId]
);
// Email changed: reset verification flags
if (setUser.email) {
await conn.query(
`UPDATE user_status SET email_verified = 0, email_verified_at = NULL WHERE user_id = ?`,
[userId]
);
}
}
if (Object.keys(setProfile).length) {
await conn.query(
`UPDATE personal_profiles SET ${Object.keys(setProfile).map(k => `${k} = ?`).join(', ')} WHERE user_id = ?`,
[...Object.values(setProfile), userId]
);
}
// Audit log
if (Object.keys(changes).length) {
await conn.query(
`INSERT INTO user_action_logs (user_id, action, details) VALUES (?, 'profile_update_basic', ?)`,
[userId, JSON.stringify({ changes })]
);
}
logger.info('PersonalUserRepository.updateBasicInfo:success', { userId, changedKeys: Object.keys(changes) });
return this.getMergedProfileById(userId);
}
async updateBankInfo(userId, payload) {
logger.info('PersonalUserRepository.updateBankInfo:start', { userId, payloadPreview: { ...payload, ...(payload?.iban ? { iban: `****${String(payload.iban).slice(-4)}` } : {}) } });
const conn = this.unitOfWork.connection;
// Lock current rows to compute diffs safely
const [[currentUser]] = await conn.query(
`SELECT id, iban FROM users WHERE id = ? FOR UPDATE`,
[userId]
);
const [[currentProfile]] = await conn.query(
`SELECT account_holder_name FROM personal_profiles WHERE user_id = ? FOR UPDATE`,
[userId]
);
if (!currentUser) {
logger.warn('PersonalUserRepository.updateBankInfo:not_found', { userId });
return null;
}
const { accountHolderName, iban } = payload;
const changes = {};
const setUser = {};
const setProfile = {};
if (iban !== undefined && iban !== currentUser.iban) {
setUser.iban = iban;
changes.iban = { from: currentUser.iban ? `****${String(currentUser.iban).slice(-4)}` : null, to: `****${String(iban).slice(-4)}` };
}
if (accountHolderName !== undefined && accountHolderName !== currentProfile?.account_holder_name) {
setProfile.account_holder_name = accountHolderName;
changes.accountHolderName = {
from: currentProfile?.account_holder_name ?? null,
to: accountHolderName
};
}
logger.info('PersonalUserRepository.updateBankInfo:diff', {
userId,
willUpdateUsers: Object.keys(setUser),
willUpdateProfile: Object.keys(setProfile),
changes
});
if (Object.keys(setUser).length) {
await conn.query(
`UPDATE users SET ${Object.keys(setUser).map(k => `${k} = ?`).join(', ')} WHERE id = ?`,
[...Object.values(setUser), userId]
);
}
if (Object.keys(setProfile).length) {
await conn.query(
`UPDATE personal_profiles SET ${Object.keys(setProfile).map(k => `${k} = ?`).join(', ')} WHERE user_id = ?`,
[...Object.values(setProfile), userId]
);
}
if (Object.keys(changes).length) {
await conn.query(
`INSERT INTO user_action_logs (user_id, action, details) VALUES (?, 'profile_update_bank', ?)`,
[userId, JSON.stringify({ changes })]
);
}
logger.info('PersonalUserRepository.updateBankInfo:success', { userId, changedKeys: Object.keys(changes) });
return this.getMergedProfileById(userId);
}
} }
module.exports = PersonalUserRepository; module.exports = PersonalUserRepository;

View File

@ -6,6 +6,7 @@ const DocumentTemplateController = require('../controller/documentTemplate/Docum
const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added
const CoffeeController = require('../controller/admin/CoffeeController'); const CoffeeController = require('../controller/admin/CoffeeController');
const AdminUserController = require('../controller/admin/AdminUserController'); const AdminUserController = require('../controller/admin/AdminUserController');
const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add
// Helper middlewares for company-stamp // Helper middlewares for company-stamp
function adminOnly(req, res, next) { function adminOnly(req, res, next) {
@ -36,6 +37,10 @@ router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUs
// Admin: set state for coffee product // Admin: set state for coffee product
router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState); router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState);
// Personal profile (self-service) - no admin guard
router.patch('/profile/personal/basic', authMiddleware, PersonalProfileController.updateBasic);
router.patch('/profile/personal/bank', authMiddleware, PersonalProfileController.updateBank);
// Add other PATCH routes here as needed // Add other PATCH routes here as needed
module.exports = router; module.exports = router;

View File

@ -20,6 +20,26 @@ class PersonalProfileService {
throw error; throw error;
} }
} }
static async updateBasic(userId, payload, unitOfWork) {
logger.info('PersonalProfileService.updateBasic:start', { userId });
const repo = new PersonalUserRepository(unitOfWork);
const updated = await repo.updateBasicInfo(userId, payload);
const UserStatusService = require('../../status/UserStatusService');
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
logger.info('PersonalProfileService.updateBasic:success', { userId });
return updated;
}
static async updateBank(userId, payload, unitOfWork) {
logger.info('PersonalProfileService.updateBank:start', { userId });
const repo = new PersonalUserRepository(unitOfWork);
const updated = await repo.updateBankInfo(userId, payload);
const UserStatusService = require('../../status/UserStatusService');
await UserStatusService.checkAndSetPendingIfComplete(userId, unitOfWork);
logger.info('PersonalProfileService.updateBank:success', { userId });
return updated;
}
} }
module.exports = PersonalProfileService; module.exports = PersonalProfileService;