# Infinity Dialer Failed-Call Migration — Design

**Date:** 2026-06-29
**Instance:** Kazakhstan (`APP_INSTANCE=kazakhstan`)
**Reference:** `VicidialMigrationCommand` + `VicidialCommunicationMigrator`

## Goal

Backfill failed predictive-dialer calls from the Infinity dialer's archive database
(`infinity_dialer` connection) into our communication history (`CallHistory`) via
`CommunicationService::saveCommunication`, mirroring how `VicidialCommunicationMigrator`
ingests Vicidial calls for Colombia.

## Source data (`infinity_dialer`, schema `datamodel`)

Tables are monthly-partitioned; we query the partitioned parents:

- `callcenter/connections/ArchiveConnections$` (alias `c`) — one row per call leg.
  - `nativeFirstCallID` → our `external_communication_id`.
  - `dialedNumber` → the called phone number.
  - `durationWait` (nullable integer) → drives `result_id`.
  - `timeStart` (timestamp) → the communication date; also the date-range filter column.
  - `seance_link` (jsonb, e.g. `{"id": "<uuid>", "timestamp": 1782741770421}`) → link to the seance.
- `callcenter/seances/ArchiveSeances$` (alias `s`) — holds the call **`result`** (NOT on
  ArchiveConnections, contrary to the original brief). `result = 'failed'` marks failed calls.

**Join:** `(c."seance_link"->>'id')::uuid = s.id`.

**Filter:** `s.result = 'failed'` AND `c."timeStart" >= :from` AND `c."timeStart" < :to`.

Column/table identifiers contain `/`, `$`, and capitals, so every identifier must be
double-quoted; the literal `$` inside the heredoc/string must be escaped for PHP.

Verified for `dialedNumber = '79046022666'`: the join resolves, all matched seances have
`result = 'failed'`, all calls are outbound (`direction = 'out'`), and `durationWait` is a
mix of NULL and non-NULL.

## Mapping to a communication

The dialer archive provides only a phone number — no document/loan. We resolve it ourselves:

1. **Phone → document.** Look up `Phone` (`14_telefonos`) where `telefono = dialedNumber`
   and `idactivo = 1`, take the first match → `documento`.
   - **No match → skip the call, increment `skipped`.** (Decision: skip-and-count.)
2. Build a `CommunicationDTO`:
   - `document`: resolved above
   - `strategy`: `1`
   - `actionId`: `1`
   - `contactId`: `3`
   - `reasonId`: `0`
   - `resultId`: **`21`** when `c."durationWait" IS NULL`, otherwise **`14`**
   - `text`:
     - `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"`
   - `phoneNumber`: `dialedNumber`
   - `communicationDate`: `Carbon::parse(timeStart)`
   - `externalCommunicationId`: `nativeFirstCallID`
   - `time`: `'00:00:01'` (failed calls have no talk time)
   - `userId`: the Infinity bot user (see below)
   - `communicationMetadata`: `CommunicationMetadataDTO(userId, CHANNEL_MIGRATION, currentMethod())`
3. Call `CommunicationService::saveCommunication($dto)` inside try/catch:
   - success → `processed++`
   - exception → log to the `infinity` log channel, `failed++`

> **External dependency (owned by user):** `result_id = 21` does **not** currently exist in
> the Kazakhstan `4_resultado` dictionary. `saveCommunication` dereferences the resolved
> `Result`, so row `21` (with the appropriate `nivel`, `homologacion`, `esacuerdo = 0`) MUST
> be seeded before this runs in production, or every null-`durationWait` call will throw.
> This is intentionally out of scope of the code change per the user's decision.

## Attributed user (Infinity bot)

There is no Infinity user in the Kazakhstan `usuarios` table. We create an `infinity.bot`
user mirroring the existing `ai_rudder.bot` record (`idperfil = 8`, `idproyecto = 1`,
`idcasa = 1`, `documento = 'TECH_INFINITY'`, `idestado = 1`). The migrator resolves this
user **by username at runtime** (resolved once per run) rather than hardcoding a numeric id,
so it is robust to whatever auto-increment id the insert receives.

## Components

### New
1. `app/Integrations/Infinity/Models/InfinityFailedCall.php` — value object (extends
   `AbstractSharedModel`, like `VidicialCall`). Constructor-promoted readonly props:
   `externalId`, `phoneNumber`, `durationWaitIsNull` (bool), `date` (Carbon). Accessors:
   `getResultIdAttribute(): int` (21/14), `getTextAttribute(): string`,
   plus `static fromResponse(\stdClass): static`.
2. `app/Services/Communication/Migration/InfinityCommunicationMigrator.php` — implements
   `CommunicationMigratorContract`; `getProviderName(): 'Infinity'`; `execute(from, to)` as
   described above; Telegram start/finish notifications; returns
   `CommunicationMigrationResultDTO($processed, $failed, $skipped)`.
3. `app/Console/Commands/Cron/Communication/InfinityMigrationCommand.php` — signature
   `infinity:migrate {from?} {to?}`, type-hints `InfinityCommunicationMigrator` directly
   (exact mirror of `VicidialMigrationCommand`). No args → `from = today()`,
   `to = today()->addDay()` (captures the full current day under the `[from, to)` filter).
4. Database migration on the `users` connection inserting the `infinity.bot` user if absent.

### Modified
5. `app/Services/Communication/Migration/Helpers/CommunicationMigratorHelper.php` — add
   `InfinityCommunicationMigrator::class` to `PROVIDERS` (admin migration UI dropdown).
6. `app/Providers/AppServiceProvider.php` — add
   `INSTANCE_KAZAKHSTAN => InfinityCommunicationMigrator::class` to the
   `CommunicationMigratorContract` `match`, so `communications:migrate` also works for
   Kazakhstan.
7. `app/Console/Kernel.php` — add `InfinityMigrationCommand::class => '21:00'` to
   `LEAD_TIME[INSTANCE_KAZAKHSTAN]`.
8. `config/logging.php` — add an `infinity` daily/single channel
   (`storage_path('logs/infinity.log')`), matching the existing `vicidial`/`webitel` channels.

## Query (reference SQL)

```sql
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;
```

## Out of scope

- Seeding `4_resultado` row `21` (user-owned).
- Non-failed call results, inbound calls, talk-time accounting.
- Deduplication beyond whatever `saveCommunication`/`CallHistory` already enforce
  (if duplicate runs over the same window are a concern, that is a follow-up).

## Testing

- Unit test for `InfinityFailedCall`: `result_id`/`text` mapping for null vs non-null
  `durationWait`; `fromResponse` parsing.
- Feature test for `InfinityCommunicationMigrator::execute`: skip-on-unresolved-phone path
  and the processed path (the dialer DB is external/read-only, so the source query is
  exercised against a small fixture or mocked at the query boundary; `saveCommunication`
  exercised against seeded `collection` data). Given the repo has effectively no existing
  test coverage and the source DB is a remote read replica, the realistic verification is a
  scoped `infinity:migrate <day>` run with Telegram/log assertions; automated coverage is
  best-effort around the pure mapping logic.
```