Files
dsp/classes/DataSource.php
2026-01-29 14:31:48 +07:00

1490 lines
63 KiB
PHP

<?php
class DataSource {
private $pdo;
private $uploadDir;
private $columnExistenceCache = [];
private const DEFAULT_FILE_RULES = [
'extensions' => ['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<int, array{owner_person_id:int, owner_name:string, usage_count:int}>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>, missing: array<int, array<string, mixed>>, 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;
}
}
}