# Salesforce

Send a completed call Task to Chambr from a Queueable, and store the returned
`app_url` on the Task.

## Set up a Named Credential

1. **External Credential**: Setup, Named Credentials, External Credentials,
   **New**. Name it `Chambr`, authentication protocol **Custom**.
2. Add a **principal** (Named Principal) with an authentication parameter
   `Key` holding your [API key](https://developers.staging.chambr.ai/authentication.md).
3. Add a **custom header** `Authorization` with the value
   `{!'Bearer ' & $Credential.Chambr.Key}`.
4. **Named Credential**: name `Chambr`, URL `https://api.staging.chambr.ai`, the
   External Credential above. Turn **Generate Authorization Header** off and
   **Allow Formulas in HTTP Header** on.
5. Give the running user a permission set with **External Credential Principal
   Access** to the principal.

## Send calls from Apex

`Call_Transcript__c`, `Call_Type__c` and `Chambr_Call_Url__c` are your own
custom fields (a Long Text Area holds up to 131,072 characters).
`Owner.Email` must be the rep's Chambr login email; under SSO it often is not.
If you cannot guarantee it, leave `rep` out and
[send the call without a rep](https://developers.staging.chambr.ai/guides/send-calls.md#send-a-call-without-a-rep).

The job takes a list of Task ids and sends one per run, then chains to the
next, so one callout runs per transaction.

```apex
public class ChambrSendCall implements Queueable, Database.AllowsCallouts {
    public final List<Id> taskIds;
    public final Integer attempt;
    public ChambrSendCall(List<Id> taskIds) { this(taskIds, 1); }
    public ChambrSendCall(List<Id> taskIds, Integer attempt) { this.taskIds = taskIds; this.attempt = attempt; }

    public void execute(QueueableContext ctx) {
        System.attachFinalizer(new ChambrRetry(taskIds, attempt));
        Task t = [SELECT Id, CompletedDateTime, LastModifiedDate, Owner.Email, Owner.Name,
                         Call_Transcript__c, Call_Type__c, Who.Name
                  FROM Task WHERE Id = :taskIds[0]];
        if (String.isBlank(t.Call_Transcript__c)) return; // nothing to score
        Datetime occurred = t.CompletedDateTime != null ? t.CompletedDateTime : t.LastModifiedDate;

        Map<String, Object> body = new Map<String, Object>{
            'external_id' => String.valueOf(t.Id),
            'provider'    => 'salesforce',
            // RFC 3339 with Z. A +0000 offset is rejected.
            'occurred_at' => occurred.formatGmt('yyyy-MM-dd\'T\'HH:mm:ss\'Z\''),
            'rep'         => new Map<String, Object>{ 'email' => t.Owner.Email, 'name' => t.Owner.Name },
            'transcript'  => new Map<String, Object>{ 'text' => t.Call_Transcript__c }, // "Name: line" per turn
            'metadata'    => new Map<String, Object>{
                'salesforce' => new Map<String, Object>{ 'task_id' => String.valueOf(t.Id) }
            }
        };
        if (String.isNotBlank(t.Call_Type__c)) body.put('call_type', t.Call_Type__c); // e.g. 'discovery'
        if (t.Who != null && String.isNotBlank(t.Who.Name)) {
            body.put('participants', new List<Object>{
                new Map<String, Object>{ 'role' => 'customer', 'name' => t.Who.Name }
            });
        }

        HttpRequest req = new HttpRequest();
        req.setEndpoint('callout:Chambr/v1/calls');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setBody(JSON.serialize(body));
        req.setTimeout(60000); // at least 60 s

        HttpResponse res = new Http().send(req);
        Integer status = res.getStatusCode();
        if (status == 200 || status == 202) {
            Map<String, Object> out = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
            update new Task(Id = t.Id, Chambr_Call_Url__c = (String) out.get('app_url'));
        } else if (status == 429 || status >= 500) {
            // Safe to retry (idempotent on provider + external_id): the Finalizer re-enqueues.
            throw new CalloutException('Chambr ' + status + ' ' + res.getHeader('X-Request-Id'));
        } else {
            // Other 4xx: fix the data. Do not retry unchanged.
            System.debug(LoggingLevel.ERROR, 'Chambr ' + status + ': ' + res.getBody());
        }
    }
}
```

Enqueue one job per trigger or Flow run, with every completed Task id in it:
`System.enqueueJob(new ChambrSendCall(new List<Id>(completedTaskIds)));`.
A transaction can enqueue at most 50 jobs, so never enqueue one per Task in a
bulk update. If your transcripts use generic labels such as "Speaker 1", also
send `transcript.rep_speaker`.

## Retry with a Finalizer

A Queueable cannot sleep, and a thrown exception alone retries nothing. The
Finalizer runs after each job, including after an exception or a callout
timeout. It retries the same Task with a delay of up to 10 minutes, then moves
on to the next.

```apex
public class ChambrRetry implements Finalizer {
    private final List<Id> taskIds;
    private final Integer attempt;
    public ChambrRetry(List<Id> taskIds, Integer attempt) { this.taskIds = taskIds; this.attempt = attempt; }

    public void execute(FinalizerContext ctx) {
        Boolean failed = ctx.getResult() == ParentJobResult.UNHANDLED_EXCEPTION;
        if (failed && attempt < 5) {
            System.enqueueJob(new ChambrSendCall(taskIds, attempt + 1), Math.min(attempt * 2, 10)); // minutes
        } else if (taskIds.size() > 1) {
            List<Id> rest = taskIds.clone();
            rest.remove(0);
            System.enqueueJob(new ChambrSendCall(rest));
        }
    }
}
```

## Test with HttpCalloutMock

Production deploys need test coverage. A minimal test:

```apex
@IsTest
private class ChambrSendCallTest {
    private class Accepted implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(202);
            res.setBody('{"id":"6f1c2b0e-8a4d-4c1e-9d55-0d6c7b1e2a90","status":"queued","app_url":"https://app.staging.chambr.ai/t/1/real-calls/2"}');
            return res;
        }
    }

    @IsTest static void storesTheAppUrl() {
        Task t = new Task(Subject = 'Call', Call_Transcript__c = 'Sarah: Hi\nDana: Hello');
        insert t;
        Test.setMock(HttpCalloutMock.class, new Accepted());
        Test.startTest();
        System.enqueueJob(new ChambrSendCall(new List<Id>{ t.Id }));
        Test.stopTest();
        System.assertNotEquals(null, [SELECT Chambr_Call_Url__c FROM Task WHERE Id = :t.Id].Chambr_Call_Url__c);
    }
}
```
