# Infinity Dialer Failed-Call Migration — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Backfill failed predictive-dialer calls from the Infinity dialer archive DB into our communication history, runnable on demand (`infinity:migrate`), from the admin UI, and on a daily schedule for the Kazakhstan instance.

**Architecture:** A new `InfinityCommunicationMigrator` (implements the existing `CommunicationMigratorContract`) reads failed calls from the `infinity_dialer` Postgres connection (joining `ArchiveConnections$` to `ArchiveSeances$`), maps each to a `CommunicationDTO` via a small `InfinityFailedCall` value object, resolves the borrower document from the dialed phone number through `14_telefonos`, and persists via `CommunicationService::saveCommunication`. It is wired in exactly like `VicidialCommunicationMigrator` (dedicated command, UI provider list, per-instance binding, Kernel schedule).

**Tech Stack:** PHP 8.3 · Laravel 10 · PostgreSQL (`infinity_dialer`, `collection`, `users` connections) · PHPUnit 10 · Pint.

## Global Constraints

- Run all Artisan/Composer/PHPUnit/Pint commands **inside the container**: `docker exec -i polaris-service-new <cmd>`.
- **Commits:** Per the user's standing preference, do NOT commit during execution. The "Checkpoint" step at the end of each task means: run `vendor/bin/pint --dirty`, run the task's tests, and stop. Commit only when the user explicitly asks.
- Fixed communication fields for every migrated call: `action_id = 1`, `contact_id = 3`, `strategy = 1`, `reason_id = 0`, `time = '00:00:01'`, metadata channel `CommunicationMetadataDTO::CHANNEL_MIGRATION`.
- `result_id = 21` when `ArchiveConnections."durationWait" IS NULL`, otherwise `14`. (Row `21` in `4_resultado` is seeded by the user out-of-band; not this plan's responsibility.)
- Texts, verbatim:
  - `21` → `Se realiza marcación por el marcador predictivo y la llamada es rechazada`
  - `14` → `Se realiza marcación por el marcador predictivo, lalinea se encuentra ocupado`
- Source identifiers contain `/`, `$`, capitals → every SQL identifier double-quoted; literal `$` escaped inside PHP strings.
- Source filter: `ArchiveSeances.result = 'failed'`, join `(c."seance_link"->>'id')::uuid = s.id`, date window `c."timeStart" >= :from AND c."timeStart" < :to`.
- Don't use `DB::` raw query builder where Eloquent fits; the one exception is the cross-schema `infinity_dialer` source query, which has no Eloquent model and uses `DB::connection('infinity_dialer')->select(...)` (consistent with `VicidialCommunicationMigrator`).
- Migrated calls are attributed to a dedicated `infinity.bot` user, resolved by username at runtime.

---

### Task 1: `InfinityFailedCall` value object

**Files:**
- Create: `app/Integrations/Infinity/Models/InfinityFailedCall.php`
- Test: `tests/Unit/Integrations/Infinity/InfinityFailedCallTest.php`

**Interfaces:**
- Produces:
  - `InfinityFailedCall::fromResponse(\stdClass $row): static`
  - readonly props: `string $externalId`, `string $phoneNumber`, `bool $durationWaitIsNull`, `Carbon $date`
  - magic accessors (via `AbstractSharedModel`): `int $result_id`, `string $text`

- [ ] **Step 1: Write the failing test**

Create `tests/Unit/Integrations/Infinity/InfinityFailedCallTest.php`:

```php
<?php

namespace Tests\Unit\Integrations\Infinity;

use App\Integrations\Infinity\Models\InfinityFailedCall;
use Tests\TestCase;

class InfinityFailedCallTest extends TestCase
{
    public function test_maps_rejected_call_when_duration_wait_is_null(): void
    {
        $row = (object) [
            'nativeFirstCallID' => 'abc-123',
            'dialedNumber'      => '79046022666',
            'durationWait'      => null,
            'timeStart'         => '2026-06-29 14:02:53.696',
        ];

        $call = InfinityFailedCall::fromResponse($row);

        $this->assertSame('abc-123', $call->externalId);
        $this->assertSame('79046022666', $call->phoneNumber);
        $this->assertTrue($call->durationWaitIsNull);
        $this->assertSame('2026-06-29 14:02:53', $call->date->format('Y-m-d H:i:s'));
        $this->assertSame(21, $call->result_id);
        $this->assertSame(
            'Se realiza marcación por el marcador predictivo y la llamada es rechazada',
            $call->text
        );
    }

    public function test_maps_busy_call_when_duration_wait_is_present(): void
    {
        $row = (object) [
            'nativeFirstCallID' => 'def-456',
            'dialedNumber'      => '79046022666',
            'durationWait'      => 15,
            'timeStart'         => '2026-06-29 14:02:53',
        ];

        $call = InfinityFailedCall::fromResponse($row);

        $this->assertFalse($call->durationWaitIsNull);
        $this->assertSame(14, $call->result_id);
        $this->assertSame(
            'Se realiza marcación por el marcador predictivo, lalinea se encuentra ocupado',
            $call->text
        );
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=InfinityFailedCallTest`
Expected: FAIL — `Class "App\Integrations\Infinity\Models\InfinityFailedCall" not found`.

- [ ] **Step 3: Write the value object**

Create `app/Integrations/Infinity/Models/InfinityFailedCall.php`:

```php
<?php

namespace App\Integrations\Infinity\Models;

use App\Models\Shared\AbstractSharedModel;
use Carbon\Carbon;

/**
 * @property-read int $result_id
 * @property-read string $text
 */
class InfinityFailedCall extends AbstractSharedModel
{
    public function __construct(
        public readonly string $externalId,
        public readonly string $phoneNumber,
        public readonly bool $durationWaitIsNull,
        public readonly Carbon $date,
    ) {
    }

    public static function fromResponse(\stdClass $object): static
    {
        return new self(
            externalId: (string) $object->nativeFirstCallID,
            phoneNumber: (string) $object->dialedNumber,
            durationWaitIsNull: is_null($object->durationWait),
            date: Carbon::parse($object->timeStart),
        );
    }

    public function getResultIdAttribute(): int
    {
        return $this->durationWaitIsNull ? 21 : 14;
    }

    public function getTextAttribute(): string
    {
        return $this->durationWaitIsNull
            ? 'Se realiza marcación por el marcador predictivo y la llamada es rechazada'
            : 'Se realiza marcación por el marcador predictivo, lalinea se encuentra ocupado';
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=InfinityFailedCallTest`
Expected: PASS (2 tests).

- [ ] **Step 5: Checkpoint**

Run: `docker exec -i polaris-service-new vendor/bin/pint --dirty`
Do not commit (per Global Constraints).

---

### Task 2: Infinity bot user migration

**Files:**
- Create: `database/migrations/2026_06_29_120000_create_infinity_bot_user.php`

**Interfaces:**
- Produces: a `usuarios` row (connection `users`) with `usuario = 'infinity.bot'`, resolvable by `App\Models\User\User::query()->where('usuario', 'infinity.bot')->value('id')`.

**Context:** Mirrors the existing `ai_rudder.bot` row (`idperfil = 8`, `idproyecto = 1`, `idcasa = 1`, `idestado = 1`). The `usuarios` table lives on the `users` connection (`collector_usuarios_drpeso`). Insert is idempotent (`updateOrInsert` on `usuario`).

- [ ] **Step 1: Create the migration**

Create `database/migrations/2026_06_29_120000_create_infinity_bot_user.php`:

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;

return new class extends Migration
{
    public function getConnection(): string
    {
        return 'users';
    }

    public function up(): void
    {
        DB::connection('users')->table('usuarios')->updateOrInsert(
            ['usuario' => 'infinity.bot'],
            [
                'nombre'     => 'Infinity Bot',
                'password'   => Hash::make(str()->random(40)),
                'documento'  => 'TECH_INFINITY',
                'idperfil'   => 8,
                'idproyecto' => 1,
                'idcasa'     => 1,
                'email'      => '',
                'idestado'   => 1,
            ]
        );
    }

    public function down(): void
    {
        DB::connection('users')->table('usuarios')->where('usuario', 'infinity.bot')->delete();
    }
};
```

- [ ] **Step 2: Run the migration**

Run: `docker exec -i polaris-service-new php artisan migrate --no-interaction`
Expected: the new migration runs without error.

- [ ] **Step 3: Verify the user exists**

Run:
```bash
docker exec -i polaris-service-new php artisan tinker --execute="echo App\Models\User\User::query()->where('usuario','infinity.bot')->value('id');"
```
Expected: a numeric id is printed (non-empty).

- [ ] **Step 4: Checkpoint**

Run: `docker exec -i polaris-service-new vendor/bin/pint --dirty`
Do not commit.

---

### Task 3: `InfinityCommunicationMigrator` service + `infinity` log channel

**Files:**
- Create: `app/Services/Communication/Migration/InfinityCommunicationMigrator.php`
- Modify: `config/logging.php` (add the `infinity` channel next to `vicidial`)

**Interfaces:**
- Consumes: `InfinityFailedCall::fromResponse()` and its `result_id`/`text` accessors (Task 1); the `infinity.bot` user (Task 2).
- Produces:
  - `InfinityCommunicationMigrator::getProviderName(): string` → `'Infinity'`
  - `InfinityCommunicationMigrator::execute(Carbon $createdAtFrom, ?Carbon $createdAtTo = null): CommunicationMigrationResultDTO`

- [ ] **Step 1: Add the `infinity` log channel**

In `config/logging.php`, immediately after the `vicidial` channel block, add:

```php
        'infinity' => [
            'driver' => 'single',
            'path' => storage_path('logs/infinity.log'),
            'level' => 'info',
        ],
```

- [ ] **Step 2: Create the migrator**

Create `app/Services/Communication/Migration/InfinityCommunicationMigrator.php`:

```php
<?php

namespace App\Services\Communication\Migration;

use App\Helpers\Notify\TelegramNotifyHelper;
use App\Http\Requests\Promise\DTO\CommunicationDTO;
use App\Http\Requests\Promise\DTO\CommunicationMetadataDTO;
use App\Integrations\Infinity\Models\InfinityFailedCall;
use App\Models\Collection\Client\Phone;
use App\Models\User\User;
use App\Services\Communication\CommunicationService;
use App\Services\Communication\Migration\Abstract\CommunicationMigratorContract;
use App\Services\Communication\Migration\DTO\CommunicationMigrationResultDTO;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class InfinityCommunicationMigrator implements CommunicationMigratorContract
{
    private const string BOT_USERNAME = 'infinity.bot';

    public static function getProviderName(): string
    {
        return 'Infinity';
    }

    public function execute(Carbon $createdAtFrom, ?Carbon $createdAtTo = null): CommunicationMigrationResultDTO
    {
        TelegramNotifyHelper::send('Infinity migration started');

        $from = $createdAtFrom->format('Y-m-d H:i:s');
        $to = ($createdAtTo ?? today()->addDay())->format('Y-m-d H:i:s');

        $userId = $this->resolveBotUserId();

        $query = "SELECT
    c.\"nativeFirstCallID\",
    c.\"dialedNumber\",
    c.\"durationWait\",
    c.\"timeStart\"
FROM datamodel.\"callcenter/connections/ArchiveConnections\$\" c
JOIN datamodel.\"callcenter/seances/ArchiveSeances\$\" s
  ON (c.\"seance_link\"->>'id')::uuid = s.id
WHERE s.result = 'failed'
  AND c.\"timeStart\" >= '$from'
  AND c.\"timeStart\" <  '$to'
ORDER BY c.\"timeStart\" DESC";

        $calls = collect(DB::connection('infinity_dialer')->select($query))
            ->map(fn (\stdClass $row) => InfinityFailedCall::fromResponse($row));

        $currentMethod = currentMethod();

        $processedCount = 0;
        $failedCount = 0;
        $skippedCount = 0;

        /** @var InfinityFailedCall $call */
        foreach ($calls as $call) {
            $document = $this->resolveDocument($call->phoneNumber);

            if (is_null($document)) {
                $skippedCount++;

                continue;
            }

            $communicationMetadata = new CommunicationMetadataDTO(
                userId: (string) $userId,
                channel: CommunicationMetadataDTO::CHANNEL_MIGRATION,
                method: $currentMethod,
            );

            $communicationDTO = new CommunicationDTO(
                document: $document,
                promiseDate: null,
                value: null,
                strategy: '1',
                phoneNumber: $call->phoneNumber,
                text: $call->text,
                actionId: 1,
                contactId: 3,
                resultId: $call->result_id,
                reasonId: 0,
                userId: $userId,
                time: '00:00:01',
                communicationDate: $call->date,
                externalCommunicationId: $call->externalId,
                communicationMetadata: $communicationMetadata,
            );

            try {
                CommunicationService::saveCommunication($communicationDTO);
            } catch (\Throwable $exception) {
                Log::channel('infinity')->warning($exception->getMessage(), $communicationDTO->toArray());
                $failedCount++;

                continue;
            }

            $processedCount++;
        }

        $dateFromAsString = toDate($createdAtFrom);
        $dateToAsString = toDate($createdAtTo ?? today()->addDay());

        TelegramNotifyHelper::send(
            "[$dateFromAsString] - [$dateToAsString] Infinity migration completed successfully
---
<b>Processed:</b> $processedCount
<b>Failed:</b> $failedCount
<b>Skipped:</b> $skippedCount
"
        );

        return new CommunicationMigrationResultDTO($processedCount, $failedCount, $skippedCount);
    }

    private function resolveBotUserId(): int
    {
        $userId = User::query()->where('usuario', self::BOT_USERNAME)->value('id');

        if (is_null($userId)) {
            throw new \RuntimeException('Infinity bot user [' . self::BOT_USERNAME . '] not found');
        }

        return (int) $userId;
    }

    private function resolveDocument(string $phoneNumber): ?string
    {
        return Phone::query()
            ->where('telefono', $phoneNumber)
            ->where('idactivo', 1)
            ->value('documento');
    }
}
```

- [ ] **Step 3: Verify it resolves and runs against a known day**

Run (dry sanity check — this writes real communications for the chosen day; use a recent day with known failed calls such as today):
```bash
docker exec -i polaris-service-new php artisan tinker --execute="
\$m = app(App\Services\Communication\Migration\InfinityCommunicationMigrator::class);
\$r = \$m->execute(\Carbon\Carbon::today(), \Carbon\Carbon::today()->addDay());
echo 'processed='.\$r->succeedCount.' failed='.\$r->failedCount.' skipped='.\$r->skippedCount;
"
```
Expected: prints counts without throwing; `succeedCount + failedCount + skippedCount` equals the number of failed calls for the window. (If `result_id 21` is not yet seeded, expect non-zero `failedCount` for null-`durationWait` calls — that is the documented external dependency, not a code defect.)

- [ ] **Step 4: Checkpoint**

Run: `docker exec -i polaris-service-new vendor/bin/pint --dirty`
Do not commit.

---

### Task 4: `InfinityMigrationCommand`

**Files:**
- Create: `app/Console/Commands/Cron/Communication/InfinityMigrationCommand.php`

**Interfaces:**
- Consumes: `InfinityCommunicationMigrator::execute()` (Task 3).
- Produces: artisan command `infinity:migrate {from?} {to?}`.

- [ ] **Step 1: Create the command**

Create `app/Console/Commands/Cron/Communication/InfinityMigrationCommand.php`:

```php
<?php

namespace App\Console\Commands\Cron\Communication;

use App\Services\Communication\Migration\InfinityCommunicationMigrator;
use Carbon\Carbon;
use Illuminate\Console\Command;

class InfinityMigrationCommand extends Command
{
    //php artisan infinity:migrate 2026-06-28 2026-06-29
    protected $signature = 'infinity:migrate {from?} {to?}';

    protected $description = 'Command for migration of failed calls from the Infinity dialer';

    public function handle(InfinityCommunicationMigrator $infinityCommunicationMigrator): void
    {
        $fromInput = $this->argument('from');
        $toInput = $this->argument('to');

        $from = $fromInput ? Carbon::parse($fromInput) : today();
        $to = $toInput ? Carbon::parse($toInput) : today()->addDay();

        $infinityCommunicationMigrator->execute($from, $to);
    }
}
```

- [ ] **Step 2: Verify the command is registered**

Run: `docker exec -i polaris-service-new php artisan list | grep infinity:migrate`
Expected: `infinity:migrate` appears in the list.

- [ ] **Step 3: Checkpoint**

Run: `docker exec -i polaris-service-new vendor/bin/pint --dirty`
Do not commit.

---

### Task 5: Wiring (UI provider list, instance binding, schedule)

**Files:**
- Modify: `app/Services/Communication/Migration/Helpers/CommunicationMigratorHelper.php`
- Modify: `app/Providers/AppServiceProvider.php`
- Modify: `app/Console/Kernel.php`

**Interfaces:**
- Consumes: `InfinityCommunicationMigrator` (Task 3), `InfinityMigrationCommand` (Task 4).

- [ ] **Step 1: Register in the migration UI provider list**

In `app/Services/Communication/Migration/Helpers/CommunicationMigratorHelper.php`, add the import and the entry:

```php
use App\Services\Communication\Migration\InfinityCommunicationMigrator;
```

```php
    protected const PROVIDERS = [
        IssabelCommunicationMigrator::class,
        WebitelCommunicationMigrator::class,
        VicidialCommunicationMigrator::class,
        InfinityCommunicationMigrator::class,
    ];
```

- [ ] **Step 2: Bind the migrator for the Kazakhstan instance**

In `app/Providers/AppServiceProvider.php`, add the import:

```php
use App\Services\Communication\Migration\InfinityCommunicationMigrator;
```

and add the `INSTANCE_KAZAKHSTAN` arm to the existing `match` (the block currently has `INSTANCE_COLOMBIA`, `INSTANCE_PERU`, `default`):

```php
            $migratorClass = match (config('polaris.instance')) {
                INSTANCE_COLOMBIA => IssabelCommunicationMigrator::class,
                INSTANCE_PERU => WebitelCommunicationMigrator::class,
                INSTANCE_KAZAKHSTAN => InfinityCommunicationMigrator::class,
                default => throw new \Exception('Migrator is not configured'),
            };
```

- [ ] **Step 3: Schedule the command for Kazakhstan**

In `app/Console/Kernel.php`, add the import near the other command imports:

```php
use App\Console\Commands\Cron\Communication\InfinityMigrationCommand;
```

and add the entry to the `INSTANCE_KAZAKHSTAN` block of `LEAD_TIME` (after `SubscribeInfinityWebhooksCommand::class => '00:30'`):

```php
            InfinityMigrationCommand::class => '21:00',
```

- [ ] **Step 4: Verify wiring**

Run:
```bash
docker exec -i polaris-service-new php artisan tinker --execute="
print_r(App\Services\Communication\Migration\Helpers\CommunicationMigratorHelper::getAllProviders());
echo get_class(App\Services\Communication\Migration\Helpers\CommunicationMigratorHelper::getProviderByName('Infinity')).PHP_EOL;
"
docker exec -i polaris-service-new php artisan schedule:list | grep -i infinity
```
Expected: provider list includes `Infinity`; `getProviderByName('Infinity')` returns `App\Services\Communication\Migration\InfinityCommunicationMigrator`; `schedule:list` shows the `infinity:migrate` command (when the running instance is Kazakhstan).

- [ ] **Step 5: Run the value-object test suite once more (regression)**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=InfinityFailedCallTest`
Expected: PASS (2 tests).

- [ ] **Step 6: Checkpoint**

Run: `docker exec -i polaris-service-new vendor/bin/pint --dirty`
Do not commit.

---

## Self-Review notes

- **Spec coverage:** value object (Task 1), bot user (Task 2), migrator + log channel (Task 3), command (Task 4), UI/binding/schedule (Task 5) — every spec component and modification is covered.
- **`result_id 21` dependency:** intentionally not implemented here; the migrator's catch logs and counts such failures, so an unseeded dictionary degrades gracefully (counted as `failed`) rather than crashing the run.
- **Testing reality:** per the repo (no meaningful existing coverage, external read-only source DB), automated coverage is the pure-mapping unit test; the migrator/command/wiring are verified by the scoped Tinker/artisan runs shown above.