瀏覽代碼

feat: added the following routes: POST /me/change-password, GET /sla/configs, POST /sla/configs, GET /sla/live-status

EduLascala 2 周之前
父節點
當前提交
f95e693627

+ 68 - 0
controllers/MeChangePasswordController.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Payload;
+use Libs\Validator;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class MeChangePasswordController
+{
+    private UserModel $userModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        if ($userId <= 0) {
+            return Payload::fail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE', 401);
+        }
+
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $currentPassword = (string) ($body['currentPassword'] ?? '');
+        $newPassword = (string) ($body['newPassword'] ?? '');
+        $confirmPassword = (string) ($body['confirmPassword'] ?? '');
+
+        $validator = (new Validator([
+            'currentPassword' => $currentPassword,
+            'newPassword' => $newPassword,
+            'confirmPassword' => $confirmPassword,
+        ]))
+            ->required('currentPassword')->minLength('currentPassword', 8)->maxLength('currentPassword', 255)
+            ->required('newPassword')->minLength('newPassword', 8)->maxLength('newPassword', 255)
+            ->required('confirmPassword')->minLength('confirmPassword', 8)->maxLength('confirmPassword', 255);
+
+        if ($validator->fails()) {
+            return Payload::fail($validator->firstError() ?? 'Invalid payload', [], 'E_VALIDATE', 400);
+        }
+
+        if ($newPassword !== $confirmPassword) {
+            return Payload::fail('New password and confirmation do not match', [], 'E_VALIDATE', 400);
+        }
+
+        if ($currentPassword === $newPassword) {
+            return Payload::fail('New password must be different from current password', [], 'E_VALIDATE', 400);
+        }
+
+        $result = $this->userModel->changePassword($userId, $currentPassword, $newPassword);
+
+        if ($result === 'not_found') {
+            return Payload::fail('User not found', [], 'E_NOT_FOUND', 404);
+        }
+
+        if ($result === 'invalid_current_password') {
+            return Payload::fail('Current password is invalid', [], 'E_VALIDATE', 400);
+        }
+
+        if ($result === 'error') {
+            return Payload::fail('Failed to update password', [], 'E_GENERIC', 500);
+        }
+
+        return Payload::ok([], 'S_OK', 'Password updated.');
+    }
+}

+ 39 - 0
controllers/SlaConfigsController.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Payload;
+use Models\SlaConfigsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class SlaConfigsController
+{
+    private UserModel $userModel;
+    private SlaConfigsModel $slaConfigsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->slaConfigsModel = new SlaConfigsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        if ($userId <= 0) {
+            return Payload::fail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE', 401);
+        }
+
+        $companyId = $this->userModel->getCompanyIdByUserId($userId);
+        if ($companyId === null) {
+            return Payload::fail('User not found', [], 'E_NOT_FOUND', 404);
+        }
+
+        try {
+            return Payload::ok($this->slaConfigsModel->getConfigs($companyId));
+        } catch (\Throwable $e) {
+            return Payload::fail('Failed to load SLA configs', [], 'E_GENERIC', 500);
+        }
+    }
+}

+ 39 - 0
controllers/SlaLiveStatusController.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Payload;
+use Models\SlaConfigsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class SlaLiveStatusController
+{
+    private UserModel $userModel;
+    private SlaConfigsModel $slaConfigsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->slaConfigsModel = new SlaConfigsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        if ($userId <= 0) {
+            return Payload::fail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE', 401);
+        }
+
+        $companyId = $this->userModel->getCompanyIdByUserId($userId);
+        if ($companyId === null) {
+            return Payload::fail('User not found', [], 'E_NOT_FOUND', 404);
+        }
+
+        try {
+            return Payload::ok($this->slaConfigsModel->getLiveStatus($companyId));
+        } catch (\Throwable $e) {
+            return Payload::fail('Failed to load SLA live status', [], 'E_GENERIC', 500);
+        }
+    }
+}

+ 91 - 0
controllers/SlaSaveConfigController.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace Controllers;
+
+use Libs\Payload;
+use Libs\Validator;
+use Models\SlaConfigsModel;
+use Models\UserModel;
+use Psr\Http\Message\ServerRequestInterface;
+
+class SlaSaveConfigController
+{
+    private UserModel $userModel;
+    private SlaConfigsModel $slaConfigsModel;
+
+    public function __construct()
+    {
+        $this->userModel = new UserModel();
+        $this->slaConfigsModel = new SlaConfigsModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $userId = (int) ($request->getAttribute('user_id') ?? 0);
+        if ($userId <= 0) {
+            return Payload::fail('Unauthorized: Missing authenticated user', [], 'E_VALIDATE', 401);
+        }
+
+        $body = json_decode((string) $request->getBody(), true) ?: [];
+        $id = trim((string) ($body['id'] ?? ''));
+        $name = trim((string) ($body['name'] ?? ''));
+        $firstResponseH = $body['firstResponseH'] ?? null;
+        $firstResponseM = $body['firstResponseM'] ?? null;
+        $resolutionH = $body['resolutionH'] ?? null;
+        $alertPct = $body['alertPct'] ?? null;
+
+        $validator = (new Validator([
+            'firstResponseH' => $firstResponseH,
+            'firstResponseM' => $firstResponseM,
+            'resolutionH' => $resolutionH,
+            'alertPct' => $alertPct,
+            'name' => $name,
+        ]))
+            ->required('firstResponseH')->intRange('firstResponseH', 0, 72)
+            ->required('firstResponseM')->intRange('firstResponseM', 0, 59)
+            ->required('resolutionH')->intRange('resolutionH', 1, 720)
+            ->required('alertPct')->intRange('alertPct', 1, 99)
+            ->maxLength('name', 20);
+
+        if ($id === '') {
+            $validator->required('name');
+        }
+
+        if ($validator->fails()) {
+            return Payload::fail($validator->firstError() ?? 'Invalid payload', [], 'E_VALIDATE', 400);
+        }
+
+        $companyId = $this->userModel->getCompanyIdByUserId($userId);
+        if ($companyId === null) {
+            return Payload::fail('User not found', [], 'E_NOT_FOUND', 404);
+        }
+
+        try {
+            $result = $this->slaConfigsModel->saveConfig($companyId, $body);
+        } catch (\Throwable $e) {
+            return Payload::fail('Failed to save SLA config', [], 'E_GENERIC', 500);
+        }
+
+        if (($result['status'] ?? '') === 'not_found') {
+            return Payload::fail('SLA config not found', [], 'E_NOT_FOUND', 404);
+        }
+
+        if (($result['status'] ?? '') === 'invalid') {
+            return Payload::fail('Invalid payload', [], 'E_VALIDATE', 400);
+        }
+
+        if (($result['status'] ?? '') === 'duplicate') {
+            return Payload::fail('Department already has an SLA config', [], 'E_VALIDATE', 400);
+        }
+
+        if (($result['status'] ?? '') === 'error' || !isset($result['item'])) {
+            return Payload::fail('Failed to save SLA config', [], 'E_GENERIC', 500);
+        }
+
+        if (($result['status'] ?? '') === 'created') {
+            return Payload::ok($result['item'], 'S_CREATED', 'SLA config created.');
+        }
+
+        return Payload::ok($result['item'], 'S_OK', 'SLA config updated.');
+    }
+}

+ 361 - 0
models/SlaConfigsModel.php

@@ -0,0 +1,361 @@
+<?php
+
+namespace Models;
+
+use Libs\Database;
+
+class SlaConfigsModel
+{
+    private const DEFAULT_ALERT_PERCENT = 80;
+
+    private \PDO $pdo;
+
+    public function __construct()
+    {
+        $this->pdo = Database::pdo();
+    }
+
+    public function getConfigs(int $companyId): array
+    {
+        $configs = $this->fetchConfigs($companyId);
+        if ($configs === []) {
+            return ['items' => []];
+        }
+
+        $liveDataByDepartment = $this->getLiveDataByDepartment($companyId);
+        $items = [];
+
+        foreach ($configs as $config) {
+            $departmentName = trim((string) ($config['sla_config_department'] ?? ''));
+            $departmentKey = mb_strtolower($departmentName);
+            $liveData = $liveDataByDepartment[$departmentKey] ?? null;
+
+            $items[] = [
+                'id' => $this->slugify($departmentName),
+                'name' => $departmentName,
+                'firstResponseH' => (int) ($config['sla_config_response_hours'] ?? 0),
+                'firstResponseM' => 0,
+                'resolutionH' => (int) ($config['sla_config_resolution_hours'] ?? 0),
+                'alertPct' => self::DEFAULT_ALERT_PERCENT,
+                'liveStatus' => $this->resolveLiveStatus($liveData),
+                'liveDetail' => $this->resolveLiveDetail($liveData),
+                'lastUpdated' => $this->formatRelativeTime($config['sla_config_updated_at'] ?? null),
+            ];
+        }
+
+        return ['items' => $items];
+    }
+
+    public function saveConfig(int $companyId, array $payload): array
+    {
+        $id = trim((string) ($payload['id'] ?? ''));
+        $name = trim((string) ($payload['name'] ?? ''));
+        $firstResponseHours = (int) ($payload['firstResponseH'] ?? 0);
+        $firstResponseMinutes = (int) ($payload['firstResponseM'] ?? 0);
+        $resolutionHours = (int) ($payload['resolutionH'] ?? 0);
+
+        $existingConfig = $id !== '' ? $this->findConfigBySlugId($companyId, $id) : null;
+        if ($id !== '' && $existingConfig === null) {
+            return ['status' => 'not_found'];
+        }
+
+        $departmentName = $name !== ''
+            ? $name
+            : (string) ($existingConfig['sla_config_department'] ?? '');
+
+        if ($departmentName === '') {
+            return ['status' => 'invalid'];
+        }
+
+        $responseHoursToPersist = $this->normalizeResponseHoursToPersist($firstResponseHours, $firstResponseMinutes);
+
+        try {
+            if ($existingConfig !== null) {
+                $stmt = $this->pdo->prepare(
+                    "UPDATE sla_config
+                    SET sla_config_department = :department,
+                        sla_config_response_hours = :response_hours,
+                        sla_config_resolution_hours = :resolution_hours,
+                        sla_config_updated_at = NOW()
+                    WHERE company_id = :company_id
+                      AND sla_config_department = :current_department
+                      AND sla_config_deleted_at = 'infinity'"
+                );
+                $stmt->execute([
+                    'department' => $departmentName,
+                    'response_hours' => $responseHoursToPersist,
+                    'resolution_hours' => $resolutionHours,
+                    'company_id' => $companyId,
+                    'current_department' => $existingConfig['sla_config_department'],
+                ]);
+
+                $status = 'updated';
+            } else {
+                $stmt = $this->pdo->prepare(
+                    "INSERT INTO sla_config (
+                        company_id,
+                        sla_config_department,
+                        sla_config_response_hours,
+                        sla_config_resolution_hours
+                    ) VALUES (
+                        :company_id,
+                        :department,
+                        :response_hours,
+                        :resolution_hours
+                    )"
+                );
+                $stmt->execute([
+                    'company_id' => $companyId,
+                    'department' => $departmentName,
+                    'response_hours' => $responseHoursToPersist,
+                    'resolution_hours' => $resolutionHours,
+                ]);
+
+                $status = 'created';
+            }
+        } catch (\PDOException $e) {
+            if ($e->getCode() === '23505') {
+                return ['status' => 'duplicate'];
+            }
+
+            return ['status' => 'error'];
+        }
+
+        $savedItem = $this->getConfigItemByDepartmentName($companyId, $departmentName);
+        if ($savedItem === null) {
+            return ['status' => 'error'];
+        }
+
+        return [
+            'status' => $status,
+            'item' => $savedItem,
+        ];
+    }
+
+    public function getLiveStatus(int $companyId): array
+    {
+        $configItems = $this->getConfigs($companyId)['items'] ?? [];
+        $items = [];
+
+        foreach ($configItems as $item) {
+            $items[] = [
+                'id' => $item['id'] ?? '',
+                'name' => $item['name'] ?? '',
+                'liveStatus' => $item['liveStatus'] ?? 'ok',
+                'liveDetail' => $item['liveDetail'] ?? 'dentro do SLA',
+                'lastUpdated' => $item['lastUpdated'] ?? 'agora',
+            ];
+        }
+
+        return ['items' => $items];
+    }
+
+    private function fetchConfigs(int $companyId): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                sla_config_department,
+                sla_config_response_hours,
+                sla_config_resolution_hours,
+                sla_config_updated_at
+            FROM sla_config
+            WHERE company_id = :company_id
+              AND sla_config_deleted_at = 'infinity'
+            ORDER BY sla_config_department ASC"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+
+        return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+    }
+
+    private function findConfigBySlugId(int $companyId, string $id): ?array
+    {
+        $configs = $this->fetchConfigs($companyId);
+        foreach ($configs as $config) {
+            $departmentName = trim((string) ($config['sla_config_department'] ?? ''));
+            if ($departmentName === '') {
+                continue;
+            }
+
+            if ($this->slugify($departmentName) === $id) {
+                return $config;
+            }
+        }
+
+        return null;
+    }
+
+    private function getConfigItemByDepartmentName(int $companyId, string $departmentName): ?array
+    {
+        $items = $this->getConfigs($companyId)['items'] ?? [];
+        foreach ($items as $item) {
+            if (($item['name'] ?? '') === $departmentName) {
+                return $item;
+            }
+        }
+
+        return null;
+    }
+
+    private function normalizeResponseHoursToPersist(int $hours, int $minutes): int
+    {
+        $totalMinutes = max(0, ($hours * 60) + $minutes);
+        if ($totalMinutes === 0) {
+            return 0;
+        }
+
+        return (int) ceil($totalMinutes / 60);
+    }
+
+    private function getLiveDataByDepartment(int $companyId): array
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT
+                lower(trim(o.operator_department)) AS department_key,
+                MAX(
+                    CASE
+                        WHEN c.conversation_id IS NOT NULL
+                         AND c.conversation_sla_deadline < NOW()
+                        THEN EXTRACT(EPOCH FROM (NOW() - c.conversation_sla_deadline))
+                        ELSE NULL
+                    END
+                ) AS max_overdue_seconds,
+                MAX(
+                    CASE
+                        WHEN c.conversation_id IS NOT NULL
+                         AND c.conversation_sla_deadline >= NOW()
+                         AND EXTRACT(EPOCH FROM (c.conversation_sla_deadline - c.conversation_started_at)) > 0
+                        THEN (
+                            EXTRACT(EPOCH FROM (NOW() - c.conversation_started_at))
+                            / EXTRACT(EPOCH FROM (c.conversation_sla_deadline - c.conversation_started_at))
+                        ) * 100
+                        ELSE NULL
+                    END
+                ) AS max_progress_pct
+            FROM operator o
+            LEFT JOIN conversation c
+                ON c.operator_id = o.operator_id
+               AND c.company_id = :company_id
+               AND c.conversation_deleted_at = 'infinity'
+               AND c.conversation_status = 'open'
+            WHERE o.company_id = :company_id
+              AND o.operator_deleted_at = 'infinity'
+            GROUP BY lower(trim(o.operator_department))"
+        );
+        $stmt->execute(['company_id' => $companyId]);
+        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+
+        $grouped = [];
+        foreach ($rows as $row) {
+            $key = (string) ($row['department_key'] ?? '');
+            if ($key === '') {
+                continue;
+            }
+
+            $grouped[$key] = [
+                'max_overdue_seconds' => $row['max_overdue_seconds'] !== null ? (float) $row['max_overdue_seconds'] : null,
+                'max_progress_pct' => $row['max_progress_pct'] !== null ? (float) $row['max_progress_pct'] : null,
+            ];
+        }
+
+        return $grouped;
+    }
+
+    private function resolveLiveStatus(?array $liveData): string
+    {
+        $maxOverdueSeconds = $liveData['max_overdue_seconds'] ?? null;
+        if ($maxOverdueSeconds !== null && $maxOverdueSeconds > 0) {
+            return 'breach';
+        }
+
+        $maxProgressPct = $liveData['max_progress_pct'] ?? null;
+        if ($maxProgressPct !== null && $maxProgressPct >= self::DEFAULT_ALERT_PERCENT) {
+            return 'warning';
+        }
+
+        return 'ok';
+    }
+
+    private function resolveLiveDetail(?array $liveData): string
+    {
+        $maxOverdueSeconds = $liveData['max_overdue_seconds'] ?? null;
+        if ($maxOverdueSeconds !== null && $maxOverdueSeconds > 0) {
+            return $this->formatOverdueTime((int) round($maxOverdueSeconds)) . ' estourado';
+        }
+
+        $maxProgressPct = $liveData['max_progress_pct'] ?? null;
+        if ($maxProgressPct !== null && $maxProgressPct >= self::DEFAULT_ALERT_PERCENT) {
+            $remainingPct = max(0, (int) round(100 - $maxProgressPct));
+            return sprintf('próximo de estourar (%d%% restante)', $remainingPct);
+        }
+
+        return 'dentro do SLA';
+    }
+
+    private function formatOverdueTime(int $seconds): string
+    {
+        if ($seconds < 60) {
+            return $seconds . 's';
+        }
+
+        $minutes = intdiv($seconds, 60);
+        if ($minutes < 60) {
+            return $minutes . 'min';
+        }
+
+        $hours = intdiv($minutes, 60);
+        $remainingMinutes = $minutes % 60;
+
+        if ($remainingMinutes === 0) {
+            return $hours . 'h';
+        }
+
+        return sprintf('%dh %dmin', $hours, $remainingMinutes);
+    }
+
+    private function formatRelativeTime(?string $timestamp): string
+    {
+        if ($timestamp === null || trim($timestamp) === '') {
+            return 'agora';
+        }
+
+        try {
+            $dateTime = new \DateTimeImmutable($timestamp);
+        } catch (\Throwable $e) {
+            return 'agora';
+        }
+
+        $diffSeconds = time() - $dateTime->getTimestamp();
+        if ($diffSeconds <= 30) {
+            return 'agora';
+        }
+
+        if ($diffSeconds < 3600) {
+            $minutes = max(1, (int) floor($diffSeconds / 60));
+            return $minutes === 1 ? 'há 1 minuto' : sprintf('há %d minutos', $minutes);
+        }
+
+        if ($diffSeconds < 86400) {
+            $hours = max(1, (int) floor($diffSeconds / 3600));
+            return $hours === 1 ? 'há 1 hora' : sprintf('há %d horas', $hours);
+        }
+
+        $days = max(1, (int) floor($diffSeconds / 86400));
+        return $days === 1 ? 'há 1 dia' : sprintf('há %d dias', $days);
+    }
+
+    private function slugify(string $value): string
+    {
+        $normalized = trim($value);
+        if ($normalized === '') {
+            return '';
+        }
+
+        $transliterated = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized) : $normalized;
+        $transliterated = is_string($transliterated) ? $transliterated : $normalized;
+        $slug = preg_replace('/[^a-zA-Z0-9]+/', '-', $transliterated) ?? '';
+        $slug = trim($slug, '-');
+
+        return mb_strtolower($slug);
+    }
+}

+ 41 - 0
models/UserModel.php

@@ -156,4 +156,45 @@ class UserModel
             ],
         ];
     }
+
+    public function changePassword(int $userId, string $currentPassword, string $newPassword): string
+    {
+        $stmt = $this->pdo->prepare(
+            "SELECT user_password
+            FROM \"user\"
+            WHERE user_id = :user_id
+              AND user_deleted_at = 'infinity'
+            LIMIT 1"
+        );
+        $stmt->execute(['user_id' => $userId]);
+        $userPasswordHash = $stmt->fetchColumn();
+
+        if (!is_string($userPasswordHash) || $userPasswordHash === '') {
+            return 'not_found';
+        }
+
+        if (!password_verify($currentPassword, $userPasswordHash)) {
+            return 'invalid_current_password';
+        }
+
+        $newPasswordHash = password_hash($newPassword, PASSWORD_DEFAULT);
+
+        try {
+            $updateStmt = $this->pdo->prepare(
+                "UPDATE \"user\"
+                SET user_password = :user_password
+                WHERE user_id = :user_id
+                  AND user_deleted_at = 'infinity'"
+            );
+            $updateStmt->execute([
+                'user_password' => $newPasswordHash,
+                'user_id' => $userId,
+            ]);
+        } catch (\PDOException $e) {
+            Logger::error('Failed to update user password', ['user_id' => $userId, 'error' => $e->getMessage()]);
+            return 'error';
+        }
+
+        return 'updated';
+    }
 }

+ 4 - 0
public/index.php

@@ -26,6 +26,7 @@ $authJwt = new JwtAuthMiddleware();
 
 // Rotas versionadas sob /v1 para permitir evolução sem quebrar clientes existentes.
 $app->get('/v1/me', $authJwt, \Controllers\MeController::class);
+$app->post('/v1/me/change-password', $authJwt, \Controllers\MeChangePasswordController::class);
 $app->get('/v1/dashboard/overview', $authJwt, \Controllers\DashboardOverviewController::class);
 $app->get('/v1/interactions', $authJwt, \Controllers\InteractionsController::class);
 $app->get('/v1/interactions/details', $authJwt, \Controllers\InteractionDetailsController::class);
@@ -34,10 +35,13 @@ $app->get('/v1/personas/overview', $authJwt, \Controllers\PersonasOverviewContro
 $app->get('/v1/evolution/overview', $authJwt, \Controllers\EvolutionOverviewController::class);
 $app->get('/v1/executive/dashboard', $authJwt, \Controllers\ExecutiveDashboardController::class);
 $app->get('/v1/agents', $authJwt, \Controllers\AgentsController::class);
+$app->get('/v1/sla/configs', $authJwt, \Controllers\SlaConfigsController::class);
+$app->get('/v1/sla/live-status', $authJwt, \Controllers\SlaLiveStatusController::class);
 
 $app->post('/v1/login', \Controllers\LoginController::class);
 // Registro exige autenticação: o novo usuário herda o company_id do solicitante.
 $app->post('/v1/register', $authJwt, \Controllers\RegisterController::class);
+$app->post('/v1/sla/configs', $authJwt, \Controllers\SlaSaveConfigController::class);
 $app->post('/v1/agents', $authJwt, \Controllers\AgentSaveController::class);
 $app->post('/v1/agents/status', $authJwt, \Controllers\AgentStatusController::class);
 $app->post('/v1/agents/escalation', $authJwt, \Controllers\AgentEscalationController::class);