Codeunit 7102 Contact Sync Processor, source in 29

Source29

src/Layers/W1/BaseApp/CRM/Outlook/ContactSyncProcessor.Codeunit.al407 lines, Copyright (c) Microsoft Corporation. MIT

namespace Microsoft.CRM.Outlook;
using Microsoft.CRM.Contact;

codeunit 7102 "Contact Sync Processor"
{
    var
        SuppressUI: Boolean;
        SyncSuccessLbl: Label '%1 contacts have been synchronized successfully.', Comment = '%1 = Number of synced contacts';
        StartMsg: Label 'This can take a few minutes if you have many contacts.';
        ContactAlreadyExistsLbl: Label 'Contact with this email already exists';
        GraphBatchUriLbl: Label 'https://graph.microsoft.com/v1.0/$batch', Locked = true;
        GraphContactFolderUrlLbl: Label '/me/contactFolders/%1/contacts', Comment = '%1 = Folder ID', Locked = true;
        ContentTypeLbl: Label 'application/json', Locked = true;
        AuthorizationLbl: Label 'Bearer %1', Comment = '%1 = Access token', Locked = true;
        AcceptLbl: Label 'application/json', Locked = true;
        GivenNameLbl: Label 'givenName', Locked = true;
        SurnameLbl: Label 'surname', Locked = true;
        DisplayNameLbl: Label 'displayName', Locked = true;
        CompanyNameLbl: Label 'companyName', Locked = true;
        MobilePhoneLbl: Label 'mobilePhone', Locked = true;
        CategoriesLbl: Label 'categories', Locked = true;
        EmailAddressesLbl: Label 'emailAddresses', Locked = true;
        BusinessPhonesLbl: Label 'businessPhones', Locked = true;
        AddressLbl: Label 'address', Locked = true;
        EmailNameLbl: Label 'name', Locked = true;
        BCCategoryLbl: Label 'Business Central', Locked = true;
        JobTitleLbl: Label 'jobTitle', Locked = true;
        FolderIdLbl: Label 'parentFolderId', Locked = true;
        EmailPrimaryLbl: Label 'primaryEmailAddress', Locked = true;
        InitialsLbl: Label 'initials', Locked = true;
        CityLbl: Label 'city', Locked = true;
        CountyLbl: Label 'state', Locked = true;
        PostCodeLbl: Label 'postalCode', Locked = true;
        CountryorRegionLbl: Label 'countryOrRegion', Locked = true;
        BusinessAddressLbl: Label 'businessAddress', Locked = true;
        BatchSize: Integer;
        // Telemetry/Logging Messages
        SyncStartedTxt: Label 'ProcessBidirectionalSync started with %1 queue entries : Sync Direction %2', Comment = '%1 = queue count, %2 = sync direction', Locked = true;
        NoQueueEntriesTxt: Label 'No contacts in sync queue, exiting', Locked = true;
        SyncingContactToLocalTxt: Label 'Syncing contact  from O365 to Business Central', Locked = true;
        ContactSyncSuccessTxt: Label 'Contact  successfully synced to Business Central', Locked = true;
        NoContactsToSyncMsg: Label 'No contacts were synchronized.', Locked = true;
        ContactSyncFailedTxt: Label 'Failed to sync contact  to Business Central', Locked = true;
        SyncCompletedTxt: Label 'Bidirectional sync completed. Total synced: %1', Comment = '%1 = number of synced contacts', Locked = true;
        CategoryLbl: Label 'Contact Sync', Locked = true, Comment = 'Telemetry category name';
        BatchRequestStartedTxt: Label 'Starting batch request with %1 contacts', Comment = '%1 = batch size', Locked = true;
        BatchRequestCompletedTxt: Label 'Batch request completed with %1 successful, %2 failed', Comment = '%1 = success count, %2 = fail count', Locked = true;
        BatchRequestFailedTxt: Label 'Batch request failed', Locked = true;
        HttpStatusErrorLbl: Label 'HTTP Status: %1', Comment = '%1 = HTTP status code', Locked = true;
        // Batch request JSON keys
        BatchIdLbl: Label 'id', Locked = true;
        BatchMethodLbl: Label 'method', Locked = true;
        BatchUrlLbl: Label 'url', Locked = true;
        BatchHeadersLbl: Label 'headers', Locked = true;
        BatchBodyLbl: Label 'body', Locked = true;
        BatchMethodPostLbl: Label 'POST', Locked = true;
#if not CLEAN29
    [Obsolete('Removed due to Contact Sync redesign, will be deleted in future release.', '29.0')]
    procedure ProcessBidirectionalSync(var TempSyncQueue: Record "Contact Sync Queue" temporary; AccessToken: SecretText)
    begin
    end;
#endif
    procedure ProcessBidirectionalSync(var TempSyncQueue: Record "Contact Sync Queue" temporary; AccessToken: SecretText; FolderId: Text; SyncDirection: Enum "ContactSyncDirection")
    var
        TempBatchQueue: Record "Contact Sync Queue" temporary;
        SyncedCount: Integer;
        BatchCount: Integer;
        ProgressDialog: Dialog;
    begin
        BatchSize := 20; // Microsoft Graph batch limit
        Session.LogMessage('0000QSV', StrSubstNo(SyncStartedTxt, TempSyncQueue.Count(), Format(SyncDirection)), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);
        if not TempSyncQueue.FindSet() then begin
            Session.LogMessage('0000QSW', NoQueueEntriesTxt, Verbosity::Verbose, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);
            exit;
        end;

        SyncedCount := 0;
        BatchCount := 0;
        if not SuppressUI then
            ProgressDialog.Open(StartMsg);

        repeat
            case TempSyncQueue."Sync Direction" of
                TempSyncQueue."Sync Direction"::"To BC":
                    if (SyncDirection = SyncDirection::"Full Sync") then begin
                        Session.LogMessage('0000QSX', StrSubstNo(SyncingContactToLocalTxt), Verbosity::Verbose, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);
                        if SyncContactToBC(TempSyncQueue) then begin
                            Session.LogMessage('0000QSY', StrSubstNo(ContactSyncSuccessTxt), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);
                            TempSyncQueue."Sync Status" := TempSyncQueue."Sync Status"::Processed;
                            SyncedCount += 1;
                        end else begin
                            Session.LogMessage('0000QSZ', StrSubstNo(ContactSyncFailedTxt), Verbosity::Error, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);
                            TempSyncQueue."Sync Status" := TempSyncQueue."Sync Status"::Error;
                        end;
                        TempSyncQueue.Modify(false);
                    end;

                TempSyncQueue."Sync Direction"::"To M365":
                    begin
                        // Add to batch queue
                        TempBatchQueue.TransferFields(TempSyncQueue);
                        TempBatchQueue.Insert(false);
                        BatchCount += 1;
                        // Process batch when we reach 20 records or end of queue
                        if BatchCount >= BatchSize then begin
                            SyncedCount += ProcessBatchToO365(TempBatchQueue, TempSyncQueue, AccessToken, FolderId);
                            TempBatchQueue.DeleteAll();
                            BatchCount := 0;
                        end;
                    end;
            end;
        until TempSyncQueue.Next() = 0;

        // Process remaining batch
        if BatchCount > 0 then
            SyncedCount += ProcessBatchToO365(TempBatchQueue, TempSyncQueue, AccessToken, FolderId);

        if not SuppressUI then
            ProgressDialog.Close();
        Session.LogMessage('0000QT3', StrSubstNo(SyncCompletedTxt, SyncedCount), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);
        if not SuppressUI then
            if SyncedCount > 0 then
                Message(SyncSuccessLbl, SyncedCount)
            else
                Message(NoContactsToSyncMsg);
    end;

    [NonDebuggable]
    local procedure ProcessBatchToO365(var TempBatchQueue: Record "Contact Sync Queue" temporary; var TempSyncQueue: Record "Contact Sync Queue" temporary; AccessToken: SecretText; FolderId: Text): Integer
    var
        HttpClient: HttpClient;
        HttpContent: HttpContent;
        HttpResponse: HttpResponseMessage;
        BatchRequest: JsonObject;
        RequestsArray: JsonArray;
        ResponseJson: JsonObject;
        ResponsesArray: JsonArray;
        ResponseToken: JsonToken;
        BatchJsonText: Text;
        ResponseText: Text;
        Headers: HttpHeaders;
        RequestId: Integer;
        SuccessCount: Integer;
        FailCount: Integer;
        StatusCode: Integer;
    begin
        if not TempBatchQueue.FindSet() then
            exit(0);
        Session.LogMessage('0000QT8', StrSubstNo(BatchRequestStartedTxt, TempBatchQueue.Count()), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);

        RequestId := 1;
        repeat
            RequestsArray.Add(BuildBatchRequestItem(TempBatchQueue, FolderId, RequestId));
            RequestId += 1;
        until TempBatchQueue.Next() = 0;

        BatchRequest.Add('requests', RequestsArray);
        BatchRequest.WriteTo(BatchJsonText);

        HttpContent.WriteFrom(BatchJsonText);
        HttpContent.GetHeaders(Headers);
        Headers.Clear();
        Headers.Add('Content-Type', ContentTypeLbl);

        HttpClient.DefaultRequestHeaders.Add('Authorization', SecretStrSubstNo(AuthorizationLbl, AccessToken));
        HttpClient.DefaultRequestHeaders.Add('Accept', AcceptLbl);

        if not HttpClient.Post(GraphBatchUriLbl, HttpContent, HttpResponse) then begin
            // Mark all as error
            TempBatchQueue.SetLoadFields("Sync Status", "Error Message");
            if TempBatchQueue.FindSet() then
                repeat
                    if TempSyncQueue.Get(TempBatchQueue."Entry No.") then begin
                        TempSyncQueue."Sync Status" := TempSyncQueue."Sync Status"::Error;
                        TempSyncQueue."Error Message" := BatchRequestFailedTxt;
                        TempSyncQueue.Modify(false);
                    end;
                until TempBatchQueue.Next() = 0;
            exit(0);
        end;

        HttpResponse.Content.ReadAs(ResponseText);
        ResponseJson.ReadFrom(ResponseText);

        // Process batch responses
        SuccessCount := 0;
        FailCount := 0;

        if ResponseJson.Get('responses', ResponseToken) then begin
            ResponsesArray := ResponseToken.AsArray();
            TempBatchQueue.FindSet();
            RequestId := 1;

            repeat
                if GetBatchResponseStatus(ResponsesArray, RequestId, StatusCode) then
                    if TempSyncQueue.Get(TempBatchQueue."Entry No.") then begin
                        if (StatusCode >= 200) and (StatusCode < 300) then begin
                            TempSyncQueue."Sync Status" := TempSyncQueue."Sync Status"::Processed;
                            SuccessCount += 1;
                        end else begin
                            TempSyncQueue."Sync Status" := TempSyncQueue."Sync Status"::Error;
                            TempSyncQueue."Error Message" := StrSubstNo(HttpStatusErrorLbl, StatusCode);
                            FailCount += 1;
                        end;
                        TempSyncQueue.Modify(false);
                    end;
                RequestId += 1;
            until TempBatchQueue.Next() = 0;
        end;

        Session.LogMessage('0000QT9', StrSubstNo(BatchRequestCompletedTxt, SuccessCount, FailCount), Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CategoryLbl);
        exit(SuccessCount);
    end;

    local procedure BuildBatchRequestItem(Contact: Record "Contact Sync Queue"; FolderId: Text; RequestId: Integer): JsonObject
    var
        RequestItem: JsonObject;
        HeadersObject: JsonObject;
        BodyObject: JsonObject;
        BodyText: Text;
    begin
        BodyText := BuildContactJsonBody(Contact, FolderId);
        BodyObject.ReadFrom(BodyText);

        RequestItem.Add(BatchIdLbl, Format(RequestId));
        RequestItem.Add(BatchMethodLbl, BatchMethodPostLbl);
        RequestItem.Add(BatchUrlLbl, StrSubstNo(GraphContactFolderUrlLbl, FolderId));

        HeadersObject.Add('Content-Type', ContentTypeLbl);
        RequestItem.Add(BatchHeadersLbl, HeadersObject);
        RequestItem.Add(BatchBodyLbl, BodyObject);

        exit(RequestItem);
    end;

    local procedure GetBatchResponseStatus(ResponsesArray: JsonArray; RequestId: Integer; var StatusCode: Integer): Boolean
    var
        ResponseToken: JsonToken;
        ResponseObject: JsonObject;
        IdToken: JsonToken;
        StatusToken: JsonToken;
        i: Integer;
    begin
        // Responses are typically returned in the same order as requests
        // Try direct index access first (RequestId is 1-based, array is 0-based)
        if ResponsesArray.Get(RequestId - 1, ResponseToken) then begin
            ResponseObject := ResponseToken.AsObject();
            if ResponseObject.Get('id', IdToken) then
                if IdToken.AsValue().AsText() = Format(RequestId) then
                    if ResponseObject.Get('status', StatusToken) then begin
                        StatusCode := StatusToken.AsValue().AsInteger();
                        exit(true);
                    end;
        end;

        // Fallback to linear search if direct access didn't match (order not guaranteed)
        for i := 0 to ResponsesArray.Count() - 1 do begin
            ResponsesArray.Get(i, ResponseToken);
            ResponseObject := ResponseToken.AsObject();

            if ResponseObject.Get('id', IdToken) then
                if IdToken.AsValue().AsText() = Format(RequestId) then
                    if ResponseObject.Get('status', StatusToken) then begin
                        StatusCode := StatusToken.AsValue().AsInteger();
                        exit(true);
                    end;
        end;
        exit(false);
    end;

    local procedure SyncContactToBC(var TempSyncQueue: Record "Contact Sync Queue" temporary): Boolean
    var
        Contact: Record Contact;
    begin
        // Check if contact already exists
        Contact.Reset();
        Contact.SetRange("E-Mail", CopyStr(TempSyncQueue."Email Address", 1, MaxStrLen(Contact."E-Mail")));
        Contact.SetRange(Type, Contact.Type::Person);
        if Contact.FindFirst() then begin
            TempSyncQueue."Error Message" := ContactAlreadyExistsLbl;
            exit(false); // Contact already exists
        end;

        // Create new contact in BC
        Contact.Init();
        Contact."No." := '';
        Contact.Type := Contact.Type::Person;
        if TempSyncQueue."Display Name" <> '' then
            Contact.Name := CopyStr(TempSyncQueue."Display Name", 1, MaxStrLen(Contact.Name))
        else
            Contact.Name := CopyStr(TempSyncQueue."Given Name" + ' ' + TempSyncQueue.Surname, 1, MaxStrLen(Contact.Name));
        Contact."First Name" := CopyStr(TempSyncQueue."Given Name", 1, MaxStrLen(Contact."First Name"));
        Contact.Surname := CopyStr(TempSyncQueue.Surname, 1, MaxStrLen(Contact.Surname));
        Contact."E-Mail" := CopyStr(TempSyncQueue."Email Address", 1, MaxStrLen(Contact."E-Mail"));
        Contact."Job Title" := CopyStr(TempSyncQueue."Job Title", 1, MaxStrLen(Contact."Job Title"));
        Contact."Company Name" := CopyStr(TempSyncQueue."Company Name", 1, MaxStrLen(Contact."Company Name"));
        Contact."Phone No." := CopyStr(TempSyncQueue."Business Phone", 1, MaxStrLen(Contact."Phone No."));
        Contact."Mobile Phone No." := CopyStr(TempSyncQueue."Mobile Phone", 1, MaxStrLen(Contact."Mobile Phone No."));
        Contact.Address := CopyStr(TempSyncQueue.Address, 1, MaxStrLen(Contact.Address));
        Contact.City := CopyStr(TempSyncQueue.City, 1, MaxStrLen(Contact.City));
        Contact.County := CopyStr(TempSyncQueue.County, 1, MaxStrLen(Contact.County));
        Contact."Post Code" := CopyStr(TempSyncQueue."Post Code", 1, MaxStrLen(Contact."Post Code"));
        Contact."Country/Region Code" := CopyStr(TempSyncQueue."Country/Region Code", 1, MaxStrLen(Contact."Country/Region Code"));
        if Contact.Insert(true) then begin
            TempSyncQueue."BC Contact No." := Contact."No.";
            exit(true);
        end;
        exit(false);
    end;

    local procedure BuildContactJsonBody(Contact: Record "Contact Sync Queue"; FolderId: Text): Text
    var
        JsonObject: JsonObject;
        JsonText: Text;
    begin
        JsonObject.Add(GivenNameLbl, Contact."Given Name");
        JsonObject.Add(SurnameLbl, Contact.Surname);
        if Contact."Display Name" <> '' then
            JsonObject.Add(DisplayNameLbl, Contact."Display Name")
        else
            JsonObject.Add(DisplayNameLbl, Contact."Given Name" + ' ' + Contact.Surname);
        JsonObject.Add(CompanyNameLbl, Contact."Company Name");
        JsonObject.Add(InitialsLbl, Contact.Initials);
        JsonObject.Add(MobilePhoneLbl, Contact."Mobile Phone");
        JsonObject.Add(JobTitleLbl, Contact."Job Title");
        JsonObject.Add(CategoriesLbl, BuildCategoriesArray());
        JsonObject.Add(FolderIdLbl, FolderId);

        // Add email addresses
        if Contact."Email Address" <> '' then
            JsonObject.Add(EmailPrimaryLbl, BuildPrimaryEmailArray(Contact."Email Address"));
        if Contact."Email 2" <> '' then
            JsonObject.Add(EmailAddressesLbl, BuildEmailArray(Contact."Email 2"));
        // Add business phones
        if Contact."Business Phone" <> '' then
            JsonObject.Add(BusinessPhonesLbl, BuildPhoneArray(Contact."Business Phone"));
        JsonObject.Add(BusinessAddressLbl, BuildAddressObject(Contact));
        JsonObject.WriteTo(JsonText);
        exit(JsonText);
    end;

    local procedure BuildAddressObject(var Contact: Record "Contact Sync Queue"): JsonObject
    var
        AddressObject: JsonObject;
    begin
        AddressObject.Add(CityLbl, Contact.City);
        AddressObject.Add(CountyLbl, Contact.County);
        AddressObject.Add(PostCodeLbl, Contact."Post Code");
        AddressObject.Add(CountryorRegionLbl, Contact."Country/Region Code");
        exit(AddressObject);
    end;

    local procedure BuildEmailArray(EmailAddress: Text[80]): JsonArray
    var
        EmailArray: JsonArray;
        EmailObject: JsonObject;
    begin
        EmailObject.Add(AddressLbl, EmailAddress);
        EmailObject.Add(EmailNameLbl, EmailAddress);
        EmailArray.Add(EmailObject);
        exit(EmailArray);
    end;

    local procedure BuildPrimaryEmailArray(EmailAddress: Text[80]): JsonObject
    var
        EmailObject: JsonObject;
    begin
        EmailObject.Add(AddressLbl, EmailAddress);
        EmailObject.Add(EmailNameLbl, EmailAddress);
        exit(EmailObject);
    end;

    local procedure BuildPhoneArray(PhoneNo: Text[30]): JsonArray
    var
        PhoneArray: JsonArray;
    begin
        PhoneArray.Add(PhoneNo);
        exit(PhoneArray);
    end;

    local procedure BuildCategoriesArray(): JsonArray
    var
        CategoriesArray: JsonArray;
    begin
        CategoriesArray.Add(BCCategoryLbl);
        exit(CategoriesArray);
    end;

    /// <summary>
    /// Suppresses UI elements (dialogs and messages) during execution.
    /// Use this method in test scenarios to prevent unexpected UI prompts.
    /// </summary>
    /// <param name="Suppress">Set to true to suppress UI elements.</param>
    procedure SetSuppressUI(Suppress: Boolean)
    begin
        SuppressUI := Suppress;
    end;

    /// <summary>
    /// Returns whether UI suppression is currently enabled.
    /// </summary>
    /// <returns>True if UI is suppressed, false otherwise.</returns>
    procedure IsSuppressUI(): Boolean
    begin
        exit(SuppressUI);
    end;
}