|
|
|
@@ -0,0 +1,132 @@
|
|
|
|
|
<?php
|
|
|
|
|
namespace Moko\Plugin\System\MokoSuiteField\Helper;
|
|
|
|
|
|
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
|
|
|
|
|
|
use Joomla\CMS\Factory;
|
|
|
|
|
use Joomla\Database\DatabaseInterface;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Customer feedback — post-service surveys, NPS scores, satisfaction tracking.
|
|
|
|
|
*/
|
|
|
|
|
class CustomerFeedbackHelper
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* Send a feedback request after work order completion.
|
|
|
|
|
*/
|
|
|
|
|
public static function requestFeedback(int $woId): object
|
|
|
|
|
{
|
|
|
|
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
|
|
|
|
|
|
|
|
$db->setQuery($db->getQuery(true)
|
|
|
|
|
->select('wo.id, wo.wo_number, wo.contact_id, cd.name AS customer_name, cd.email_to, cd.telephone')
|
|
|
|
|
->from($db->quoteName('#__mokosuitefield_work_orders', 'wo'))
|
|
|
|
|
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = wo.contact_id')
|
|
|
|
|
->where('wo.id = ' . (int) $woId));
|
|
|
|
|
$wo = $db->loadObject();
|
|
|
|
|
|
|
|
|
|
if (!$wo || !$wo->email_to) {
|
|
|
|
|
return (object) ['success' => false, 'error' => 'No email for feedback request'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate unique feedback token
|
|
|
|
|
$token = bin2hex(random_bytes(16));
|
|
|
|
|
|
|
|
|
|
$request = (object) [
|
|
|
|
|
'wo_id' => $woId,
|
|
|
|
|
'contact_id' => $wo->contact_id,
|
|
|
|
|
'token' => $token,
|
|
|
|
|
'status' => 'sent',
|
|
|
|
|
'sent_at' => Factory::getDate()->toSql(),
|
|
|
|
|
];
|
|
|
|
|
$db->insertObject('#__mokosuitefield_feedback_requests', $request, 'id');
|
|
|
|
|
|
|
|
|
|
return (object) ['success' => true, 'token' => $token, 'email' => $wo->email_to];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Submit feedback (called from public survey page via token).
|
|
|
|
|
*/
|
|
|
|
|
public static function submitFeedback(string $token, int $rating, int $npsScore, string $comments = ''): object
|
|
|
|
|
{
|
|
|
|
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
|
|
|
|
|
|
|
|
// Validate inputs before DB access
|
|
|
|
|
$rating = max(1, min(5, $rating));
|
|
|
|
|
$npsScore = max(0, min(10, $npsScore));
|
|
|
|
|
|
|
|
|
|
$filter = \Joomla\Filter\InputFilter::getInstance();
|
|
|
|
|
$comments = $filter->clean($comments, 'STRING');
|
|
|
|
|
|
|
|
|
|
$db->transactionStart();
|
|
|
|
|
try {
|
|
|
|
|
// Lock the request row to prevent race condition on token reuse
|
|
|
|
|
$db->setQuery('SELECT id, wo_id, contact_id, status FROM #__mokosuitefield_feedback_requests WHERE '
|
|
|
|
|
. $db->quoteName('token') . ' = ' . $db->quote($token) . ' FOR UPDATE');
|
|
|
|
|
$request = $db->loadObject();
|
|
|
|
|
|
|
|
|
|
if (!$request) {
|
|
|
|
|
$db->transactionRollback();
|
|
|
|
|
return (object) ['success' => false, 'error' => 'Invalid feedback link'];
|
|
|
|
|
}
|
|
|
|
|
if ($request->status === 'completed') {
|
|
|
|
|
$db->transactionRollback();
|
|
|
|
|
return (object) ['success' => false, 'error' => 'Feedback already submitted'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$feedback = (object) [
|
|
|
|
|
'request_id' => $request->id,
|
|
|
|
|
'wo_id' => $request->wo_id,
|
|
|
|
|
'contact_id' => $request->contact_id,
|
|
|
|
|
'rating' => $rating,
|
|
|
|
|
'nps_score' => $npsScore,
|
|
|
|
|
'comments' => $comments,
|
|
|
|
|
'submitted_at' => Factory::getDate()->toSql(),
|
|
|
|
|
];
|
|
|
|
|
$db->insertObject('#__mokosuitefield_feedback', $feedback);
|
|
|
|
|
|
|
|
|
|
$db->setQuery($db->getQuery(true)
|
|
|
|
|
->update('#__mokosuitefield_feedback_requests')
|
|
|
|
|
->set($db->quoteName('status') . ' = ' . $db->quote('completed'))
|
|
|
|
|
->where('id = ' . (int) $request->id));
|
|
|
|
|
$db->execute();
|
|
|
|
|
|
|
|
|
|
$db->transactionCommit();
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$db->transactionRollback();
|
|
|
|
|
// Don't leak internal errors to public users
|
|
|
|
|
return (object) ['success' => false, 'error' => 'Unable to save feedback. Please try again.'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (object) ['success' => true, 'rating' => $rating, 'nps' => $npsScore];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get NPS score and satisfaction summary.
|
|
|
|
|
*/
|
|
|
|
|
public static function getSatisfactionSummary(string $from = '', string $to = ''): object
|
|
|
|
|
{
|
|
|
|
|
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
|
|
|
|
$from = $from ?: date('Y-01-01');
|
|
|
|
|
$to = $to ?: date('Y-m-d');
|
|
|
|
|
|
|
|
|
|
$db->setQuery($db->getQuery(true)
|
|
|
|
|
->select('COUNT(*) AS total_responses')
|
|
|
|
|
->select('COALESCE(AVG(rating), 0) AS avg_rating')
|
|
|
|
|
->select('COALESCE(AVG(nps_score), 0) AS avg_nps')
|
|
|
|
|
->select('SUM(CASE WHEN nps_score >= 9 THEN 1 ELSE 0 END) AS promoters')
|
|
|
|
|
->select('SUM(CASE WHEN nps_score BETWEEN 7 AND 8 THEN 1 ELSE 0 END) AS passives')
|
|
|
|
|
->select('SUM(CASE WHEN nps_score <= 6 THEN 1 ELSE 0 END) AS detractors')
|
|
|
|
|
->from('#__mokosuitefield_feedback')
|
|
|
|
|
->where('DATE(submitted_at) BETWEEN ' . $db->quote($from) . ' AND ' . $db->quote($to)));
|
|
|
|
|
|
|
|
|
|
$stats = $db->loadObject() ?: (object) ['total_responses' => 0, 'avg_rating' => 0, 'avg_nps' => 0, 'promoters' => 0, 'passives' => 0, 'detractors' => 0];
|
|
|
|
|
|
|
|
|
|
$total = (int) $stats->total_responses;
|
|
|
|
|
$stats->nps_score = $total > 0
|
|
|
|
|
? round(((int) $stats->promoters - (int) $stats->detractors) / $total * 100)
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
return $stats;
|
|
|
|
|
}
|
|
|
|
|
}
|