update(['gln' => $gln]); return; } $callLog = CallLog::create([ 'linkedid' => $linkedid, 'gln' => $gln, ]); self::rememberId($linkedid, $callLog->getKey()); } public static function sync(string $linkedid, string $direction, array $data = []): void { $existing = self::findByLinkedid($linkedid); $phoneNumber = $data['phone_number'] ?? $existing->phone_number ?? null; // Normalize phone number using Egyptian format (same as Campaign/SMS logic) $normalizedPhone = self::normalizeEgyptianPhone($phoneNumber); // Auto-resolve lead_id from phone_number if not provided $leadId = $data['lead_id'] ?? $existing->lead_id ?? null; if (!$leadId && $normalizedPhone) { $leadId = self::findLeadByPhone($normalizedPhone); } // Auto-resolve contact_id from phone_number if not provided $contactId = $data['contact_id'] ?? $existing->contact_id ?? null; if (!$contactId && $normalizedPhone) { $contactId = self::findContactByPhone($normalizedPhone); } $record = [ 'linkedid' => $linkedid, 'direction' => $direction, 'phone_number' => $phoneNumber, 'gln' => $data['gln'] ?? $existing->gln ?? Redis::get('call-gln:' . $linkedid), 'call_date' => $data['call_date'] ?? $existing->call_date ?? null, 'duration' => $data['duration'] ?? $existing->duration ?? 0, 'talktime' => $data['talktime'] ?? $existing->talktime ?? 0, 'ringtime' => $data['ringtime'] ?? $existing->ringtime ?? 0, 'user_id' => $data['user_id'] ?? $existing->user_id ?? null, 'queue' => $data['queue'] ?? $existing->queue ?? null, 'lead_id' => $leadId, 'contact_id' => $contactId, ]; $surveyAnswers = self::getSurveyAnswers($linkedid); if ($surveyAnswers !== null) { $record['survey'] = array_merge($existing->survey ?? [], $surveyAnswers); } if ($existing) { // Index already exists for this record: update only, never extend its TTL. $existing->update($record); } else { $callLog = CallLog::create($record); self::rememberId($linkedid, $callLog->getKey()); } } public static function refreshSurvey(string $linkedid): int { $surveyAnswers = self::getSurveyAnswers($linkedid); if ($surveyAnswers === null) { return 0; } $existing = self::findByLinkedid($linkedid); if ($existing) { // Index already exists for this record: update only, never extend its TTL. $existing->survey = array_merge($existing->survey ?? [], $surveyAnswers); $existing->save(); return 1; } $callLog = CallLog::create([ 'linkedid' => $linkedid, 'survey' => $surveyAnswers, ]); self::rememberId($linkedid, $callLog->getKey()); return 1; } /** * Resolve the CallLog for a LINKEDID using the Redis id index only. * * No database existence check is performed: a missing key means "no * CallLog", and a stale key simply yields null so the caller creates a * fresh record. */ public static function findByLinkedid(string $linkedid): ?CallLog { $id = Redis::get(self::redisKey($linkedid)); if ($id === null || $id === false || $id === '') { return null; } return CallLog::find($id); } /** * Store the Redis index pointing at the given CallLog id, with a one-hour TTL. * * Only called right after a CallLog is created by the GLN, Survey, call-sync * or CallLogsController create flow. Updates never call this, so the index * TTL is never refreshed. */ public static function rememberId(string $linkedid, $callLogId): void { if ($linkedid === '' || $callLogId === null || $callLogId === '') { return; } Redis::setex(self::redisKey($linkedid), self::CALL_LOG_ID_TTL, (string) $callLogId); } public static function redisKey(string $linkedid): string { return self::CALL_LOG_ID_KEY_PREFIX . $linkedid; } private static function getSurveyAnswers(string $linkedid): ?array { $survey = DB::table('surveys') ->where('linkedid', $linkedid) ->orderByDesc('id') ->first(['customer_choice', 'q2', 'q3', 'q4', 'q5']); if (!$survey) { return null; } $answers = []; foreach ([ 'Q1' => $survey->customer_choice, 'Q2' => $survey->q2, 'Q3' => $survey->q3, 'Q4' => $survey->q4, 'Q5' => $survey->q5, ] as $question => $answer) { if ($answer !== null && $answer !== '') { $answers[$question] = $answer; } } return $answers ?: null; } /** * Normalize phone number to Egyptian local format (0XXXXXXXXX) using * the same logic as PhoneNormalizer/SMS/Campaign modules. */ private static function normalizeEgyptianPhone(?string $number): ?string { if (!$number) return null; // Use the PhoneNormalizer with Egypt config (country_code=20, trunk_prefix=0) $countryCode = config('sms.country_code', '20'); $trunkPrefix = config('sms.trunk_prefix', '0'); return PhoneNormalizer::toLocal($number, $countryCode, $trunkPrefix); } /** * Find Lead by matching normalized phone/mobile against normalized input. * Returns the Lead ID if found, null otherwise. */ private static function findLeadByPhone(string $normalizedPhone): ?int { $lead = Lead::where(function ($query) use ($normalizedPhone) { $query->whereRaw("REPLACE(REPLACE(REPLACE(phone, '+', ''), ' ', ''), '-', '') = ?", [$normalizedPhone]) ->orWhereRaw("REPLACE(REPLACE(REPLACE(mobile, '+', ''), ' ', ''), '-', '') = ?", [$normalizedPhone]); })->orderBy('created_at', 'desc')->first(); return $lead ? $lead->id : null; } /** * Find Contact by matching normalized phone/mobile against normalized input. * Returns the Contact ID if found, null otherwise. */ private static function findContactByPhone(string $normalizedPhone): ?int { $contact = Contact::where(function ($query) use ($normalizedPhone) { $query->whereRaw("REPLACE(REPLACE(REPLACE(phone, '+', ''), ' ', ''), '-', '') = ?", [$normalizedPhone]) ->orWhereRaw("REPLACE(REPLACE(REPLACE(mobile, '+', ''), ' ', ''), '-', '') = ?", [$normalizedPhone]); })->first(); return $contact ? $contact->id : null; } }