diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index c2b02a6..c71112b 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -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" diff --git a/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php new file mode 100644 index 0000000..21df3a5 --- /dev/null +++ b/source/packages/plg_system_mokosuitefield/src/Helper/CustomerFeedbackHelper.php @@ -0,0 +1,132 @@ +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; + } +} diff --git a/source/pkg_mokosuitefield.xml b/source/pkg_mokosuitefield.xml index db5ea83..53306f5 100644 --- a/source/pkg_mokosuitefield.xml +++ b/source/pkg_mokosuitefield.xml @@ -2,7 +2,7 @@ Package - MokoSuite Field mokosuitefield - 01.02.00 + 01.02.01 2026-06-12 Moko Consulting hello@mokoconsulting.tech