dga_feedback-2.0.0/src/Service/DgaFeedbackService.php
src/Service/DgaFeedbackService.php
<?php
namespace Drupal\dga_feedback\Service;
use Drupal\Core\Database\Connection;
/**
* Service for managing feedback data and calculations.
*/
class DgaFeedbackService {
/**
* The database connection.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* Constructs a DgaFeedbackService object.
*
* @param \Drupal\Core\Database\Connection $database
* The database connection.
*/
public function __construct(Connection $database) {
$this->database = $database;
}
/**
* Saves a feedback submission.
*
* @param array $data
* Array containing:
* - entity_type: The entity type (default: 'node')
* - entity_id: The entity ID
* - is_useful: "yes" or "no"
* - reasons: Array of selected reasons
* - feedback: Optional feedback text
* - gender: Optional gender selection
* - url: The URL where feedback was submitted
* - user_id: Optional user ID
* - ip_address: Optional IP address
*
* @return int|false
* The ID of the inserted record, or FALSE on failure.
*/
public function saveFeedback(array $data) {
try {
// Normalize URL
$url = $data['url'] ?? '/';
$url = trim($url);
if (empty($url) || $url === '/') {
$url = '/';
} else {
// Remove language prefix if still present.
if (preg_match('#^/[a-z]{2}(/.*)?$#', $url, $matches)) {
$url = isset($matches[1]) ? $matches[1] : '/';
if (empty($url)) {
$url = '/';
}
}
// Remove trailing slash.
$url = rtrim($url, '/');
if (empty($url)) {
$url = '/';
}
}
// SECURITY: Sanitize reasons array (already sanitized in controller, but double-check here)
// Get configurable limits from settings
$config = \Drupal::config('dga_feedback.settings');
$reason_max_length = (int) ($config->get('reason_max_length') ?? 200);
$reason_max_count = (int) ($config->get('reason_max_count') ?? 10);
$feedback_max_length = (int) ($config->get('feedback_max_length') ?? 5000);
$reasons_data = isset($data['reasons']) && is_array($data['reasons']) ? $data['reasons'] : [];
$sanitized_reasons = [];
foreach ($reasons_data as $reason) {
if (is_string($reason)) {
$reason = trim(strip_tags($reason));
if (strlen($reason) > 0 && strlen($reason) <= $reason_max_length) {
$sanitized_reasons[] = $reason;
}
}
if (count($sanitized_reasons) >= $reason_max_count) {
break; // Limit to configured max reasons
}
}
// SECURITY: Sanitize feedback text (already sanitized in controller, but double-check here)
$feedback_text = isset($data['feedback']) ? trim($data['feedback']) : '';
$feedback_text = strip_tags($feedback_text);
if (strlen($feedback_text) > $feedback_max_length) {
$feedback_text = substr($feedback_text, 0, $feedback_max_length);
}
$fields = [
'entity_type' => $data['entity_type'] ?? 'node',
'entity_id' => (int) ($data['entity_id'] ?? 0),
'is_useful' => $data['is_useful'] === 'yes' ? 'yes' : 'no',
'reasons' => !empty($sanitized_reasons) ? json_encode($sanitized_reasons) : '',
'feedback' => $feedback_text,
'gender' => isset($data['gender']) && in_array($data['gender'], ['male', 'female']) ? $data['gender'] : NULL,
'url' => $url,
'user_id' => $data['user_id'] ?? NULL, // NULL is valid for anonymous
'ip_address' => $data['ip_address'] ?? NULL,
'created' => time(),
];
// Validate is_useful value.
if (!in_array($fields['is_useful'], ['yes', 'no'])) {
\Drupal::logger('dga_feedback')->error('Invalid is_useful value: @value', ['@value' => $data['is_useful'] ?? 'missing']);
return FALSE;
}
// Validate required fields
if (empty($fields['url'])) {
\Drupal::logger('dga_feedback')->error('Empty URL field');
return FALSE;
}
try {
// Direct insert - Drupal's database layer auto-commits
// Transaction wrapper can cause issues with anonymous users
// Do NOT wrap in transaction - direct insert works better for anonymous
$feedback_id = $this->database->insert('dga_feedback')
->fields($fields)
->execute();
if ($feedback_id) {
// Invalidate cache tags after saving feedback.
\Drupal::service('cache_tags.invalidator')->invalidateTags(['dga_feedback:submissions']);
}
} catch (\Exception $e) {
\Drupal::logger('dga_feedback')->error('Exception saving feedback: @message, Trace: @trace', [
'@message' => $e->getMessage(),
'@trace' => $e->getTraceAsString(),
]);
throw $e;
}
return $feedback_id;
}
catch (\Exception $e) {
\Drupal::logger('dga_feedback')->error('Error saving feedback: @message, Trace: @trace', [
'@message' => $e->getMessage(),
'@trace' => $e->getTraceAsString(),
]);
return FALSE;
}
catch (\Throwable $e) {
\Drupal::logger('dga_feedback')->error('Fatal error saving feedback: @message, Trace: @trace', [
'@message' => $e->getMessage(),
'@trace' => $e->getTraceAsString(),
]);
return FALSE;
}
}
/**
* Gets feedback statistics for a specific entity or URL.
*
* @param string|null $entity_type
* The entity type to filter by (optional).
* @param int|null $entity_id
* The entity ID to filter by (optional).
* @param string|null $url
* The URL to filter by (optional).
*
* @return array
* Array with:
* - yes_count: Number of "yes" responses (int)
* - no_count: Number of "no" responses (int)
* - total_count: Total number of feedbacks (int)
* - yes_percentage: Percentage of "yes" responses (float)
*/
public function getStatistics($entity_type = NULL, $entity_id = NULL, $url = NULL) {
$query = $this->database->select('dga_feedback', 'f');
$query->addExpression('COUNT(f.id)', 'total_count');
$query->addExpression('SUM(CASE WHEN f.is_useful = \'yes\' THEN 1 ELSE 0 END)', 'yes_count');
$query->addExpression('SUM(CASE WHEN f.is_useful = \'no\' THEN 1 ELSE 0 END)', 'no_count');
// Match by entity OR URL when both are provided
if ($entity_type && $entity_id !== NULL && $entity_id > 0 && $url) {
$or_group = $query->orConditionGroup();
$or_group->condition(
$query->andConditionGroup()
->condition('f.entity_type', $entity_type)
->condition('f.entity_id', $entity_id)
);
$normalized_url = rtrim($url, '/') ?: '/';
if ($normalized_url === '/') {
$or_group->condition('f.url', '/');
} else {
$or_group->condition(
$query->orConditionGroup()
->condition('f.url', $normalized_url)
->condition('f.url', $normalized_url . '/')
);
}
$query->condition($or_group);
}
elseif ($entity_type && $entity_id !== NULL && $entity_id > 0) {
$query->condition('f.entity_type', $entity_type);
$query->condition('f.entity_id', $entity_id);
}
elseif ($url) {
$normalized_url = rtrim($url, '/') ?: '/';
$conditions = $query->orConditionGroup();
$conditions->condition('f.url', $normalized_url);
if ($normalized_url !== '/') {
$conditions->condition('f.url', $normalized_url . '/');
}
if (preg_match('#^/[a-z]{2}/(.+)$#', $normalized_url, $matches)) {
$without_lang = '/' . $matches[1];
$conditions->condition('f.url', $without_lang);
if ($without_lang !== '/') {
$conditions->condition('f.url', $without_lang . '/');
}
}
else if ($normalized_url !== '/') {
$lang_prefixes = ['/en', '/ar'];
foreach ($lang_prefixes as $prefix) {
$with_lang = $prefix . $normalized_url;
$conditions->condition('f.url', $with_lang);
$conditions->condition('f.url', $with_lang . '/');
}
}
$query->condition($conditions);
}
$result = $query->execute()->fetchObject();
// Handle empty result set.
if (!$result || $result->total_count == 0) {
return [
'yes_count' => 0,
'no_count' => 0,
'total_count' => 0,
'yes_percentage' => 0.0,
];
}
$total_count = (int) $result->total_count;
$yes_count = (int) ($result->yes_count ?? 0);
$no_count = (int) ($result->no_count ?? 0);
$yes_percentage = $total_count > 0 ? (($yes_count / $total_count) * 100) : 0.0;
return [
'yes_count' => $yes_count,
'no_count' => $no_count,
'total_count' => $total_count,
'yes_percentage' => round($yes_percentage, 1),
];
}
/**
* Gets feedback statistics for the current page URL.
*
* @param string $url
* The URL path.
*
* @return array
* Array with statistics.
*/
public function getStatisticsByUrl($url) {
return $this->getStatistics(NULL, NULL, $url);
}
/**
* Gets feedback statistics for a specific entity.
*
* @param string $entity_type
* The entity type.
* @param int $entity_id
* The entity ID.
*
* @return array
* Array with statistics.
*/
public function getStatisticsByEntity($entity_type, $entity_id) {
return $this->getStatistics($entity_type, $entity_id, NULL);
}
/**
* Gets overall statistics across all feedbacks.
*
* @return array
* Array with statistics for all feedbacks.
*/
public function getOverallStatistics() {
return $this->getStatistics(NULL, NULL, NULL);
}
/**
* Gets all feedback submissions with pagination.
*
* @param int $limit
* Number of items per page.
* @param int $offset
* Offset for pagination.
* @param array $filters
* Optional filters.
* @param string $sort_by
* Field to sort by (default: 'created').
* @param string $sort_direction
* Sort direction: 'ASC' or 'DESC' (default: 'DESC').
*
* @return array
* Array of feedback submissions.
*/
public function getAllSubmissions($limit = 50, $offset = 0, array $filters = [], $sort_by = 'created', $sort_direction = 'DESC') {
$query = $this->database->select('dga_feedback', 'f');
$query->fields('f');
// Validate sort parameters.
$valid_sort_fields = ['id', 'is_useful', 'created', 'url', 'entity_type', 'entity_id', 'user_id'];
if (!in_array($sort_by, $valid_sort_fields)) {
$sort_by = 'created';
}
$sort_direction = strtoupper($sort_direction);
if ($sort_direction !== 'ASC' && $sort_direction !== 'DESC') {
$sort_direction = 'DESC';
}
$query->orderBy('f.' . $sort_by, $sort_direction);
$query->range($offset, $limit);
// Apply filters.
if (isset($filters['id']) && $filters['id'] > 0) {
$query->condition('f.id', $filters['id']);
}
if (!empty($filters['entity_type'])) {
$query->condition('f.entity_type', $filters['entity_type']);
}
if (isset($filters['entity_id']) && $filters['entity_id'] > 0) {
$query->condition('f.entity_id', (int) $filters['entity_id']);
}
if (!empty($filters['url'])) {
$query->condition('f.url', '%' . $this->database->escapeLike($filters['url']) . '%', 'LIKE');
}
if (!empty($filters['is_useful'])) {
$query->condition('f.is_useful', $filters['is_useful']);
}
if (!empty($filters['feedback'])) {
$query->condition('f.feedback', '%' . $this->database->escapeLike($filters['feedback']) . '%', 'LIKE');
}
if (isset($filters['user_id']) && $filters['user_id'] !== NULL && $filters['user_id'] !== '') {
$query->condition('f.user_id', (int) $filters['user_id']);
}
if (!empty($filters['ip_address'])) {
$query->condition('f.ip_address', '%' . $this->database->escapeLike($filters['ip_address']) . '%', 'LIKE');
}
if (!empty($filters['date_from'])) {
$query->condition('f.created', $filters['date_from'], '>=');
}
if (!empty($filters['date_to'])) {
$query->condition('f.created', $filters['date_to'], '<=');
}
$results = $query->execute()->fetchAll(\PDO::FETCH_ASSOC);
// Decode JSON reasons
foreach ($results as &$result) {
if (!empty($result['reasons'])) {
$decoded = json_decode($result['reasons'], TRUE);
$result['reasons'] = is_array($decoded) ? $decoded : [];
} else {
$result['reasons'] = [];
}
}
return $results;
}
/**
* Gets total count of submissions with optional filters.
*
* @param array $filters
* Optional filters (same as getAllSubmissions).
*
* @return int
* Total count of submissions.
*/
public function getSubmissionsCount(array $filters = []) {
$query = $this->database->select('dga_feedback', 'f');
$query->addExpression('COUNT(f.id)', 'count');
// Apply same filters as getAllSubmissions.
if (isset($filters['id']) && $filters['id'] > 0) {
$query->condition('f.id', $filters['id']);
}
if (!empty($filters['entity_type'])) {
$query->condition('f.entity_type', $filters['entity_type']);
}
if (isset($filters['entity_id']) && $filters['entity_id'] > 0) {
$query->condition('f.entity_id', (int) $filters['entity_id']);
}
if (!empty($filters['url'])) {
$query->condition('f.url', '%' . $this->database->escapeLike($filters['url']) . '%', 'LIKE');
}
if (!empty($filters['is_useful'])) {
$query->condition('f.is_useful', $filters['is_useful']);
}
if (!empty($filters['feedback'])) {
$query->condition('f.feedback', '%' . $this->database->escapeLike($filters['feedback']) . '%', 'LIKE');
}
if (isset($filters['user_id']) && $filters['user_id'] !== NULL && $filters['user_id'] !== '') {
$query->condition('f.user_id', (int) $filters['user_id']);
}
if (!empty($filters['ip_address'])) {
$query->condition('f.ip_address', '%' . $this->database->escapeLike($filters['ip_address']) . '%', 'LIKE');
}
if (!empty($filters['date_from'])) {
$query->condition('f.created', $filters['date_from'], '>=');
}
if (!empty($filters['date_to'])) {
$query->condition('f.created', $filters['date_to'], '<=');
}
$result = $query->execute()->fetchField();
return (int) $result;
}
/**
* Gets statistics grouped by usefulness (yes/no).
*
* @return array
* Array with 'yes' and 'no' counts.
*/
public function getUsefulnessDistribution() {
$query = $this->database->select('dga_feedback', 'f');
$query->addField('f', 'is_useful');
$query->addExpression('COUNT(f.id)', 'count');
$query->groupBy('f.is_useful');
$query->orderBy('f.is_useful', 'ASC');
$results = $query->execute()->fetchAll();
$distribution = ['yes' => 0, 'no' => 0];
foreach ($results as $result) {
$useful = $result->is_useful ?? '';
if (in_array($useful, ['yes', 'no'])) {
$distribution[$useful] = (int) $result->count;
}
}
return $distribution;
}
/**
* Gets a single submission by ID.
*
* @param int $id
* The submission ID.
*
* @return array|false
* Array of submission data or FALSE if not found.
*/
public function getSubmissionById($id) {
$query = $this->database->select('dga_feedback', 'f');
$query->fields('f');
$query->condition('f.id', (int) $id);
$result = $query->execute()->fetchAssoc();
if ($result) {
// Decode JSON reasons
if (!empty($result['reasons'])) {
$decoded = json_decode($result['reasons'], TRUE);
$result['reasons'] = is_array($decoded) ? $decoded : [];
} else {
$result['reasons'] = [];
}
}
return $result ? $result : FALSE;
}
/**
* Deletes a submission by ID.
*
* @param int $id
* The submission ID.
*
* @return bool
* TRUE if deleted, FALSE otherwise.
*/
public function deleteSubmission($id) {
try {
$deleted = $this->database->delete('dga_feedback')
->condition('id', (int) $id)
->execute();
if ($deleted) {
// Invalidate cache tags after deleting.
\Drupal::service('cache_tags.invalidator')->invalidateTags(['dga_feedback:submissions']);
}
return $deleted > 0;
} catch (\Exception $e) {
\Drupal::logger('dga_feedback')->error('Error deleting feedback: @message', [
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Deletes multiple submissions by IDs.
*
* @param array $ids
* Array of submission IDs.
*
* @return int
* Number of submissions deleted.
*/
public function bulkDeleteSubmissions(array $ids) {
if (empty($ids)) {
return 0;
}
try {
$ids = array_filter(array_map('intval', $ids), function($id) {
return $id > 0;
});
if (empty($ids)) {
return 0;
}
$deleted = $this->database->delete('dga_feedback')
->condition('id', $ids, 'IN')
->execute();
if ($deleted > 0) {
// Invalidate cache tags after deleting.
\Drupal::service('cache_tags.invalidator')->invalidateTags(['dga_feedback:submissions']);
}
return $deleted;
} catch (\Exception $e) {
\Drupal::logger('dga_feedback')->error('Error bulk deleting feedback: @message', [
'@message' => $e->getMessage(),
]);
return 0;
}
}
/**
* Updates a submission.
*
* @param int $id
* The submission ID.
* @param array $data
* Array of fields to update.
*
* @return bool
* TRUE if updated, FALSE otherwise.
*/
public function updateSubmission($id, array $data) {
try {
$fields = [];
if (isset($data['is_useful']) && in_array($data['is_useful'], ['yes', 'no'])) {
$fields['is_useful'] = $data['is_useful'];
}
if (isset($data['reasons']) && is_array($data['reasons'])) {
$fields['reasons'] = json_encode($data['reasons']);
}
if (isset($data['feedback'])) {
$fields['feedback'] = trim($data['feedback']);
}
if (isset($data['gender']) && in_array($data['gender'], ['male', 'female', NULL])) {
$fields['gender'] = $data['gender'];
}
if (empty($fields)) {
return FALSE;
}
$updated = $this->database->update('dga_feedback')
->fields($fields)
->condition('id', (int) $id)
->execute();
if ($updated !== FALSE) {
// Invalidate cache tags after updating.
\Drupal::service('cache_tags.invalidator')->invalidateTags(['dga_feedback:submissions']);
}
return $updated !== FALSE;
} catch (\Exception $e) {
\Drupal::logger('dga_feedback')->error('Error updating feedback: @message', [
'@message' => $e->getMessage(),
]);
return FALSE;
}
}
/**
* Gets the total count of unique URLs.
*
* @return int
* Total number of unique URLs.
*/
public function getUniqueUrlCount() {
$query = $this->database->select('dga_feedback', 'f');
$query->addExpression('COUNT(DISTINCT f.url)', 'count');
$result = $query->execute()->fetchField();
return (int) $result;
}
/**
* Gets statistics grouped by URL.
*
* @param int $limit
* Maximum number of URLs to return (default: 50).
* @param string $order_by
* Field to order by: 'count', 'yes_percentage', or 'url' (default: 'count').
* @param string $order_direction
* Order direction: 'ASC' or 'DESC' (default: 'DESC').
*
* @return array
* Array of URL statistics.
*/
public function getStatisticsGroupedByUrl($limit = 50, $order_by = 'count', $order_direction = 'DESC') {
$query = $this->database->select('dga_feedback', 'f');
$query->addField('f', 'url');
$query->addExpression('COUNT(f.id)', 'count');
$query->addExpression('SUM(CASE WHEN f.is_useful = \'yes\' THEN 1 ELSE 0 END)', 'yes_count');
$query->addExpression('SUM(CASE WHEN f.is_useful = \'no\' THEN 1 ELSE 0 END)', 'no_count');
$query->groupBy('f.url');
$valid_order_fields = ['count', 'yes_percentage', 'url'];
if (!in_array($order_by, $valid_order_fields)) {
$order_by = 'count';
}
$order_direction = strtoupper($order_direction);
if ($order_direction !== 'ASC' && $order_direction !== 'DESC') {
$order_direction = 'DESC';
}
// yes_percentage is calculated, not a database column, so we can't order by it in SQL
// We'll fetch more results and sort in PHP if needed
$fetch_limit = ($order_by === 'yes_percentage') ? $limit * 10 : $limit;
if ($order_by === 'url') {
$query->orderBy('f.url', $order_direction);
} elseif ($order_by === 'count') {
$query->orderBy('count', $order_direction);
}
// For yes_percentage, we'll order by count as fallback and sort in PHP
$query->range(0, $fetch_limit);
$results = $query->execute()->fetchAll();
$url_stats = [];
foreach ($results as $result) {
$url = $result->url ?? '/';
$count = (int) $result->count;
$yes_count = (int) ($result->yes_count ?? 0);
$no_count = (int) ($result->no_count ?? 0);
$yes_percentage = $count > 0 ? (($yes_count / $count) * 100) : 0.0;
$url_stats[] = [
'url' => $url,
'count' => $count,
'yes_count' => $yes_count,
'no_count' => $no_count,
'yes_percentage' => round($yes_percentage, 1),
];
}
// Sort by yes_percentage if needed (since it's calculated)
if ($order_by === 'yes_percentage') {
usort($url_stats, function($a, $b) use ($order_direction) {
if ($order_direction === 'DESC') {
return $b['yes_percentage'] <=> $a['yes_percentage'];
} else {
return $a['yes_percentage'] <=> $b['yes_percentage'];
}
});
// Limit to requested limit after sorting
$url_stats = array_slice($url_stats, 0, $limit);
}
return $url_stats;
}
/**
* Gets most useful page (highest yes percentage).
*
* @return array|null
* Array with url, yes_percentage, and count, or NULL if no feedback exists.
*/
public function getMostUsefulPage() {
$stats = $this->getStatisticsGroupedByUrl(1, 'yes_percentage', 'DESC');
return !empty($stats) ? $stats[0] : NULL;
}
/**
* Gets most feedback page (highest count).
*
* @return array|null
* Array with url, yes_percentage, and count, or NULL if no feedback exists.
*/
public function getMostFeedbackPage() {
$stats = $this->getStatisticsGroupedByUrl(1, 'count', 'DESC');
return !empty($stats) ? $stats[0] : NULL;
}
/**
* Gets recent activity statistics (last 7 days, last 30 days).
*
* @return array
* Array with last_7_days and last_30_days statistics.
*/
public function getRecentActivity() {
$now = time();
$seven_days_ago = $now - (7 * 24 * 60 * 60);
$thirty_days_ago = $now - (30 * 24 * 60 * 60);
// Last 7 days
$query_7 = $this->database->select('dga_feedback', 'f');
$query_7->addExpression('COUNT(f.id)', 'count');
$query_7->addExpression('SUM(CASE WHEN f.is_useful = \'yes\' THEN 1 ELSE 0 END)', 'yes_count');
$query_7->condition('f.created', $seven_days_ago, '>=');
$result_7 = $query_7->execute()->fetchObject();
// Last 30 days
$query_30 = $this->database->select('dga_feedback', 'f');
$query_30->addExpression('COUNT(f.id)', 'count');
$query_30->addExpression('SUM(CASE WHEN f.is_useful = \'yes\' THEN 1 ELSE 0 END)', 'yes_count');
$query_30->condition('f.created', $thirty_days_ago, '>=');
$result_30 = $query_30->execute()->fetchObject();
$count_7 = (int) ($result_7->count ?? 0);
$yes_count_7 = (int) ($result_7->yes_count ?? 0);
$useful_percentage_7 = $count_7 > 0 ? (($yes_count_7 / $count_7) * 100) : 0.0;
$count_30 = (int) ($result_30->count ?? 0);
$yes_count_30 = (int) ($result_30->yes_count ?? 0);
$useful_percentage_30 = $count_30 > 0 ? (($yes_count_30 / $count_30) * 100) : 0.0;
return [
'last_7_days' => [
'count' => $count_7,
'useful_percentage' => round($useful_percentage_7, 1),
],
'last_30_days' => [
'count' => $count_30,
'useful_percentage' => round($useful_percentage_30, 1),
],
];
}
/**
* Gets useful feedback percentage (yes responses).
*
* @return float
* Percentage of useful feedback (0-100).
*/
public function getUsefulPercentage() {
$stats = $this->getOverallStatistics();
return $stats['yes_percentage'] ?? 0.0;
}
/**
* Gets submissions count by user type (anonymous vs authenticated).
*
* @return array
* Array with anonymous and authenticated counts.
*/
public function getSubmissionsByUserType() {
$anonymous_query = $this->database->select('dga_feedback', 'f');
$anonymous_query->addExpression('COUNT(f.id)', 'count');
$anonymous_query->isNull('f.user_id');
$anonymous_count = (int) $anonymous_query->execute()->fetchField();
$auth_query = $this->database->select('dga_feedback', 'f');
$auth_query->addExpression('COUNT(f.id)', 'count');
$auth_query->isNotNull('f.user_id');
$auth_count = (int) $auth_query->execute()->fetchField();
return [
'anonymous' => $anonymous_count,
'authenticated' => $auth_count,
];
}
}
