diff --git a/controller/profile/PersonalProfileController.js b/controller/profile/PersonalProfileController.js index cf7603e..3c1f9ee 100644 --- a/controller/profile/PersonalProfileController.js +++ b/controller/profile/PersonalProfileController.js @@ -3,6 +3,27 @@ const PersonalProfileService = require('../../services/profile/personal/Personal const PersonalUserRepository = require('../../repositories/user/personal/PersonalUserRepository'); 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 { static async completeProfile(req, res) { const userId = req.user.userId; @@ -22,6 +43,201 @@ class PersonalProfileController { 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; diff --git a/database/createDb.js b/database/createDb.js index 56fdc28..813d324 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -126,19 +126,19 @@ async function createDatabase() { nationality VARCHAR(255), address TEXT, zip_code VARCHAR(20), - city VARCHAR(100), -- Added city column + city VARCHAR(100), country VARCHAR(100), phone_secondary VARCHAR(255), emergency_contact_name 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, 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'); + console.log('โœ… Personal profiles table created/verified'); // 3. company_profiles table: Details specific to company users await connection.query(` @@ -146,18 +146,18 @@ async function createDatabase() { id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, company_name VARCHAR(255) NOT NULL, - registration_number VARCHAR(255) UNIQUE, -- allow NULL + registration_number VARCHAR(255) UNIQUE, phone VARCHAR(255) NULL, address TEXT, zip_code VARCHAR(20), city VARCHAR(100), - country VARCHAR(100), -- Added country column after city + country VARCHAR(100), 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 + account_holder_name VARCHAR(255), 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, @@ -165,24 +165,7 @@ async function createDatabase() { 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'); - } + console.log('โœ… Company profiles table created/verified'); // 4. user_status table: Comprehensive tracking of user verification and completion steps await connection.query(` @@ -310,37 +293,15 @@ async function createDatabase() { storageKey VARCHAR(255) NOT NULL, description TEXT, 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, - state ENUM('active','inactive') DEFAULT 'inactive', -- Added state column + state ENUM('active','inactive') DEFAULT 'inactive', 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 ( @@ -349,8 +310,8 @@ async function createDatabase() { 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 + original_filename_front VARCHAR(255), + original_filename_back VARCHAR(255), id_type VARCHAR(50), id_number VARCHAR(100), expiry_date DATE, @@ -364,6 +325,7 @@ async function createDatabase() { await connection.query(` CREATE TABLE IF NOT EXISTS user_action_logs ( id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NULL, affected_user_id INT NULL, action VARCHAR(100) NOT NULL, performed_by_user_id INT NULL, @@ -371,8 +333,10 @@ async function createDatabase() { ip_address VARCHAR(45), user_agent TEXT, 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 (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_performed_by (performed_by_user_id), INDEX idx_action (action), @@ -381,6 +345,57 @@ async function createDatabase() { `); 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 --- // 10. email_attempts table: For tracking email sending attempts @@ -423,43 +438,6 @@ async function createDatabase() { `); 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 ( @@ -586,14 +564,6 @@ async function createDatabase() { `); 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 --- await connection.query(` CREATE TABLE IF NOT EXISTS matrix_config ( @@ -654,103 +624,6 @@ async function createDatabase() { `); 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!'); await connection.end(); diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js index 6e3f460..4f0ce47 100644 --- a/middleware/authMiddleware.js +++ b/middleware/authMiddleware.js @@ -1,4 +1,5 @@ const jwt = require('jsonwebtoken'); +const { logger } = require('./logger'); function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; @@ -9,9 +10,43 @@ function authMiddleware(req, res, next) { const token = authHeader.split(' ')[1]; try { 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(); } 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' }); } } diff --git a/repositories/user/personal/PersonalUserRepository.js b/repositories/user/personal/PersonalUserRepository.js index df27c47..1c09598 100644 --- a/repositories/user/personal/PersonalUserRepository.js +++ b/repositories/user/personal/PersonalUserRepository.js @@ -185,6 +185,196 @@ class PersonalUserRepository { 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; \ No newline at end of file diff --git a/routes/patchRoutes.js b/routes/patchRoutes.js index bd4ff51..78f9a85 100644 --- a/routes/patchRoutes.js +++ b/routes/patchRoutes.js @@ -6,6 +6,7 @@ const DocumentTemplateController = require('../controller/documentTemplate/Docum const CompanyStampController = require('../controller/companyStamp/CompanyStampController'); // <-- added const CoffeeController = require('../controller/admin/CoffeeController'); const AdminUserController = require('../controller/admin/AdminUserController'); +const PersonalProfileController = require('../controller/profile/PersonalProfileController'); // <-- add // Helper middlewares for company-stamp 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 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 module.exports = router; diff --git a/services/profile/personal/PersonalProfileService.js b/services/profile/personal/PersonalProfileService.js index 87df410..9ab6c85 100644 --- a/services/profile/personal/PersonalProfileService.js +++ b/services/profile/personal/PersonalProfileService.js @@ -20,6 +20,26 @@ class PersonalProfileService { 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; \ No newline at end of file