['csv', 'json', 'pdf', 'xls', 'xlsx'], 'mimes' => [ 'text/csv', 'text/plain', 'application/json', 'application/pdf', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ], 'description' => 'CSV, JSON, PDF, XLS, XLSX', ]; private const TYPE_SPECIFIC_RULES = [ 'csv' => [ 'extensions' => ['csv'], 'mimes' => ['text/csv', 'text/plain', 'application/vnd.ms-excel'], 'description' => 'CSV files (.csv)', ], 'json' => [ 'extensions' => ['json'], 'mimes' => ['application/json', 'text/json', 'text/plain'], 'description' => 'JSON files (.json)', ], 'pdf' => [ 'extensions' => ['pdf'], 'mimes' => ['application/pdf'], 'description' => 'PDF documents (.pdf)', ], 'xls' => [ 'extensions' => ['xls'], 'mimes' => ['application/vnd.ms-excel'], 'description' => 'Excel 97-2003 (.xls)', ], 'xlsx' => [ 'extensions' => ['xlsx'], 'mimes' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], 'description' => 'Excel (.xlsx)', ], 'dta' => [ 'extensions' => ['dta'], 'mimes' => [ 'application/x-stata', 'application/stata', 'application/octet-stream', ], 'description' => 'Stata dataset (.dta)', ], 'stata' => [ 'extensions' => ['dta'], 'mimes' => [ 'application/x-stata', 'application/stata', 'application/octet-stream', ], 'description' => 'Stata dataset (.dta)', ], 'sav' => [ 'extensions' => ['sav'], 'mimes' => [ 'application/octet-stream', 'application/vnd.spss.sav', ], 'description' => 'SPSS dataset (.sav)', ], ]; public function __construct(PDO $pdo) { $this->pdo = $pdo; // Correctly define the absolute path from the project root. // __DIR__ points to the 'classes' directory. // dirname(__DIR__) moves up one level to 'dsp26072025'. $projectRoot = dirname(__DIR__); $this->uploadDir = $projectRoot . '/uploads/datasources/'; // Ensure upload directory exists if (!is_dir($this->uploadDir)) { if (!mkdir($this->uploadDir, 0777, true)) { // If it still fails, there is a deeper permissions issue die("Error: Failed to create directory. Please check permissions for " . $this->uploadDir); } } } // Public getter for upload directory (needed for file deletion) public function getUploadDir(): string { return $this->uploadDir; } // --- Data Source Management (dsps_tbl_datasource) --- /** * Adds a new data source. * * @param int $fkdspstds_id FK to dsps_tbl_typedatasource. * @param int $fkdspscate_id FK to dsps_tbl_dspscategory. * @param int $fkisp_id_of FK to ist_tbl_people (Data Owner). * @param string|null $filename Primary file path/name or API endpoint URL. * @param string $title_en English title. * @param string|null $title_kh Khmer title. * @param string|null $description Description. * @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published'). * @param int $reg_by User ID who registered it. * @param string|null $filename1 Optional secondary file path. * @param string|null $filename2 Optional tertiary file path. * @param string|null $filename3 Optional quaternary file path. * @return bool True on success. * @throws Exception If a database error occurs. */ public function addDataSource( int $fkdspstds_id, int $fkdspscate_id, int $fkisp_id_of, ?string $filename, string $title_en, ?string $title_kh, ?string $description, string $status, int $reg_by, ?string $filename1 = null, ?string $filename2 = null, ?string $filename3 = null ): bool { // Determine public_date based on status. If 'Published', set to current date, otherwise NULL. $publicDateSql = ($status === 'Published') ? "CURRENT_DATE" : "NULL"; $sql = "INSERT INTO dsps_tbl_datasource (fkdspstds_id, fkdspscate_id, fkisp_id_of, dspsds_filename, dspsds_title_en, dspsds_title_kh, dspsds_description, dspsds_status, dspsds_reg_by, dspsds_public_date, dspsds_filename1, dspsds_filename2, dspsds_filename3) VALUES (:fkdspstds_id, :fkdspscate_id, :fkisp_id_of, :filename, :title_en, :title_kh, :description, :status, :reg_by, " . $publicDateSql . ", :filename1, :filename2, :filename3)"; // Directly inject CURRENT_DATE or NULL try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT); $stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT); $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); $stmt->bindParam(':filename', $filename); $stmt->bindParam(':title_en', $title_en); $stmt->bindParam(':title_kh', $title_kh); $stmt->bindParam(':description', $description); $stmt->bindParam(':status', $status); $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT); $stmt->bindParam(':filename1', $filename1); $stmt->bindParam(':filename2', $filename2); $stmt->bindParam(':filename3', $filename3); return $stmt->execute(); } catch (PDOException $e) { error_log("Error adding data source: " . $e->getMessage()); throw new Exception("Could not add data source. Please try again later."); } } /** * Updates an existing data source. * * @param int $id ID of the data source to update. * @param int $fkdspstds_id FK to dsps_tbl_typedatasource. * @param int $fkdspscate_id FK to dsps_tbl_dspscategory. * @param int $fkisp_id_of FK to ist_tbl_people (Data Owner). * @param string|null $filename File path/name or API endpoint URL. * @param string $title_en English title. * @param string|null $title_kh Khmer title. * @param string|null $description Description. * @param string $status Status ('Active', 'Inactive', 'Pending Review', 'Published'). * @param int $mod_by User ID who modified it. * @param string|null $filename1 Optional secondary file path. * @param string|null $filename2 Optional tertiary file path. * @param string|null $filename3 Optional quaternary file path. * @return bool True on success. * @throws Exception If a database error occurs. */ public function updateDataSource( int $id, int $fkdspstds_id, int $fkdspscate_id, int $fkisp_id_of, ?string $filename, string $title_en, ?string $title_kh, ?string $description, string $status, int $mod_by, ?string $filename1 = null, ?string $filename2 = null, ?string $filename3 = null ): bool { // Check current status to decide on dspsds_public_date update $currentDataSource = $this->getDataSourceById($id); $publicDateUpdate = ""; if ($status === 'Published' && ($currentDataSource['dspsds_status'] !== 'Published' || empty($currentDataSource['dspsds_public_date']))) { $publicDateUpdate = ", dspsds_public_date = CURRENT_DATE"; } elseif ($status !== 'Published' && !empty($currentDataSource['dspsds_public_date'])) { $publicDateUpdate = ", dspsds_public_date = NULL"; } $sql = "UPDATE dsps_tbl_datasource SET fkdspstds_id = :fkdspstds_id, fkdspscate_id = :fkdspscate_id, fkisp_id_of = :fkisp_id_of, dspsds_filename = :filename, dspsds_title_en = :title_en, dspsds_title_kh = :title_kh, dspsds_description = :description, dspsds_status = :status, dspsds_filename1 = :filename1, dspsds_filename2 = :filename2, dspsds_filename3 = :filename3, dspsds_mod_datetime = CURRENT_TIMESTAMP, dspsds_reg_by = :mod_by {$publicDateUpdate} WHERE pkdspsds_id = :id"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':fkdspstds_id', $fkdspstds_id, PDO::PARAM_INT); $stmt->bindParam(':fkdspscate_id', $fkdspscate_id, PDO::PARAM_INT); $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); $stmt->bindParam(':filename', $filename); $stmt->bindParam(':title_en', $title_en); $stmt->bindParam(':title_kh', $title_kh); $stmt->bindParam(':description', $description); $stmt->bindParam(':status', $status); $stmt->bindParam(':filename1', $filename1); $stmt->bindParam(':filename2', $filename2); $stmt->bindParam(':filename3', $filename3); $stmt->bindParam(':mod_by', $mod_by, PDO::PARAM_INT); $stmt->bindParam(':id', $id, PDO::PARAM_INT); return $stmt->execute(); } catch (PDOException $e) { error_log("Error updating data source (ID: $id): " . $e->getMessage()); throw new Exception("Could not update data source. Please try again later."); } } /** * Deletes a data source from the database and its associated file. * * @param int $id The ID of the data source to delete. * @return bool True on success. * @throws Exception If a database error occurs. */ public function deleteDataSource(int $id): bool { // Get the filename to delete the actual file $dataSource = $this->getDataSourceById($id); if ($dataSource) { $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3']; foreach ($fileColumns as $column) { if (!empty($dataSource[$column])) { $filePath = $this->uploadDir . $dataSource[$column]; if (is_file($filePath)) { unlink($filePath); } } } } $sql = "DELETE FROM dsps_tbl_datasource WHERE pkdspsds_id = :id"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':id', $id); return $stmt->execute(); } catch (PDOException $e) { error_log("Error deleting data source (ID: $id): " . $e->getMessage()); throw new Exception("Could not delete data source. Please try again later."); } } /** * Retrieves a single data source by its ID. * * @param int $id The ID of the data source. * @return array|false The data source data as an associative array, or false if not found. * @throws Exception If a database error occurs. */ public function getDataSourceById(int $id) { $sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh, dspscate.dspscate_title_en AS category_name, isp.isp_firstname_en, isp.isp_lastname_en FROM dsps_tbl_datasource ds JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id WHERE ds.pkdspsds_id = :id"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':id', $id); $stmt->execute(); return $stmt->fetch(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching data source by ID ($id): " . $e->getMessage()); throw new Exception("Could not retrieve data source. Please try again later."); } } /** * Retrieves data sources based on various filters. * * @param int|null $owner_person_id Optional: Filter by data owner's person ID. * @param string|null $status Optional: Filter by status (e.g., 'Active', 'Pending Review'). * @param int|null $category_id Optional: Filter by category ID. * @param string|null $search_query Optional: Search by title or description. * @param int|null $limit Optional: Limit the number of results. * @return array An array of data source data. * @throws Exception If a database error occurs. */ public function getDataSources( ?int $owner_person_id = null, ?string $status = null, ?int $category_id = null, ?string $search_query = null, ?int $limit = null ): array { $sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspstds.dspstds_name_kh AS data_type_name_kh, dspscate.dspscate_title_en AS category_name, isp.isp_firstname_en, isp.isp_lastname_en FROM dsps_tbl_datasource ds JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id JOIN ist_tbl_people isp ON ds.fkisp_id_of = isp.pkisp_id"; $conditions = []; $params = []; // Use strict checks for null to avoid issues with 0 and empty strings if ($owner_person_id !== null) { $conditions[] = "ds.fkisp_id_of = :owner_person_id"; $params[':owner_person_id'] = $owner_person_id; } if ($status !== null) { $conditions[] = "ds.dspsds_status = :status"; $params[':status'] = $status; } if ($category_id !== null) { $conditions[] = "ds.fkdspscate_id = :category_id"; $params[':category_id'] = $category_id; } if ($search_query !== null && $search_query !== '') { $conditions[] = "(ds.dspsds_title_en LIKE :search_query OR ds.dspsds_description LIKE :search_query)"; $params[':search_query'] = '%' . $search_query . '%'; } if (!empty($conditions)) { $sql .= " WHERE " . implode(" AND ", $conditions); } $sql .= " ORDER BY ds.dspsds_reg_datetime DESC"; // A safer way to handle LIMIT is to validate and concatenate the integer // directly, as not all PDO drivers support binding LIMIT values. if ($limit !== null && is_int($limit) && $limit > 0) { $sql .= " LIMIT " . $limit; } try { $stmt = $this->pdo->prepare($sql); // Pass the parameters array directly to execute() $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching data sources: " . $e->getMessage()); throw new Exception("Could not retrieve data sources. Please try again later."); } } /** * Gets the total count of data sources. * * @return int The total number of data sources. * @throws Exception If a database error occurs. */ public function getTotalDataSources(): int { $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource"; try { $stmt = $this->pdo->query($sql); return $stmt->fetchColumn(); } catch (PDOException $e) { error_log("Error getting total data sources count: " . $e->getMessage()); throw new Exception("Could not retrieve data source count. Please try again later."); } } /** * Handles the upload of a data source file. * * @param array $file The $_FILES array for the uploaded file. * @return string The unique filename of the uploaded file. * @throws Exception If the upload fails. */ public function handleDataSourceFileUpload(array $file, ?array $fileRules = null): string { if ($file['error'] !== UPLOAD_ERR_OK) { throw new Exception('File upload error: ' . $file['error']); } $rules = $fileRules ?? self::DEFAULT_FILE_RULES; $allowedMimeTypes = $rules['mimes'] ?? self::DEFAULT_FILE_RULES['mimes']; $allowedExtensions = $rules['extensions'] ?? self::DEFAULT_FILE_RULES['extensions']; $description = $rules['description'] ?? self::DEFAULT_FILE_RULES['description']; if (($file['size'] ?? 0) <= 0) { throw new Exception('Uploaded file is empty or missing.'); } error_log(sprintf( '[DataSource] Upload requested: name=%s size=%s mime=%s rules=%s', $file['name'] ?? '', $file['size'] ?? 'unknown', $file['type'] ?? 'unknown', $description )); $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($file['tmp_name']); if (!in_array($mimeType, $allowedMimeTypes, true)) { throw new Exception('Invalid file type. Allowed formats: ' . $description); } $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if (!in_array($extension, array_map('strtolower', $allowedExtensions), true)) { throw new Exception('Invalid file extension. Allowed formats: ' . $description); } $originalStem = pathinfo($file['name'], PATHINFO_FILENAME); $slug = $this->slugifyFilename($originalStem); $uniqueFilename = 'datasource_' . uniqid(); if (!empty($slug)) { $uniqueFilename .= '_' . $slug; } $uniqueFilename .= '.' . $extension; $destination = $this->uploadDir . $uniqueFilename; if (!move_uploaded_file($file['tmp_name'], $destination)) { $moved = false; if (PHP_SAPI === 'cli' || defined('STDIN')) { $moved = rename($file['tmp_name'], $destination); } if (!$moved) { error_log(sprintf('[DataSource] Failed to move uploaded file into %s (tmp=%s)', $destination, $file['tmp_name'])); throw new Exception('Failed to move uploaded file.'); } } error_log(sprintf('[DataSource] File stored as %s', $uniqueFilename)); return $uniqueFilename; } private function resolvePrimaryFileRules(?string $typeName): array { if (!$typeName) { return self::DEFAULT_FILE_RULES; } $key = strtolower(trim($typeName)); if (isset(self::TYPE_SPECIFIC_RULES[$key])) { return self::TYPE_SPECIFIC_RULES[$key]; } foreach (self::TYPE_SPECIFIC_RULES as $alias => $rules) { if (str_contains($alias, $key) || str_contains($key, $alias)) { return $rules; } } // fallback to defaults but include description referencing requested type return self::DEFAULT_FILE_RULES; } public function getPrimaryFileRulesForType(?string $typeName): array { return $this->resolvePrimaryFileRules($typeName); } private function slugifyFilename(?string $value): string { if (!$value) { return ''; } $value = strtolower($value); // Replace non-alphanumeric characters with hyphens. $value = preg_replace('/[^a-z0-9]+/i', '-', $value); $value = trim($value ?? '', '-'); if ($value === '') { return ''; } // Limit slug length to avoid overlong filenames. return substr($value, 0, 50); } // --- Data Source Permission Management (dsps_tbl_datasource_permission) --- /** * Requests permission for a data source for a specific user. * * @param int $fkdspsds_id The ID of the data source. * @param int $fkisp_id_of The person ID of the user requesting permission. * @param string $permission The type of permission ('Read', 'Download', 'Analyze'). * @param string|null $notes Any notes from the requester. * @param int $reg_by The user ID who initiated the request (usually the data user themselves). * @return bool True on success. * @throws Exception If a database error occurs or a pending request already exists. */ public function requestDataSourcePermission( int $fkdspsds_id, int $fkisp_id_of, string $permission, ?string $notes, int $reg_by, ?string $proofPath = null ): bool { // Check if a pending request already exists for this user and data source $checkSql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of AND dspsdsp_status = 'Pending'"; $stmtCheck = $this->pdo->prepare($checkSql); $stmtCheck->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); $stmtCheck->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); $stmtCheck->execute(); if ($stmtCheck->fetchColumn() > 0) { throw new Exception("You already have a pending permission request for this data source."); } // Detect any existing permission (approved/rejected/etc.) to avoid duplicate key violations $existingSql = "SELECT dspsdsp_status FROM dsps_tbl_datasource_permission WHERE fkdspsds_id = :fkdspsds_id AND fkisp_id_of = :fkisp_id_of LIMIT 1"; $stmtExisting = $this->pdo->prepare($existingSql); $stmtExisting->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); $stmtExisting->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); $stmtExisting->execute(); if ($row = $stmtExisting->fetch(PDO::FETCH_ASSOC)) { $statusLabel = $row['dspsdsp_status'] ?? 'processed'; throw new Exception("This request was already {$statusLabel}. Please check My Permissions."); } $hasProofColumn = $this->ensurePermissionProofColumn(); $columns = [ 'fkdspsds_id', 'fkisp_id_of', 'dspsdsp_permission', 'dspsdsp_notes' ]; $placeholders = [ ':fkdspsds_id', ':fkisp_id_of', ':permission', ':notes' ]; if ($hasProofColumn) { $columns[] = 'dspsdsp_proof_path'; $placeholders[] = ':proof_path'; } $columns[] = 'dspsdsp_status'; $columns[] = 'dspsdsp_reg_by'; $placeholders[] = ':status'; $placeholders[] = ':reg_by'; $sql = 'INSERT INTO dsps_tbl_datasource_permission (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $placeholders) . ')'; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); $stmt->bindParam(':permission', $permission); if ($notes === null) { $stmt->bindValue(':notes', null, PDO::PARAM_NULL); } else { $stmt->bindValue(':notes', $notes, PDO::PARAM_STR); } if ($hasProofColumn) { if ($proofPath === null) { $stmt->bindValue(':proof_path', null, PDO::PARAM_NULL); } else { $stmt->bindValue(':proof_path', $proofPath, PDO::PARAM_STR); } } $stmt->bindValue(':status', 'Pending', PDO::PARAM_STR); $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT); return $stmt->execute(); } catch (PDOException $e) { error_log("Error requesting data source permission: " . $e->getMessage()); throw new Exception("Could not submit permission request. Please try again later."); } } /** * Retrieves permission requests for a specific Data Owner. * * @param int $owner_person_id The person ID of the Data Owner. * @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked'). * @return array An array of permission request data. * @throws Exception If a database error occurs. */ public function getPermissionRequestsForOwner(int $ownerPersonId, ?string $status = null): array { $hasProofColumn = $this->ensurePermissionProofColumn(); $proofSelect = $hasProofColumn ? 't1.dspsdsp_proof_path' : 'NULL'; $proofSelect .= ' AS dspsdsp_proof_path'; $sql = "SELECT t1.pkdspsdsp_id, t2.dspsds_title_en, t3.isp_firstname_en, t3.isp_lastname_en, t1.dspsdsp_permission, t1.dspsdsp_reg_datetime, t1.dspsdsp_notes, $proofSelect, t1.dspsdsp_status FROM dsps_tbl_datasource_permission AS t1 JOIN dsps_tbl_datasource AS t2 ON t1.fkdspsds_id = t2.pkdspsds_id JOIN ist_tbl_people AS t3 ON t1.fkisp_id_of = t3.pkisp_id WHERE t2.fkisp_id_of = :ownerPersonId"; if ($status !== null) { $sql .= " AND t1.dspsdsp_status = :status"; } $sql .= " ORDER BY t1.dspsdsp_reg_datetime DESC"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT); if ($status !== null) { $stmt->bindParam(':status', $status); } $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Database error in getPermissionRequestsForOwner: " . $e->getMessage()); return []; } } /** * Retrieves a single permission request by its ID. * Includes data source owner's person ID for authorization checks. * * @param int $permissionId The ID of the permission request. * @return array|false The permission data as an associative array, or false if not found. * @throws Exception If a database error occurs. */ public function getPermissionRequestById(int $permissionId) { $sql = "SELECT dsp.*, ds.fkisp_id_of AS datasource_owner_person_id FROM dsps_tbl_datasource_permission dsp JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id WHERE dsp.pkdspsdsp_id = :permission_id"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':permission_id', $permissionId, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetch(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching permission request by ID ($permissionId): " . $e->getMessage()); throw new Exception("Could not retrieve permission request details. Please try again later."); } } /** * Retrieves all permission requests for DAC Staff, optionally filtered by status and search query. * * @param string|null $status_filter Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked'). * @param string|null $search_query Optional: Search by data source title, requester name, or owner name. * @return array An array of all permission request data. * @throws Exception If a database error occurs. */ public function getAllPermissionRequests(?string $status_filter = null, ?string $search_query = null): array { $sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_filename, requester_p.isp_firstname_en AS requester_firstname, requester_p.isp_lastname_en AS requester_lastname, owner_p.isp_firstname_en AS owner_firstname, owner_p.isp_lastname_en AS owner_lastname FROM dsps_tbl_datasource_permission dsp JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id JOIN ist_tbl_people requester_p ON dsp.fkisp_id_of = requester_p.pkisp_id JOIN ist_tbl_people owner_p ON ds.fkisp_id_of = owner_p.pkisp_id"; $conditions = []; $params = []; if ($status_filter) { $conditions[] = "dsp.dspsdsp_status = :status_filter"; $params[':status_filter'] = $status_filter; } if ($search_query) { $search_term = '%' . $search_query . '%'; $conditions[] = "(ds.dspsds_title_en LIKE :search_query OR requester_p.isp_firstname_en LIKE :search_query OR requester_p.isp_lastname_en LIKE :search_query OR owner_p.isp_firstname_en LIKE :search_query OR owner_p.isp_lastname_en LIKE :search_query)"; $params[':search_query'] = $search_term; } if (!empty($conditions)) { $sql .= " WHERE " . implode(" AND ", $conditions); } $sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC"; try { $stmt = $this->pdo->prepare($sql); foreach ($params as $key => &$val) { $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching all permission requests: " . $e->getMessage()); throw new Exception("Could not retrieve all permission requests. Please try again later."); } } /** * Gets the count of pending permission requests. * * @return int The count of pending permission requests. * @throws Exception If a database error occurs. */ public function getPendingPermissionRequestsCount(): int { $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission WHERE dspsdsp_status = 'Pending'"; try { $stmt = $this->pdo->query($sql); return $stmt->fetchColumn(); } catch (PDOException $e) { error_log("Error getting pending permission requests count: " . $e->getMessage()); throw new Exception("Could not retrieve pending permission count. Please try again later."); } } /** * Aggregates total usage counts per data source owner (person id). * * @param int $limit Maximum number of owners to return. * @return array */ public function getUsageByOwner(int $limit = 8): array { $sql = "SELECT ds.fkisp_id_of AS owner_person_id, CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name, COUNT(dsu.pkdspsdspused_id) AS usage_count FROM dsps_tbl_datasource_used dsu JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id GROUP BY ds.fkisp_id_of, owner_name ORDER BY usage_count DESC LIMIT :limit"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching usage by owner: " . $e->getMessage()); return []; } } /** * Updates the status of a data source permission request. * * @param int $permission_id The ID of the permission request. * @param string $new_status The new status ('Approved', 'Rejected', 'Revoked'). * @param string|null $notes Optional notes for the status change. * @param int $mod_by The user ID of the Data Owner or DAC Staff who updated the status. * @return bool True on success. * @throws Exception If a database error occurs. */ public function updatePermissionStatus(int $permissionId, string $newStatus, int $modifyingUserId, string $notes = ''): bool { $sql = "UPDATE dsps_tbl_datasource_permission SET dspsdsp_status = :newStatus, dspsdsp_mod_datetime = NOW(), dspsdsp_reg_by = :modifyingUserId, dspsdsp_notes = :notes WHERE pkdspsdsp_id = :permissionId"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':newStatus', $newStatus); $stmt->bindParam(':modifyingUserId', $modifyingUserId, PDO::PARAM_INT); $stmt->bindParam(':notes', $notes); $stmt->bindParam(':permissionId', $permissionId, PDO::PARAM_INT); return $stmt->execute(); } catch (PDOException $e) { error_log("Error updating permission status: " . $e->getMessage()); return false; } } /** * Checks if a user has a specific permission for a data source. * * @param int $user_person_id The person ID of the user. * @param int $data_source_id The ID of the data source. * @param string $permission_type The type of permission to check ('Read', 'Download', 'Analyze'). * @return bool True if the user has the permission, false otherwise. * @throws Exception If a database error occurs. */ public function hasPermission(int $user_person_id, int $data_source_id, string $permission_type): bool { $sql = "SELECT COUNT(*) FROM dsps_tbl_datasource_permission WHERE fkisp_id_of = :user_person_id AND fkdspsds_id = :data_source_id AND dspsdsp_permission = :permission_type AND dspsdsp_status = 'Approved'"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT); $stmt->bindParam(':data_source_id', $data_source_id, PDO::PARAM_INT); $stmt->bindParam(':permission_type', $permission_type); $stmt->execute(); return $stmt->fetchColumn() > 0; } catch (PDOException $e) { error_log("Error checking permission for user ($user_person_id) on data source ($data_source_id): " . $e->getMessage()); throw new Exception("Could not verify permission. Please try again later."); } } /** * Returns usage counts grouped by consuming user for data sources owned by a person. * * @param int $ownerPersonId * @param int $limit * @return array> */ public function getUsageByUserForOwner(int $ownerPersonId, int $limit = 10): array { $sql = "SELECT u.isu_name AS username, COUNT(dsu.pkdspsdspused_id) AS usage_count FROM dsps_tbl_datasource_used dsu JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id JOIN ist_tbl_users u ON dsu.dspsdspused_reg_by = u.pkisu_id WHERE ds.fkisp_id_of = :ownerPersonId GROUP BY u.pkisu_id, u.isu_name ORDER BY usage_count DESC LIMIT :limit"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':ownerPersonId', $ownerPersonId, PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching usage by user for owner ($ownerPersonId): " . $e->getMessage()); return []; } } /** * Retrieves usage counts for a specific user grouped by data source. * * @param int $userPersonId * @param int $limit * @return array> */ public function getUsageByDataSourceForUser(int $userPersonId, int $limit = 8): array { $sql = "SELECT ds.pkdspsds_id, ds.dspsds_title_en, COUNT(dsu.pkdspsdspused_id) AS usage_count FROM dsps_tbl_datasource_used dsu JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id WHERE dsu.dspsdspused_reg_by = :userPersonId GROUP BY ds.pkdspsds_id, ds.dspsds_title_en ORDER BY usage_count DESC LIMIT :limit"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':userPersonId', $userPersonId, PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching usage by data source for user ($userPersonId): " . $e->getMessage()); return []; } } /** * Retrieves all data sources with owner, type, and category details. * * @param string|null $search Optional search across title, owner, or type. * @param string|null $status Optional status filter. * @return array */ public function getAllDataSourcesDetailed(?string $search = null, ?string $status = null): array { $sql = "SELECT ds.*, dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name, CONCAT(p.isp_firstname_en, ' ', p.isp_lastname_en) AS owner_name FROM dsps_tbl_datasource ds JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id JOIN ist_tbl_people p ON ds.fkisp_id_of = p.pkisp_id"; $conditions = []; $params = []; if ($status !== null && $status !== '') { $conditions[] = "ds.dspsds_status = :status"; $params[':status'] = $status; } if ($search !== null && $search !== '') { $conditions[] = "(ds.dspsds_title_en LIKE :search OR p.isp_firstname_en LIKE :search OR p.isp_lastname_en LIKE :search OR dspstds.dspstds_name_en LIKE :search)"; $params[':search'] = '%' . $search . '%'; } if (!empty($conditions)) { $sql .= " WHERE " . implode(' AND ', $conditions); } $sql .= " ORDER BY ds.dspsds_reg_datetime DESC"; try { $stmt = $this->pdo->prepare($sql); foreach ($params as $key => $value) { $stmt->bindValue($key, $value); } $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching all data sources: " . $e->getMessage()); return []; } } /** * Updates the status of a data source. * * @param int $datasourceId * @param string $newStatus * @param int $modifyingUserId * @return bool */ public function updateDataSourceStatus(int $datasourceId, string $newStatus, int $modifyingUserId): bool { $allowed = ['Active', 'Inactive', 'Pending Review', 'Published']; if (!in_array($newStatus, $allowed, true)) { throw new InvalidArgumentException("Invalid data source status value."); } $sql = "UPDATE dsps_tbl_datasource SET dspsds_status = :status, dspsds_mod_datetime = CURRENT_TIMESTAMP, dspsds_reg_by = :modBy WHERE pkdspsds_id = :id"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':status', $newStatus); $stmt->bindValue(':modBy', $modifyingUserId, PDO::PARAM_INT); $stmt->bindValue(':id', $datasourceId, PDO::PARAM_INT); return $stmt->execute(); } catch (PDOException $e) { error_log("Error updating data source status (ID: $datasourceId): " . $e->getMessage()); return false; } } /** * Retrieves all permissions for a specific user. * * @param int $user_person_id The person ID of the user. * @param string|null $status Optional: Filter by status ('Pending', 'Approved', 'Rejected', 'Revoked'). * @return array An array of permission data. * @throws Exception If a database error occurs. */ public function getUserPermissions(int $user_person_id, ?string $status = null): array { $sql = "SELECT dsp.*, ds.dspsds_title_en, ds.dspsds_description, ds.dspsds_filename, ds.dspsds_filename1, ds.dspsds_filename2, ds.dspsds_filename3, dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name FROM dsps_tbl_datasource_permission dsp JOIN dsps_tbl_datasource ds ON dsp.fkdspsds_id = ds.pkdspsds_id JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id WHERE dsp.fkisp_id_of = :user_person_id"; $params = [':user_person_id' => $user_person_id]; if ($status) { $sql .= " AND dsp.dspsdsp_status = :status"; $params[':status'] = $status; } $sql .= " ORDER BY dsp.dspsdsp_reg_datetime DESC"; try { $stmt = $this->pdo->prepare($sql); foreach ($params as $key => &$val) { $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching user permissions ($user_person_id): " . $e->getMessage()); throw new Exception("Could not retrieve your permissions. Please try again later."); } } /** * Retrieves all approved data sources a user is permitted to analyse/download. * * @param int $userPersonId The person ID tied to the current session. * @return array * @throws Exception */ public function getApprovedDataSourcesForUser(int $userPersonId): array { return $this->getUserPermissions($userPersonId, 'Approved'); } /** * Synchronises the approved data sources for a user into a dedicated Jupyter workspace directory. * Files are symlinked when possible to avoid duplication; if symlinks are not permitted, files are copied. * * @param int $userPersonId The person ID of the user. * @param string $workspaceRoot Absolute path to the shared Jupyter workspace root. * @return array{synced: array>, missing: array>, workspace_dir: string} * @throws Exception When the workspace cannot be prepared. */ public function prepareJupyterWorkspace(int $userPersonId, string $workspaceRoot): array { if (!is_dir($workspaceRoot) && !mkdir($workspaceRoot, 0775, true) && !is_dir($workspaceRoot)) { throw new RuntimeException("Unable to create Jupyter workspace root at {$workspaceRoot}"); } $userFolderName = 'user_' . $userPersonId; $userWorkspaceDir = rtrim($workspaceRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $userFolderName; if (!is_dir($userWorkspaceDir) && !mkdir($userWorkspaceDir, 0775, true) && !is_dir($userWorkspaceDir)) { throw new RuntimeException("Unable to create user workspace directory at {$userWorkspaceDir}"); } $this->purgeSyncedArtifacts($userWorkspaceDir); $approvedSources = $this->getApprovedDataSourcesForUser($userPersonId); $synced = []; $missing = []; foreach ($approvedSources as $index => $permission) { $sourceId = (int)($permission['fkdspsds_id'] ?? 0); $title = $permission['dspsds_title_en'] ?? ('Data Source ' . $sourceId); $fileColumns = ['dspsds_filename', 'dspsds_filename1', 'dspsds_filename2', 'dspsds_filename3']; $fileQueue = []; foreach ($fileColumns as $column) { $candidate = trim((string)($permission[$column] ?? '')); if ($candidate !== '' && !in_array($candidate, $fileQueue, true)) { $fileQueue[] = $candidate; } } if (empty($fileQueue)) { $missing[] = [ 'datasource_id' => $sourceId, 'title' => $title, 'reason' => 'No files associated with this data source.', ]; continue; } foreach ($fileQueue as $fileIndex => $filename) { $sourcePath = $this->uploadDir . $filename; if (!is_file($sourcePath)) { $missing[] = [ 'datasource_id' => $sourceId, 'title' => $title, 'reason' => 'File not found on disk: ' . $filename, ]; continue; } $extension = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION)); if ($extension === 'ipynb') { $missing[] = [ 'datasource_id' => $sourceId, 'title' => $title, 'reason' => 'Notebook files (.ipynb) are personal workspaces and are not shared via sync.', ]; continue; } $safeTitle = $this->sanitizeFileName($title); $targetBase = sprintf('%03d_%s', $index + 1, $safeTitle ?: ('datasource_' . $sourceId)); if ($fileIndex > 0) { $targetBase .= '_file' . ($fileIndex + 1); } $targetName = $extension ? "{$targetBase}.{$extension}" : $targetBase; $targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $targetName; if (file_exists($targetPath) || is_link($targetPath)) { unlink($targetPath); } $linked = @symlink($sourcePath, $targetPath); if (!$linked) { $linked = @copy($sourcePath, $targetPath); } if ($linked) { $synced[] = [ 'datasource_id' => $sourceId, 'title' => $title, 'source_filename' => $filename, 'relative_path' => $userFolderName . '/' . $targetName, 'absolute_path' => $targetPath, 'data_type' => $permission['data_type_name'] ?? null, 'category' => $permission['category_name'] ?? null, ]; } else { $missing[] = [ 'datasource_id' => $sourceId, 'title' => $title, 'reason' => 'Unable to sync file into workspace: ' . $filename, ]; } } } $readmePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'README.txt'; $readmeBody = "Approved data sources for R in JupyterHub are synced to this folder.\n" . "Each file is a symlink (or copy) of the original upload.\n" . "Only data sources with approved permissions for your account appear here.\n" . "Synced on: " . date('c') . "\n"; file_put_contents($readmePath, $readmeBody); $manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json'; file_put_contents($manifestPath, json_encode([ 'generated_at' => date('c'), 'synced' => $synced, 'missing' => $missing, ], JSON_PRETTY_PRINT)); return [ 'synced' => $synced, 'missing' => $missing, 'workspace_dir' => $userWorkspaceDir, ]; } /** * Removes previously synced artefacts without touching user-authored notebooks. * * @param string $userWorkspaceDir * @return void */ private function purgeSyncedArtifacts(string $userWorkspaceDir): void { $manifestPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . 'manifest.json'; if (is_readable($manifestPath)) { $manifest = json_decode((string) file_get_contents($manifestPath), true); if (isset($manifest['synced']) && is_array($manifest['synced'])) { foreach ($manifest['synced'] as $syncedItem) { if (empty($syncedItem['relative_path'])) { continue; } $basename = basename($syncedItem['relative_path']); if ($basename === '' || $basename === '.' || $basename === '..') { continue; } $targetPath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $basename; if (is_file($targetPath) || is_link($targetPath)) { @unlink($targetPath); } } } } foreach (['README.txt', 'manifest.json'] as $generatedFile) { $filePath = $userWorkspaceDir . DIRECTORY_SEPARATOR . $generatedFile; if (is_file($filePath)) { @unlink($filePath); } } // Legacy clean-up: remove any auto-synced notebooks that may still exist from older runs. // The sync process previously generated notebooks using a ###_ prefix; strip those out so // personal notebooks authored by the user (custom names) remain untouched. $iterator = new FilesystemIterator($userWorkspaceDir, FilesystemIterator::SKIP_DOTS); foreach ($iterator as $item) { if ($item->isFile() && preg_match('/^\d{3}_.+\.ipynb$/i', $item->getFilename())) { @unlink($item->getPathname()); } } } /** * Removes all files and folders within a directory without deleting the directory itself. * * @param string $directory * @return void */ private function clearDirectory(string $directory): void { if (!is_dir($directory)) { return; } $iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS); foreach ($iterator as $item) { if ($item->isDir()) { $this->deleteDirectory($item->getPathname()); } else { unlink($item->getPathname()); } } } /** * Recursively deletes a directory. * * @param string $path * @return void */ private function deleteDirectory(string $path): void { if (!is_dir($path)) { return; } $items = new FilesystemIterator($path, FilesystemIterator::SKIP_DOTS); foreach ($items as $item) { if ($item->isDir()) { $this->deleteDirectory($item->getPathname()); } else { unlink($item->getPathname()); } } rmdir($path); } /** * Sanitises a string for safe use as a file name. * * @param string $name * @return string */ private function sanitizeFileName(string $name): string { $sanitised = preg_replace('/[^a-zA-Z0-9-_]+/', '_', $name); return trim($sanitised, '_'); } // --- Usage Logging (dsps_tbl_anonymous, dsps_tbl_datasource_used) --- /** * Logs an anonymous view of a data source introduction. * * @param int $fkdspsds_id The ID of the data source viewed. * @param string|null $client_ip The IP address of the viewer. * @param string $action The action performed (e.g., 'View Introduction'). * @return bool True on success. * @throws Exception If a database error occurs. */ public function logAnonymousView(int $fkdspsds_id, ?string $client_ip, string $action): bool { $sql = "INSERT INTO dsps_tbl_anonymous (fkdspsds_id, dspsano_client_ip, dspsano_action) VALUES (:fkdspsds_id, :client_ip, :action)"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':fkdspsds_id', $fkdspsds_id, PDO::PARAM_INT); $stmt->bindParam(':client_ip', $client_ip); $stmt->bindParam(':action', $action); return $stmt->execute(); } catch (PDOException $e) { error_log("Error logging anonymous view for data source ($fkdspsds_id): " . $e->getMessage()); throw new Exception("Could not log anonymous view."); } } /** * Logs a registered user's usage of a data source. * * @param int $fkdspsdsused_id The ID of the data source used. * @param int $fkisp_id_of The person ID of the user who used it. * @param string $action The action performed (e.g., 'Downloaded', 'Accessed API', 'Ran Analysis'). * @param int $reg_by The user ID who performed the action. * @return bool True on success. * @throws Exception If a database error occurs. */ public function logDataSourceUsage(int $fkdspsdsused_id, int $fkisp_id_of, string $action, int $reg_by): bool { $sql = "INSERT INTO dsps_tbl_datasource_used (fkdspsdsused_id, fkisp_id_of, dspsdspused_action, dspsdspused_reg_by) VALUES (:fkdspsdsused_id, :fkisp_id_of, :action, :reg_by)"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':fkdspsdsused_id', $fkdspsdsused_id, PDO::PARAM_INT); $stmt->bindParam(':fkisp_id_of', $fkisp_id_of, PDO::PARAM_INT); $stmt->bindParam(':action', $action); $stmt->bindParam(':reg_by', $reg_by, PDO::PARAM_INT); return $stmt->execute(); } catch (PDOException $e) { error_log("Error logging data source usage for user ($fkisp_id_of) on data source ($fkdspsdsused_id): " . $e->getMessage()); throw new Exception("Could not log data source usage."); } } /** * Retrieves usage logs for a specific data source. * * @param int $data_source_id The ID of the data source. * @param string|null $action Optional: Filter by action. * @return array An array of usage log data. * @throws Exception If a database error occurs. */ public function getDataSourceUsageLogs(int $data_source_id, ?string $action = null): array { $sql = "SELECT dsu.*, p.isp_firstname_en, p.isp_lastname_en FROM dsps_tbl_datasource_used dsu JOIN ist_tbl_people p ON dsu.fkisp_id_of = p.pkisp_id WHERE dsu.fkdspsdsused_id = :data_source_id"; $params = [':data_source_id' => $data_source_id]; if ($action) { $sql .= " AND dsu.dspsdspused_action = :action"; $params[':action'] = $action; } $sql .= " ORDER BY dsu.dspsdspused_reg_datetime DESC"; try { $stmt = $this->pdo->prepare($sql); foreach ($params as $key => &$val) { $stmt->bindParam($key, $val, is_int($val) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching data source usage logs for ($data_source_id): " . $e->getMessage()); throw new Exception("Could not retrieve usage logs. Please try again later."); } } /** * Retrieves a user's download history. * * @param int $user_person_id The person ID of the user. * @return array An array of downloaded data sources. * @throws Exception If a database error occurs. */ public function getUserDownloads(int $user_person_id): array { $sql = "SELECT dsu.*, ds.dspsds_title_en, ds.dspsds_filename, dspstds.dspstds_name_en AS data_type_name, dspscate.dspscate_title_en AS category_name FROM dsps_tbl_datasource_used dsu JOIN dsps_tbl_datasource ds ON dsu.fkdspsdsused_id = ds.pkdspsds_id JOIN dsps_tbl_typedatasource dspstds ON ds.fkdspstds_id = dspstds.pkdspstds_id JOIN dsps_tbl_dspscategory dspscate ON ds.fkdspscate_id = dspscate.pkdspscate_id WHERE dsu.fkisp_id_of = :user_person_id AND dsu.dspsdspused_action = 'Downloaded' ORDER BY dsu.dspsdspused_reg_datetime DESC"; try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':user_person_id', $user_person_id, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching user downloads for ($user_person_id): " . $e->getMessage()); throw new Exception("Could not retrieve your downloads. Please try again later."); } } // --- Classification Methods (Moved from Classifications.php for simplicity, or keep separate) --- // Assuming these methods are here based on previous discussions. // If you have a separate Classifications.php, ensure these are in that file. /** * Retrieves all data types. * @return array * @throws Exception If a database error occurs. */ public function getAllDataTypes(): array { $sql = "SELECT * FROM dsps_tbl_typedatasource ORDER BY dspstds_name_en ASC"; try { $stmt = $this->pdo->query($sql); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching all data types: " . $e->getMessage()); throw new Exception("Could not retrieve data types. Please try again later."); } } public function getDataTypeById(int $typeId): ?array { $sql = "SELECT * FROM dsps_tbl_typedatasource WHERE pkdspstds_id = :type_id"; $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':type_id', $typeId, PDO::PARAM_INT); $stmt->execute(); $result = $stmt->fetch(PDO::FETCH_ASSOC); return $result ?: null; } /** * Retrieves all data categories. * @return array * @throws Exception If a database error occurs. */ public function getAllCategories(): array { $sql = "SELECT * FROM dsps_tbl_dspscategory ORDER BY dspscate_title_en ASC"; try { $stmt = $this->pdo->query($sql); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log("Error fetching all categories: " . $e->getMessage()); throw new Exception("Could not retrieve categories. Please try again later."); } } /** * Get a list of data sources, optionally filtered by category and search query. * @param int|null $categoryId The ID of the category to filter by. * @param string|null $searchQuery The search query to filter by title or description. * @return array An array of data source records. */ public function getFilteredDataSources($categoryId = null, $searchQuery = null) { try { // Use LEFT JOIN to get data from related tables. // All table and column names are now verified against the provided SQL dump. $sql = " SELECT ds.*, COALESCE(cat.dspscate_title_en, 'Not specified') AS category_name, COALESCE(dt.dspstds_name_en, 'Not specified') AS data_type_name, COALESCE(p.isp_firstname_en, 'Not specified') AS isp_firstname_en, COALESCE(p.isp_lastname_en, 'Not specified') AS isp_lastname_en FROM dsps_tbl_datasource AS ds LEFT JOIN dsps_tbl_dspscategory AS cat ON ds.fkdspscate_id = cat.pkdspscate_id LEFT JOIN dsps_tbl_typedatasource AS dt ON ds.fkdspstds_id = dt.pkdspstds_id LEFT JOIN ist_tbl_people AS p ON ds.fkisp_id_of = p.pkisp_id WHERE ds.dspsds_status = 'Active' "; $params = []; $whereClauses = []; if ($categoryId) { $whereClauses[] = "ds.fkdspscate_id = :category_id"; $params[':category_id'] = $categoryId; } if ($searchQuery) { $search = "%" . $searchQuery . "%"; $whereClauses[] = "(ds.dspsds_title_en LIKE :search OR ds.dspsds_description LIKE :search)"; $params[':search'] = $search; } if (!empty($whereClauses)) { $sql .= " AND " . implode(" AND ", $whereClauses); } // Order by title for a consistent display $sql .= " ORDER BY ds.dspsds_title_en ASC"; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { // Log the error and return an empty array to prevent further issues error_log("Database error in getFilteredDataSources: " . $e->getMessage()); return []; } } private function tableColumnExists(string $table, string $column): bool { $cacheKey = $table . '.' . $column; if (array_key_exists($cacheKey, $this->columnExistenceCache)) { return $this->columnExistenceCache[$cacheKey]; } if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) { return false; } $sql = sprintf('SHOW COLUMNS FROM `%s` LIKE :column', $table); try { $stmt = $this->pdo->prepare($sql); $stmt->bindParam(':column', $column, PDO::PARAM_STR); $stmt->execute(); $exists = (bool) $stmt->fetch(PDO::FETCH_ASSOC); } catch (PDOException $e) { error_log('Error checking column existence: ' . $e->getMessage()); // Assume column exists if we cannot verify to avoid silently skipping new features. $exists = true; } $this->columnExistenceCache[$cacheKey] = $exists; return $exists; } private function ensurePermissionProofColumn(): bool { $table = 'dsps_tbl_datasource_permission'; $column = 'dspsdsp_proof_path'; $cacheKey = $table . '.' . $column; if ($this->tableColumnExists($table, $column)) { return true; } $alterSql = "ALTER TABLE `{$table}` ADD COLUMN `{$column}` VARCHAR(255) DEFAULT NULL AFTER dspsdsp_notes"; try { $this->pdo->exec($alterSql); $this->columnExistenceCache[$cacheKey] = true; return true; } catch (PDOException $e) { error_log('Failed to add proof column: ' . $e->getMessage()); return false; } } }