pdo = $pdo; $this->company_id = $company_id; $this->user_id = $user_id; } // ───────────────────────────────────────────────────────────── // READ / IDENTITY BASIS // ───────────────────────────────────────────────────────────── /** * Return the current user's profile row. * * Used to populate the profile settings page and to bootstrap the UI * with the logged-in user's name and avatar. * * @return array Keys: user_id, username, name, surname, email, phone, * country, address, profile_picture. * @throws Exception If the user row is not found. */ public function getProfile(): array { $sth = $this->pdo->prepare( "SELECT user_id, username, name, surname, email, phone, country, address, profile_picture FROM user WHERE user_id = :user_id LIMIT 1" ); $sth->execute([':user_id' => $this->user_id]); $user = $sth->fetch(PDO::FETCH_ASSOC); if (!$user) throw new Exception('User not found.'); return $user; } public function getProfilePicture(): string { $sth = $this->pdo->prepare( "SELECT profile_picture FROM user WHERE user_id = :user_id LIMIT 1" ); $sth->execute([':user_id' => $this->user_id]); return (string)($sth->fetchColumn() ?: ''); } public function updateProfile(array $data, string $profile_picture): void { $this->pdo->prepare( "UPDATE user SET name = :name, surname = :surname, email = :email, phone = :phone, country = :country, address = :address, profile_picture = :profile_picture WHERE user_id = :user_id" )->execute([ ':name' => trim($data['name'] ?? ''), ':surname' => trim($data['surname'] ?? ''), ':email' => trim($data['email'] ?? ''), ':phone' => trim($data['phone'] ?? ''), ':country' => trim($data['country'] ?? ''), ':address' => trim($data['address'] ?? ''), ':profile_picture' => $profile_picture, ':user_id' => $this->user_id, ]); } /** * Return all companies the current user belongs to, plus their role in each. * * Used to populate the branch-switcher dropdown. Results are ordered by * company name so the list is stable regardless of membership order. * * @return array Each row: company_id, channel_name, company_name, branch, role. */ public function getCompanyList(): array { $sth = $this->pdo->prepare( "SELECT cl.company_id, cl.channel_name, cl.company_name, cl.branch, cmu.role FROM company_map_user cmu JOIN company_list cl ON cl.company_id = cmu.company_id WHERE cmu.user_id = :user_id ORDER BY cl.company_name ASC" ); $sth->execute([':user_id' => $this->user_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } public function getCompanyAccess(int $company_id): ?array { $sth = $this->pdo->prepare( "SELECT m.company_id, m.role, m.app_access, u.license, u.app_access AS user_app_access FROM company_map_user m JOIN user u ON u.user_id = m.user_id WHERE m.company_id = :company_id AND m.user_id = :user_id LIMIT 1" ); $sth->execute([':company_id' => $company_id, ':user_id' => $this->user_id]); $row = $sth->fetch(PDO::FETCH_ASSOC); return $row ?: null; } // ───────────────────────────────────────────────────────────── // COMPANY MEMBER BASIS // ───────────────────────────────────────────────────────────── /** * Return all users mapped to the current company, ordered by role then name. * * Role order: owner → admin → staff → viewer. * Used to populate the user management table on the settings page. * * @return array Each row: map_id, role, created_at, user_id, username, * name, surname, email, profile_picture. */ public function getCompanyUsers(): array { $sth = $this->pdo->prepare( "SELECT m.map_id, m.role, CASE WHEN u.license = 'owner' THEN u.app_access ELSE m.app_access END AS app_access, m.created_at, u.user_id, u.username, u.name, u.surname, u.email, u.profile_picture, u.status, u.license, (m.invite_token IS NOT NULL) AS is_pending_invite FROM company_map_user m JOIN user u ON u.user_id = m.user_id WHERE m.company_id = :company_id ORDER BY FIELD(m.role, 'owner', 'admin', 'staff', 'viewer'), u.name ASC" ); $sth->execute([':company_id' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Search for registered users by email keyword, excluding existing members. * * Returns up to 10 matches. Used for the invite autocomplete input. * Excludes users already mapped to this company so the results only show * people who can actually be invited. * * @param string $keyword Partial email to match. * @return array Each row: user_id, username, name, surname, email. */ public function searchUsers(string $keyword): array { if ($keyword === '') return []; // Exact email match only — LIKE across the global user table leaks // names and usernames of users belonging to other companies. $sth = $this->pdo->prepare( "SELECT u.user_id, u.username, u.name, u.surname, u.email FROM user u WHERE u.email = :email AND u.status = 'active' AND u.user_id NOT IN ( SELECT user_id FROM company_map_user WHERE company_id = :company_id ) LIMIT 1" ); $sth->execute([ ':email' => $keyword, ':company_id' => $this->company_id, ]); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Add a user to the current company by email. * * If the email is already registered: maps them to this company directly. * If the email is not registered: creates a pending account (license='user', * default_company = this company) and returns an invite token so the caller * can email them a link to invited_onboarding.php to complete registration. * The invited user inherits this company's SMTP for OTP login. * * @return array ['new_user' => bool, 'email' => string, 'token' => string|null] * @throws Exception On any validation or constraint failure. */ public function inviteUser(string $email, string $role, string $app_access): array { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new Exception('Invalid email address.'); } $allowed_roles = ['admin', 'staff', 'viewer']; if (!in_array($role, $allowed_roles, true)) { throw new Exception('Invalid role selected.'); } $sth = $this->pdo->prepare( "SELECT user_id, email FROM user WHERE email = :email LIMIT 1" ); $sth->execute([':email' => $email]); $target = $sth->fetch(PDO::FETCH_ASSOC); if ($target) { $target_user_id = (int)$target['user_id']; if ($target_user_id === $this->user_id) { throw new Exception('You cannot invite yourself.'); } $sth = $this->pdo->prepare( "SELECT map_id, invite_token FROM company_map_user WHERE company_id = :company_id AND user_id = :user_id LIMIT 1" ); $sth->execute([':company_id' => $this->company_id, ':user_id' => $target_user_id]); $existing = $sth->fetch(PDO::FETCH_ASSOC); if ($existing) { if ($existing['invite_token']) { throw new Exception('An invitation is already pending for this user. Use Resend Invite to refresh it.'); } throw new Exception('This user is already a member of your company.'); } // Existing user must explicitly accept — generate token and send email $invite_token = bin2hex(random_bytes(32)); $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); $this->pdo->prepare( "INSERT INTO company_map_user (company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at) VALUES (:company_id, :user_id, :role, :app_access, :token, :expires, NOW())" )->execute([ ':company_id' => $this->company_id, ':user_id' => $target_user_id, ':role' => $role, ':app_access' => $app_access, ':token' => $invite_token, ':expires' => $expires_at, ]); return ['new_user' => false, 'email' => $target['email'], 'token' => $invite_token]; } // Email not in system — create a pending invited account $invite_token = bin2hex(random_bytes(32)); $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); $temp_username = 'invited_' . bin2hex(random_bytes(8)); $this->pdo->beginTransaction(); try { $this->pdo->prepare( "INSERT INTO user (username, name, surname, email, password, status, license, default_company, profile_picture, verify_token, verify_expires_at) VALUES (:username, '', '', :email, '', 'pending', 'user', :default_company, '', :token, :expires)" )->execute([ ':username' => $temp_username, ':email' => $email, ':default_company' => $this->company_id, ':token' => $invite_token, ':expires' => $expires_at, ]); $new_user_id = (int)$this->pdo->lastInsertId(); $this->pdo->prepare( "INSERT INTO company_map_user (company_id, user_id, role, app_access, invite_token, invite_expires_at, created_at) VALUES (:company_id, :user_id, :role, :app_access, :token, :expires, NOW())" )->execute([ ':company_id' => $this->company_id, ':user_id' => $new_user_id, ':role' => $role, ':app_access' => $app_access, ':token' => $invite_token, ':expires' => $expires_at, ]); $this->pdo->commit(); } catch (Exception $e) { $this->pdo->rollBack(); throw $e; } return ['new_user' => true, 'email' => $email, 'token' => $invite_token]; } /** * Regenerate an invite token for a pending invited user and return it. * * Only works on license='user' + status='pending' accounts that still have * an invite_token in company_map_user. Owner-pending accounts (mid-onboarding) * are never touched. * * @param int $map_id The company_map_user.map_id of the pending member. * @return array ['email' => string, 'token' => string] * @throws Exception If the member is not found or is not a pending invite. */ public function resendInvite(int $map_id): array { if (!$map_id) throw new Exception('Invalid request.'); $sth = $this->pdo->prepare( "SELECT u.user_id, u.email, u.status, u.license, m.invite_token, m.invite_resent_at FROM company_map_user m JOIN user u ON u.user_id = m.user_id WHERE m.map_id = :map_id AND m.company_id = :company_id LIMIT 1" ); $sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception('User not found.'); if (!$row['invite_token']) throw new Exception('No pending invitation found for this user.'); // Rate limit — one resend per 60 seconds if ($row['invite_resent_at'] && strtotime($row['invite_resent_at']) > time() - 60) { throw new Exception('Please wait before resending the invitation.'); } $is_new_user = ($row['license'] === 'user' && $row['status'] === 'pending'); $new_token = bin2hex(random_bytes(32)); $expires_at = date('Y-m-d H:i:s', strtotime('+7 days')); $this->pdo->beginTransaction(); try { // Brand-new pending accounts also need user.verify_token updated (used by invited_onboarding.php) if ($is_new_user) { $this->pdo->prepare( "UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :uid" )->execute([':token' => $new_token, ':expires' => $expires_at, ':uid' => (int)$row['user_id']]); } $this->pdo->prepare( "UPDATE company_map_user SET invite_token = :token, invite_expires_at = :expires, invite_resent_at = NOW() WHERE map_id = :map_id AND company_id = :company_id" )->execute([':token' => $new_token, ':expires' => $expires_at, ':map_id' => $map_id, ':company_id' => $this->company_id]); $this->pdo->commit(); } catch (Exception $e) { $this->pdo->rollBack(); throw $e; } return ['email' => $row['email'], 'token' => $new_token, 'is_new_user' => $is_new_user]; } /** * Change the role of a company member. * * Blocks changing the owner's role. Only 'admin', 'staff', 'viewer' * are assignable — the owner role is set at company creation and is immutable. * * @param int $map_id The company_map_user.map_id to update. * @param string $role New role: 'admin', 'staff', or 'viewer'. * @throws Exception If the member is not found or is the owner. */ public function updateRole(int $map_id, string $role): void { $allowed_roles = ['admin', 'staff', 'viewer']; if (!$map_id || !in_array($role, $allowed_roles, true)) { throw new Exception('Invalid request.'); } $sth = $this->pdo->prepare( "SELECT role FROM company_map_user WHERE map_id = :map_id AND company_id = :company_id LIMIT 1" ); $sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); $current_role = $sth->fetchColumn(); if ($current_role === false) throw new Exception('User not found.'); if ($current_role === 'owner') throw new Exception('Owner role cannot be changed.'); $this->pdo->prepare( "UPDATE company_map_user SET role = :role WHERE map_id = :map_id AND company_id = :company_id" )->execute([ ':role' => $role, ':map_id' => $map_id, ':company_id' => $this->company_id, ]); } /** * Update the app_access of a non-owner company member. * * Owner's app_access is managed via user.app_access (their license); it * cannot be changed here. Caller must have already validated the value is * within the owner's license. * * @param int $map_id The company_map_user.map_id to update. * @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'. * @throws Exception If the member is not found or is the owner. */ public function updateAppAccess(int $map_id, string $app_access): void { if (!$map_id || $app_access === '') { throw new Exception('Invalid request.'); } $sth = $this->pdo->prepare( "SELECT u.license FROM company_map_user m JOIN user u ON u.user_id = m.user_id WHERE m.map_id = :map_id AND m.company_id = :company_id LIMIT 1" ); $sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); $license = $sth->fetchColumn(); if ($license === false) throw new Exception('User not found.'); if ($license === 'owner') throw new Exception('Owner app access is determined by their license.'); $this->pdo->prepare( "UPDATE company_map_user SET app_access = :app_access WHERE map_id = :map_id AND company_id = :company_id" )->execute([ ':app_access' => $app_access, ':map_id' => $map_id, ':company_id' => $this->company_id, ]); } /** * Remove a user from the current company. * * Blocks removing the owner or removing yourself. * Hard-deletes the company_map_user row — membership history is not retained. * * @param int $map_id The company_map_user.map_id to delete. * @throws Exception If the member is not found, is the owner, or is the caller. */ public function removeUser(int $map_id): void { if (!$map_id) throw new Exception('Invalid request.'); $sth = $this->pdo->prepare( "SELECT role, user_id FROM company_map_user WHERE map_id = :map_id AND company_id = :company_id LIMIT 1" ); $sth->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception('User not found.'); if ($row['role'] === 'owner') throw new Exception('The owner cannot be removed.'); if ((int)$row['user_id'] === $this->user_id) throw new Exception('You cannot remove yourself.'); $target_uid = (int)$row['user_id']; $this->pdo->beginTransaction(); try { // Lock the user row first — if invited_onboarding.php is activating this // account at the same moment, one will wait rather than both proceeding // with stale status data. $sth = $this->pdo->prepare( "SELECT license, status, default_company FROM user WHERE user_id = :uid LIMIT 1 FOR UPDATE" ); $sth->execute([':uid' => $target_uid]); $u = $sth->fetch(PDO::FETCH_ASSOC); $this->pdo->prepare( "DELETE FROM company_map_user WHERE map_id = :map_id AND company_id = :company_id" )->execute([':map_id' => $map_id, ':company_id' => $this->company_id]); if ($u) { if ($u['license'] === 'user' && $u['status'] === 'pending') { // Never activated — delete the placeholder row so the email is free $this->pdo->prepare("DELETE FROM user WHERE user_id = :uid") ->execute([':uid' => $target_uid]); } elseif ((int)$u['default_company'] === $this->company_id) { // Active user removed from their default company — clear it so they // are not left pointing at a company they no longer belong to $this->pdo->prepare( "UPDATE user SET default_company = 0 WHERE user_id = :uid" )->execute([':uid' => $target_uid]); } } $this->pdo->commit(); } catch (Exception $e) { $this->pdo->rollBack(); throw $e; } } }