feat: CustomerFeedbackHelper — NPS surveys #19

Merged
jmiller merged 3 commits from dev into main 2026-06-21 01:14:43 +00:00
3 changed files with 134 additions and 2 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# VERSION: 01.02.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -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;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuite Field</name>
<packagename>mokosuitefield</packagename>
<version>01.02.00</version>
<version>01.02.01</version>
<creationDate>2026-06-12</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>