<?xml version="1.0" encoding="utf-8"?>
<AlvaoApplication xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ModelVersion="1">
  <Applications>
    <Application id="1004">
      <Name>Jamf Connector</Name>
      <UniqueId>945b3d9a-40cd-4101-bfc7-c506f320d24d</UniqueId>
      <Version>5</Version>
      <AdvancedSettings>
        <Setting>
          <Name>Jamf.Url</Name>
          <Value />
        </Setting>
        <Setting>
          <Name>Jamf.ClientId</Name>
          <Value />
        </Setting>
        <Setting>
          <Name>Jamf.ClientSecret</Name>
          <Value />
        </Setting>
      </AdvancedSettings>
      <Scripts>
        <Script id="2039">
          <Name>JamfConnectorPeriodicAction</Name>
          <Code>using System;
using System.Data;
using Microsoft.Data.SqlClient;
using Alvao.Global;
using Alvao.API.Common;
using Alvao.Apps.API;
using Alvao.API.Common.Model.Database;
using System.Threading;
using System.Collections.Generic;
using Alvao.Context;
using Dapper;
using System.Threading.Tasks;
using System.Net.Http;
using System.Text;
using System.Net.Http.Headers;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using System.Linq;
using static Alvao.API.AM.Model.Kind;
using Alvao.API.Utils;
using static Alvao.Global.ModuleInfo;
using Alvao.API.Common.Model.CustomApps.Requests;

public class PeriodicAction : IPeriodicAction
{
    public string Name 
    { 
        get =&gt; "PeriodicAction";
        set { }
    }

    private readonly JamfConnectorRepository jamfConnectorRepository = new JamfConnectorRepository();
    private readonly JamfClientService jamfClientService = new JamfClientService();
    private int ImportedObjectsNodeFolderNodeId = 0;

    public void OnPeriod(SqlConnection con)
    {
        if(!Activation.IsModuleActivated(ModuleId.EndpointManagementConnectors))
        {
            Logger.Log.Warn("Jamf Connector cannot be used. Module DeviceDiscovery is not activated.");
            return;
        }

        if(string.IsNullOrEmpty(DbProperty.JamfClientId) || string.IsNullOrEmpty(DbProperty.JamfClientSecret) || string.IsNullOrEmpty(DbProperty.JamfUrl))
        {
            Logger.Log.Warn("Jamf Connector is not properly set. Check Jamf.ClientId, Jamf.ClientSecret and Jamf.Url in advanced settings.");
            return;
        }

        try
        {
            Task task = Task.Run(async () =&gt; await CreateNewOrUpdateChangedDevices(CancellationToken.None));
            task.Wait(); 
        }
        catch (Exception ex)
        {
            Logger.Log.Error(ex, "Error importing devices from Jamf.");
        }
    }

    private async Task CreateNewOrUpdateChangedDevices(CancellationToken cancellationToken)
    {
        var systemPersonId = Alvao.API.Common.Person.GetSystem().iPersonId;
        DateTime? lastAmIntuneCheckInDateTime = jamfConnectorRepository.GetJamfLastCheckInDevicesDateTime();

        IEnumerable&lt;JamfDevice&gt; newOrUpdatedIntuneDevices = await GetNewOrUpdatedDevices(lastAmIntuneCheckInDateTime, cancellationToken);
        cancellationToken.ThrowIfCancellationRequested();

        Logger.Log.Info($"Found {newOrUpdatedIntuneDevices?.Count()} new or updated devices from {lastAmIntuneCheckInDateTime}.");

        if (newOrUpdatedIntuneDevices is null || !newOrUpdatedIntuneDevices.Any())
            return;

        CheckOrAddMissingPropertiesToTemplateClasses();
        //ClearScannerPropertyLockoutTable();
        
        foreach (var device in newOrUpdatedIntuneDevices.OrderBy(d =&gt; d.JamfLastCheckIn))
        {
            Logger.Log.Info($"Processing device {device}");

            cancellationToken.ThrowIfCancellationRequested();

            using var scope = AlvaoContext.GetConnectionScope();
            try
            {
                scope.BeginTransaction();
                tblNode node;
                if (device.NodeId is null || device.NodeId == 0)
                {
                    Logger.Log.Info("Device not found in AM. Creating new");
                    node = CreateNewDevice(device);
                    if (node.intNodeId == 0)
                        continue;
                    Logger.Log.Info("Created new device '{0}': {1}", device.DeviceType.ToString(), device.Hostname);
                }
                else 
                {
                    node = new tblNode() { intNodeId = device.NodeId.Value };
                    Logger.Log.Info($"Device found in AM: {node.txtPath}/{node.txtName}");
                }                    

                if (node is null)
                    throw new NullReferenceException($"Error: Device ID: '{device.JamfDeviceId}' not found in DB and creation failed.");

                //RecalculateScannerPropertyLockoutTable(managedDevice, node.lintClassId ?? 0);

                List&lt;(tblKind.KindCode kindCode, object value)&gt; updatedProperties = UpdateDeviceProperties(node.intNodeId, device);
                Logger.Log.Info("Updated {0} properties.", updatedProperties.Count());

                scope.CommitTransaction();

                
                foreach(var property in updatedProperties){
                    OnObjectPropertyModifiedRequest req = new OnObjectPropertyModifiedRequest(node.intNodeId, Alvao.API.AM.ObjectProperty.GetDefinition(property.kindCode).intKindId, Alvao.API.Common.Person.GetSystem().iPersonId, property.value);
                    Alvao.API.AM.CustomApps.OnObjectPropertyModified(req);
                }
            }
            catch (NullReferenceException ex)
            {
                Logger.Log.Error(ex, "Error processing device.");
            }
        }
        Logger.Log.Info("Processed {0} devices", newOrUpdatedIntuneDevices?.Count());
    }

    public tblNode GetExistingDeviceNode(JamfDevice device)
    {
        if (device.JamfDeviceId is null)
            throw new ArgumentNullException("Missing Jamf ID.");

        tblNode node = jamfConnectorRepository.GetDeviceNodeByAnotherProperty((int)KindCode.JamfDeviceId, device.JamfDeviceId.ToString(), false);

        // computers and devices have conflicting IDs, we have to check their types too
        if(node is not null &amp;&amp; JamfDevice.ClassMap.ContainsKey((tblClass.ClassCode)node.lintClassId) &amp;&amp; JamfDevice.ClassMap[(tblClass.ClassCode)node.lintClassId] != device.DeviceType)
            node = null;

        if (node == null)
        {
            if (device.DeviceType == JamfDevice.Type.MobileDevice)
                node = jamfConnectorRepository.GetDeviceNodeByAnotherProperty((int)KindCode.IMEI, device.Imei, false);
            else
            {
                var isSnBlacklisted = jamfConnectorRepository.GetBlacklistedValuesForKindcode((int)KindCode.BIOS_SN).Any(blacklisted =&gt; device.SerialNo == blacklisted);
                if (!isSnBlacklisted)
                {
                    node = jamfConnectorRepository.GetDeviceNodeByAnotherProperty((int)KindCode.BIOS_SN, device.SerialNo, true);
                }

                if (node is null)
                {
                    node = jamfConnectorRepository.GetDeviceNodeByAnotherProperty((int)KindCode.HostName, device.Hostname, true, string.IsNullOrEmpty(device.SerialNo));
                }                                       
            }
        }
        return node;
    }

    private void CheckOrAddMissingPropertiesToTemplateClasses()
    {
        Dictionary&lt;tblClass.ClassCode, JamfDevice.Type&gt; map = new();
        foreach (var classId in JamfDevice.ClassMap.Keys)
            map[classId] = JamfDevice.ClassMap[classId];

        //add default computer class - if not IN (Desktop,Notebook)
        tblClass.ClassCode defaultComputerClassId = (tblClass.ClassCode)Alvao.API.Common.DbProperty.AMDefaultComputerClass;
        if (!map.ContainsKey(defaultComputerClassId))
            map.Add(defaultComputerClassId, JamfDevice.Type.Computer);

        foreach (var classId in map.Keys)
        {
            var deviceType = map[classId];
            if (!Alvao.API.AM.ObjectProperty.TemplateContains((int)classId, (tblKind.KindCode)KindCode.JamfDeviceId))
            {
                string kindCodes = string.Join(",", JamfDevice.GetPropertyMappingForType(deviceType).Select(x =&gt; ((int)x.Key).ToString()));
                jamfConnectorRepository.AddMissingPropsToAmTemplateAndUnify((int)classId, kindCodes);
            }
        }
    }

    private tblNode CreateNewDevice(JamfDevice managedDevice)
    {
        if (ImportedObjectsNodeFolderNodeId == 0)
            ImportedObjectsNodeFolderNodeId = jamfConnectorRepository.GetOrCreateImportedObjectsFolder();

        int classId = JamfDevice.GetFirstClassIdForDeviceType(managedDevice.DeviceType) ?? throw new InvalidOperationException($"{managedDevice.DeviceType} is not mapped to any classId");
        if (managedDevice.DeviceType == JamfDevice.Type.Computer)
            classId = Alvao.API.Common.DbProperty.AMDefaultComputerClass;

        tblNode node = new();
        switch (managedDevice.DeviceType)
        {
            case JamfDevice.Type.Computer:
                int actualComputerCount = jamfConnectorRepository.GetActualComputerCount();
                /*
                TODO - zatim v apps neumime
                int maxLicenseComputerCount = ((AssetLicense)Alvao.API.Common.Activation.GetModule(Alvao.Global.ModuleInfo.ModuleId.AM)).GetComputerCount();
                if (actualComputerCount &gt;= maxLicenseComputerCount)
                {
                    tenantService.GetDiagnosticsLogger().Warn("A new computer '{Hostname}' hasn't been created (Asset Management license is exceeded).",
                        managedDevice.DeviceName);
                    return new();
                }*/
                node = jamfConnectorRepository.CreateSimpleComputer(classId, ImportedObjectsNodeFolderNodeId, managedDevice.Hostname, Alvao.API.Common.Person.GetSystem().iPersonId);
                break;
            case JamfDevice.Type.MobileDevice:
                node = jamfConnectorRepository.CreateObjectByClass(classId, ImportedObjectsNodeFolderNodeId, Alvao.API.Common.Person.GetSystem().iPersonId);
                break;
        }
        return node;
    }

    private List&lt;(tblKind.KindCode kindCode, object value)&gt;  UpdateDeviceProperties(int nodeId, JamfDevice device)
    {
        List&lt;(tblKind.KindCode kindCode, object value)&gt; codeValue = new List&lt;(tblKind.KindCode kindCode, object value)&gt;();
        using (var scope = AlvaoContext.GetConnectionScope())
        {
            scope.BeginTransaction();
            foreach (var mapping in device.GetPropertyMapping())
            {        
                object value = typeof(JamfDevice).GetProperty(mapping.Value)?.GetValue(device);      

                if(value is null)
                    continue;

                string strValue = value is DateTime? ? ((DateTime)value).ToString("o") : value.ToString();

                Alvao.API.AM.ObjectProperty.Update(nodeId, (tblKind.KindCode)mapping.Key, strValue, false);
                // TODO do not log last check-in change
                codeValue.Add(((tblKind.KindCode)mapping.Key, strValue));
            }
            scope.CommitTransaction();
        }
        return codeValue;
    }

    private async Task&lt;IEnumerable&lt;JamfDevice&gt;&gt; GetNewOrUpdatedDevices(DateTime? lastSyncDateTime, CancellationToken cancellationToken)  
    {
        var devices = await jamfClientService.GetNewOrUpdatedDevices(null, cancellationToken);
        var collisionChecker = new ImportCollisionChecker();
        foreach(var device in devices) 
        {
            tblNode node = GetExistingDeviceNode(device);
            if (node is null) 
            {
                collisionChecker.AddImportedInfo(0, device);
            }
            else 
            {
                device.NodeId = node.intNodeId;
                collisionChecker.AddImportedInfo(node.intNodeId, device, node.ImportDuplicateAlert);
            }                     
        }
        SetAndLogCollisions(collisionChecker);
        var toRemove = collisionChecker.GetCollidingRecords().SelectMany(c =&gt; c.Value.CollidingObjects);
        devices.ToList().RemoveAll(d =&gt; toRemove.Any(r =&gt; d.JamfDeviceId == r.JamfDeviceId));
        return lastSyncDateTime is null ? devices : devices.Where(d =&gt; d.JamfLastCheckIn &gt; lastSyncDateTime);        
    }

    private void SetAndLogCollisions(ImportCollisionChecker collisionChecker) 
    {
        var collisions = collisionChecker.GetCollidingRecords();
        var systemPersonId = Alvao.API.Common.Person.GetSystem().iPersonId;
        foreach (var collision in collisions)
        {
            if (collisionChecker.ShouldLogCollision(collision.Key))
            {
                var log = collisionChecker.GetCollisionLog(collision.Key, systemPersonId);
                using var scope = AlvaoContext.GetConnectionScope();
                scope.BeginTransaction();
                jamfConnectorRepository.InsertIntoObjectLog(log);
                jamfConnectorRepository.SetImportConflict(collision.Key, true);
                scope.CommitTransaction();
            } 
        }

        var noMoreColliding = collisionChecker.GetNodesToUnsetCollisionFlag();
        foreach (var nodeId in noMoreColliding)
        {
            jamfConnectorRepository.SetImportConflict(nodeId, false);
        }
    }
}

public class JamfConnectorRepository 
{
    public DateTime? GetJamfLastCheckInDevicesDateTime()
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.ExecuteScalar&lt;DateTime?&gt;(@$"select top 1
            JamfLastCheckIn
        from NodeCust nc
        join tblNode n on n.intNodeId = nc.NodeId
        where n.IsHidden = 0
            and isdate(substring(JamfLastCheckIn,1,10))=1    --jamf values: 2024-07-09T10:44:13.9769700Z
        order by JamfLastCheckIn desc", new { kind = KindCode.JamfLastCheckIn }, transaction: scope.Transaction);
    }

    public tblNode GetDeviceNodeByAnotherProperty(int keyPropKindCode, string keyPropValue, bool onlyInComputers, bool isBiosSNInSourceEmpty = false)
    {
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.Query&lt;tblNode&gt;(@$"
            declare @columnName nvarchar(255)
            set @columnName = (select k.ColumnName from tblKind k where k.intKindCode = @kindCode)

            declare @valueType nvarchar(255)
            set @valueType = (select DATA_TYPE from INFORMATION_SCHEMA.COLUMNS where TABLE_NAME = 'NodeCust' and COLUMN_NAME = @columnName)
            if (@valueType = 'varchar' or @valueType = 'nvarchar')
                set @valueType = @valueType + '(' + cast((select CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS where TABLE_NAME = 'NodeCust' and COLUMN_NAME = @columnName) as nvarchar(255)) + ')'


            declare @sql nvarchar(max)
            set @sql = '
            select top 1
                n.intNodeId, 
                n.lintClassId,
                n.ImportDuplicateAlert
               -- case when isdate(substring(nc.IntuneLastCheckIn,1,10))=1 then nc.IntuneLastCheckIn else null end as IntuneLastCheckIn
            from tblNode n
                {(onlyInComputers ? "join tblClass c on c.intClassId=n.lintClassId and c.bComputer=1" : "")}
                join ClassKind ck on ck.ClassId=n.lintClassId
                join tblKind k on k.intKindId=ck.KindId and k.intKindCode=@kindCode
                join NodeCust nc on nc.NodeId = n.intNodeId
            where n.IsHidden=0
                and ltrim(rtrim(nc.' + @columnName + '))=@value 
                {(keyPropKindCode == (int)KindCode.HostName &amp;&amp; !isBiosSNInSourceEmpty ? $@"
                    and (nc.BiosSerialNumber is null or nc.BiosSerialNumber in {CreateBlacklistedForDynamicSql(GetCachedBlacklistedValuesForKindcode((int)KindCode.BIOS_SN))}) " : "")}
            order by n.intNodeId asc'

            declare @prmsDecl nvarchar(max)
            set @prmsDecl = N'@kindCode int, @value ' + @valueType

            exec sp_executesql @sql, @prmsDecl, @kindCode, @value",
            new { kindCode = keyPropKindCode, value = (keyPropValue != null) ? keyPropValue.Trim() : null },
            transaction: scope.Transaction).FirstOrDefault();
    }

    private string CreateBlacklistedForDynamicSql(IEnumerable&lt;string&gt; blacklisted)
    {
        StringBuilder sb = new();
        sb.Append('(').Append(string.Join(',', blacklisted.Select(it =&gt; "''" + it + "''"))).Append(')');
        return sb.ToString();
    }

    public IEnumerable&lt;string&gt; GetCachedBlacklistedValuesForKindcode(int kindCode)
    {
        return CacheUtil.GetOrCreateCachedItem("blacklistedValuesForKindCode_" + kindCode, () =&gt; GetBlacklistedValuesForKindcode(kindCode), minutesToCache: 30);
    }

    public IEnumerable&lt;string&gt; GetBlacklistedValuesForKindcode(int kindCode)
    {
        var wbemProp = ObjectWbemProcess.GetWbemEquivalentNameAndClass(kindCode);
        if (wbemProp is null)
        {
            return Enumerable.Empty&lt;string&gt;();
        }
        using (var scope = AlvaoContext.GetConnectionScope())
        {
            return scope.Connection.Query&lt;string&gt;(
                @$"select 
                    txtPropValue
                from tblWbemObjectProcess
                where txtPropName = '{wbemProp.Name}'
                    and txtCLASS = '{wbemProp.Class}'
                ", new { }, scope.Transaction);
        }
    }

    public int GetOrCreateImportedObjectsFolder()
    {
        Logger.Log.Debug(nameof(GetOrCreateImportedObjectsFolder));
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.ExecuteScalar&lt;int&gt;($@"declare @nodeId int
        select top 1
            @nodeId=n.intNodeId
        from tblNode n
        where n.lintClassId=@classId
            and n.IsActive = 1

        if @nodeId is null
        begin
            insert tblNode (lintIconId,intState,txtName,lintClassId)
            select i.intIconId,128,d.txtText,d.lintClassId
            from tblDict d
                join tblIcon i on i.uid=@iconUid
            where d.lintClassId=@classId

            select @nodeId=scope_identity()

            insert tblNodeParent values (@nodeId,@nodeId)
        end

        select @nodeId", new { classId = tblClass.ClassCode.ImportedObjects, iconUid = tblIcon.IconUid.Subnet }, transaction: scope.Transaction);
    }

    public int GetActualComputerCount()
    {
        Logger.Log.Debug(nameof(GetActualComputerCount));
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.ExecuteScalar&lt;int&gt;(@"select count(1) cnt 
        from tblNode n
        join tblClass c on c.intClassId=n.lintClassId
        where c.bComputer=1
        and n.IsActive = 1", transaction: scope.Transaction);
    }

    public void InsertIntoObjectLog(tblLog log)
    {
        Logger.Log.Debug(nameof(InsertIntoObjectLog));
        using var scope = AlvaoContext.GetConnectionScope();
        scope.Connection.Execute(@"
            insert into tblLog (lintNodeId, liLogPersonId, dteLog, txtLog)
            values (@lintNodeId, @liLogPersonId, @dteLog, @txtLog)   
        ", new {log.lintNodeId, log.liLogPersonId, log.dteLog, log.txtLog}, transaction: scope.Transaction);  
    }

    public void SetImportConflict(int nodeId, bool hasConflict)
        {
            using var scope = AlvaoContext.GetConnectionScope();
            scope.Connection.Execute(@"
                update tblNode
                set ImportDuplicateAlert = @hasConflict
                where intNodeId = @nodeId
            ", new { nodeId, hasConflict }, transaction: scope.Transaction);
        }

    public tblNode CreateSimpleComputer(int classId, int parentNodeId, string hostname, int personId)
    {
        Logger.Log.Debug("{0}, {1}, {2}", classId, parentNodeId, hostname);
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.QueryFirst&lt;tblNode&gt;(@"declare @pcNodeId int, @setNodeId int
exec Internal.spCreateSimpleComputer @hostname, @parentNodeId, @personId, @classId, 1, @pcNodeId output, @setNodeId output
select @pcNodeId intNodeId, @classId lintClassId",
            new { hostname, parentNodeId, personId, classId }, transaction: scope.Transaction);
    }

    public tblNode CreateObjectByClass(int classId, int importedObjectsNodeFolderNodeId, int personId)
    {
        Logger.Log.Debug("{0}, {1}, {2}", classId, importedObjectsNodeFolderNodeId, personId);
        using var scope = AlvaoContext.GetConnectionScope();
        return scope.Connection.QueryFirst&lt;tblNode&gt;(@"declare @id int
exec @id=spCreateNodeFromTemplate @classId, '', @parentId, @personId
select @id intNodeId, @classId lintClassId", new { classId, parentId = importedObjectsNodeFolderNodeId, personId }, transaction: scope.Transaction);
    }

    public void AddMissingPropsToAmTemplateAndUnify(int classId, string kindCodes)
    {
        Logger.Log.Debug("Unifying properties classId: {0}, kindCodes: {1}", classId, kindCodes);
        using var scope = AlvaoContext.GetConnectionScope();
            scope.Connection.Execute($@"
if object_id('tempdb..#kinds') is not null
	drop table #kinds

create table #kinds (id int primary key)
insert #kinds (id)
select id from dbo.ftCommaListToTableIds(@kindCodes)

--insert new to template
insert into ClassKind (KindId, ClassId)
select
	k.intKindId,
	@classId classId
from #kinds ids
	join tblKind k on k.intKindCode = ids.id
	left join ClassKind ck on ck.ClassId = @classId and k.intKindId = ck.KindId
where ck.KindId is null
", new { classId, kindCodes }, transaction: scope.Transaction);
        }
}

public class JamfClientService 
{
    private string Token { get; set; }
    private static readonly HttpClient client = new HttpClient();

    private async Task&lt;HttpRequestMessage&gt; PrepareRequest(HttpMethod method, string url, object content = null)
    {
        var completeUrl = $"{DbProperty.JamfUrl}/api/{url}";
        Logger.Log.Debug($"Preparing request {method} {completeUrl}");
        var httpRequest = new HttpRequestMessage(method, completeUrl);
        
        var token = await GetToken();
        httpRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);

        var userAgent = Alvao.AlvaoDbVersion.CurrentAlvaoVersion.Replace(" DEV", "").Replace(" ", "/"); // ALVAO/version
        httpRequest.Headers.Add("User-Agent", userAgent); 

        if(content != null){
            DefaultContractResolver contractResolver = new DefaultContractResolver
            {
                NamingStrategy = new CamelCaseNamingStrategy()
            };
            var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, ContractResolver = contractResolver, };

            var strContent = JsonConvert.SerializeObject(content, settings);

            httpRequest.Content = new StringContent(strContent, Encoding.UTF8, "application/json");
        }
        return httpRequest;
    }

    private async Task&lt;string&gt; GetToken()
    {
        Logger.Log.Debug("Getting token");
        if(!string.IsNullOrEmpty(Token))
            return Token;

        Logger.Log.Debug("Getting new token");
       
       var values = new Dictionary&lt;string, string&gt;
            {
                { "grant_type", "client_credentials" },
                { "client_id", DbProperty.JamfClientId },
                { "client_secret", DbProperty.JamfClientSecret }
            };

        var reqContent = new FormUrlEncodedContent(values);

        var response = await client.PostAsync($"{DbProperty.JamfUrl}/api/v1/oauth/token", reqContent);
        
        response.EnsureSuccessStatusCode();
        Logger.Log.Debug("Token successfully obtained");
        var content = response.Content.ReadAsStringAsync().Result;
        var tokenResponse = JsonConvert.DeserializeObject&lt;JamfClientCredentialsResponse&gt;(content);
        return Token = tokenResponse.access_token;
        
    }

    public async Task&lt;IEnumerable&lt;JamfDevice&gt;&gt; GetNewOrUpdatedDevices(DateTime? lastCheckInDateTime, CancellationToken cancellationToken)
    {
        return (await LoadComputers(null, cancellationToken))
            .Union(
            (await LoadMobileDevices(null, cancellationToken)));                 
    }

    public async Task&lt;IEnumerable&lt;JamfDevice&gt;&gt; LoadComputers(DateTime? lastCheckInDateTime, CancellationToken cancellationToken)
    {
        string filter = string.Empty;
        if(lastCheckInDateTime.HasValue)
        {
            filter = $"&amp;filter=general.lastContactTime=gt=\"{lastCheckInDateTime.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")}\"";
        }
        return await LoadDevices&lt;JamfComputerApiModel&gt;("v1/computers-inventory?section=HARDWARE,GENERAL,OPERATING_SYSTEM,STORAGE,USER_AND_LOCATION&amp;sort=general.lastContactTime:asc", filter, cancellationToken);
    }

    public async Task&lt;IEnumerable&lt;JamfDevice&gt;&gt; LoadMobileDevices(DateTime? lastCheckInDateTime, CancellationToken cancellationToken)
    {
        string filter = string.Empty;
        if(lastCheckInDateTime.HasValue)
        {
            filter = $"&amp;filter=lastInventoryUpdateDate=gt=\"{lastCheckInDateTime.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")}\"";
        }
        return await LoadDevices&lt;JamfMobileDeviceApiModel&gt;("v2/mobile-devices/detail?section=HARDWARE,GENERAL,NETWORK,USER_AND_LOCATION&amp;sort=lastInventoryUpdateDate:asc", filter, cancellationToken);
    }

    public async Task&lt;IEnumerable&lt;JamfDevice&gt;&gt; LoadDevices&lt;T&gt;(string baseUrl, string filter, CancellationToken cancellationToken) where T:JamfApiModel
    {
        int loadedCount = 0;
        int page = 0;
        List&lt;JamfDevice&gt; results = new();
        do 
        {
            loadedCount = 0;
            Logger.Log.Debug($"Loading devices {typeof(T)}");
            var request = await PrepareRequest(HttpMethod.Get, $"{baseUrl}{filter}&amp;page={page++}");
            var response = await client.SendAsync(request);
            var responseContent = await response.Content.ReadAsStringAsync();
            if (response.IsSuccessStatusCode)
            {
                var responseObject = JsonConvert.DeserializeObject&lt;JamfPaginatedResponse&lt;T&gt;&gt;(responseContent);
                loadedCount = responseObject.Results.Count();
                Logger.Log.Debug($"Devices loaded successfully");
                results.AddRange(responseObject.Results.Select(pc =&gt; pc.ToJamfDevice()));
            }
            else
            {
                throw new Exception($"Unsuccessful response from JIRA: {response.StatusCode} {responseContent}");
            }
        }
        while(loadedCount &gt; 0);

        return results;
    }
}

// MODELS
public class JamfDevice 
{
    public enum Type
    {
        Computer,
        MobileDevice
    }
    public int? JamfDeviceId { get; set; }
    public DateTime? JamfLastCheckIn { get; set; }
    public string Hostname { get; set; }
    public string OperatingSystem { get; set; }  // OS + OS version
    public string UserUpn { get; set; } // ??? zjistit asi od zakaznika, jak to pouzivaji
    public string Model { get; set; }
    public string Manufacturer { get; set; } // bude nutne odvodit od OS?
    public string Imei { get; set; }
    public string SerialNo { get; set; }
    public long? TotalStorageSpaceInGB { get; set; }
    public long? RamCapacityInGB { get; set; }
    public string MacAddresses { get; set; }
    public Type DeviceType { get; set; }
    public int? NodeId { get; set; }
    
    public static Dictionary&lt;tblClass.ClassCode, Type&gt; ClassMap = new()
    {
        { tblClass.ClassCode.MobilePhone, Type.MobileDevice },
        { tblClass.ClassCode.Tablet, Type.MobileDevice },
        { tblClass.ClassCode.Computer, Type.Computer },
        { tblClass.ClassCode.ComputerNotebook, Type.Computer },
        { tblClass.ClassCode.ComputerVirtual, Type.Computer }
    };

    public static int? GetFirstClassIdForDeviceType(Type type)
    {
        foreach(var classId in ClassMap.Keys)
        {
            if (ClassMap[classId] == type)
                return (int)classId;
        }
        return null;
    }

    public Dictionary&lt;KindCode, string&gt; GetPropertyMapping()
    {
        return GetPropertyMappingForType(DeviceType);
    }

    public static Dictionary&lt;KindCode, string&gt; GetPropertyMappingForType(Type type)
    {
        Dictionary&lt;KindCode, string&gt; mapping = new()
        {
            {KindCode.UserUpn, nameof(UserUpn)},
            {KindCode.JamfDeviceId, nameof(JamfDeviceId)},
            {KindCode.JamfLastCheckIn, nameof(JamfLastCheckIn)},
            {KindCode.HostName, nameof(Hostname)},
            {KindCode.OperatingSystem, nameof(OperatingSystem)},
            {KindCode.Model, nameof(Model)},
            {KindCode.Manufacturer, nameof(Manufacturer)},
            {KindCode.SerialNo, nameof(SerialNo)},
            {KindCode.MACAddresses, nameof(MacAddresses)},
            {KindCode.TotalStorageSpaceInGB, nameof(TotalStorageSpaceInGB)},
        };

        if(type == Type.Computer)
        {
            mapping.Add(KindCode.BIOS_SN, nameof(SerialNo));
            mapping.Add(KindCode.RAMCapacityInGB, nameof(RamCapacityInGB)); // not available on mobile devices in Jamf
        }

        if(type == Type.MobileDevice)
            mapping.Add(KindCode.IMEI, nameof(Imei));

        return mapping;
    }

    public override string ToString()
    {
            return $"JamfDevice [\n" +
           $"  JamfDeviceId={JamfDeviceId},\n" +
           $"  JamfLastCheckIn={JamfLastCheckIn},\n" +
           $"  Hostname={Hostname},\n" +
           $"  OperatingSystem={OperatingSystem},\n" +
           $"  UserPrincipalName={UserUpn},\n" +
           $"  Model={Model},\n" +
           $"  Manufacturer={Manufacturer},\n" +
           $"  Imei={Imei},\n" +
           $"  SerialNo={SerialNo},\n" +
           $"  TotalStorageSpaceInGB={TotalStorageSpaceInGB},\n" +
           $"  RamCapacityInGB={RamCapacityInGB},\n" +
           $"  DeviceType={DeviceType},\n" +
           $"  MacAddresses={MacAddresses}\n" +
           $"]";
    }
}

public class JamfMobileDeviceApiModel : JamfApiModel
{
    public class HardwareModel 
    {
        public string Make { get; set; }
        public string Model { get; set; }
        public string SerialNumber { get; set; }
        public string WiFiMacAddress { get; set; }
        public long? CapacityMb { get; set; }
    }
    public class GeneralModel 
    {
        public string DisplayName { get; set; }
        public string OsVersion { get; set; }
        public DateTime? LastInventoryUpdateDate { get; set; }
    }
    public class NetworkModel
    {
        public string Imei { get; set; }
    }

    public class UserAndLocationModel 
    {
        public string Username { get; set; }
    }

    public int MobileDeviceId { get; set; }
    public string DeviceType { get; set; } // OS
    public HardwareModel Hardware { get; set; }
    public GeneralModel General { get; set; }
    public NetworkModel Network { get; set; }
    public UserAndLocationModel UserAndLocation { get; set; }

    public override JamfDevice ToJamfDevice()
    {
        return new JamfDevice()
        {
            JamfDeviceId = MobileDeviceId,
            JamfLastCheckIn = General?.LastInventoryUpdateDate,
            Hostname = General?.DisplayName,
            OperatingSystem = $"{DeviceType} {General?.OsVersion}",
            Model = Hardware?.Model,
            Manufacturer = DeviceType == "iOS" ? "Apple" : string.Empty,
            Imei = Network?.Imei,
            SerialNo = Hardware?.SerialNumber,
            TotalStorageSpaceInGB = Hardware?.CapacityMb/1024,
            MacAddresses = GetMacAddresses(Hardware?.WiFiMacAddress),
            DeviceType = JamfDevice.Type.MobileDevice,
            UserUpn = UserAndLocation.Username
        };
    }
}

public class JamfComputerApiModel : JamfApiModel
{
    public int Id { get; set; }
    public HardwareModel Hardware { get; set; }
    public GeneralModel General { get; set; }
    public OperatingSystemModel OperatingSystem { get; set; }
    public StorageModel Storage { get; set; }
    public UserAndLocationModel UserAndLocation { get; set; }

    public class HardwareModel
    {
        public string Make { get; set; }
        public string Model { get; set; }
        public string SerialNumber { get; set; }
        public string MacAddress { get; set; }
        public string AltMacAddress { get; set; }
        public int TotalRamMegabytes { get; set; }
    }
    public class GeneralModel
    {
        public string Name { get; set; }
        public DateTime? LastContactTime { get; set; }
    }

    public class OperatingSystemModel
    {
        public string Name { get; set; }
        public string Version { get; set; }
    }
    
    public class StorageModel
    {
        public IEnumerable&lt;DiskModel&gt; Disks { get; set; }
    }

    public class DiskModel
    {
        public int SizeMegabytes { get; set; }
    }

    public class UserAndLocationModel 
    {
        public string Username { get; set; }
    }

    public override JamfDevice ToJamfDevice()
    {
        return new JamfDevice()
        {
            JamfDeviceId = Id,
            JamfLastCheckIn = General?.LastContactTime,
            Hostname = General?.Name,
            OperatingSystem = $"{OperatingSystem?.Name} {OperatingSystem?.Version}",
            Model = Hardware?.Model,
            Manufacturer = Hardware?.Make,            
            SerialNo = Hardware?.SerialNumber,
            TotalStorageSpaceInGB = Storage?.Disks.Sum(d =&gt; d.SizeMegabytes)/1024,
            RamCapacityInGB = Hardware?.TotalRamMegabytes/1024,
            MacAddresses = GetMacAddresses(Hardware?.MacAddress, Hardware?.AltMacAddress),
            DeviceType = JamfDevice.Type.Computer,
            UserUpn = UserAndLocation.Username
        };
    }
}

public abstract class JamfApiModel 
{
    public abstract JamfDevice ToJamfDevice();

    protected string GetMacAddresses (params string[] MacAddresses)
    { 
        return string.Join("; ", MacAddresses.Where(x =&gt; !string.IsNullOrEmpty(x)).Select(x =&gt; FormatMAC(x)));
    }
    
    private static string FormatMAC(string mac)
    {
        if( mac == null)
            return string.Empty;

        if (mac.Length != 12)
            return mac;

        
        return string.Format("{0}:{1}:{2}:{3}:{4}:{5}",
            mac.Substring(0, 2),
            mac.Substring(2, 2),
            mac.Substring(4, 2),
            mac.Substring(6, 2),
            mac.Substring(8, 2),
            mac.Substring(10, 2)).ToUpper();
    }

}

public class JamfTokenResponse 
{
    public string Token { get; set; }
}

public class JamfClientCredentialsResponse {
    public string access_token { get; set; }
}

public class JamfPaginatedResponse&lt;T&gt;
{
    public int TotalCount { get; set; }
    public IEnumerable&lt;T&gt; Results { get; set; }
}

internal static class ObjectWbemProcess
{
    internal class WbemProp
    {
        public string Class { get; set; }
        public string Name { get; set; }

        public WbemProp(string txtclass, string txtname)
        {
            Class = txtclass;
            Name = txtname;
        }
    }
    private static Dictionary&lt;int, WbemProp&gt; KindToWbemMap = new()
        {
            {87, new("BIOS", "SerialNumber") }
        };
    internal static WbemProp GetWbemEquivalentNameAndClass(int kindCode)
    {
        WbemProp wbemProp = null;
        KindToWbemMap.TryGetValue(kindCode, out wbemProp);
        return wbemProp;
    }
}

/*Groups incoming devices based on Alvao object they matched to. Devices that do not exist in Alvao yet are matched to object ID 0.
     * Collisions are detected when two or more incoming devices match to the same Alvao object (ID &gt; 0) or when two or more incoming devices do not match to any Alvao object (ID = 0) but match each other based on BIOS SN or Hostname.
     */
    public class ImportCollisionChecker
    {
        private Dictionary&lt;int, CollisionInfo&gt; MatchMap { get; set; } = new Dictionary&lt;int, CollisionInfo&gt;();

        public void AddImportedInfo(int nodeId, JamfDevice jamfDevice, bool? errorSetBeforeImport = null) 
        {
            var isFirstOccurence = !MatchMap.ContainsKey(nodeId);
            if (isFirstOccurence)
            {
                MatchMap.Add(nodeId, new CollisionInfo());
            }
            var collisionInfo = MatchMap[nodeId];
            if (errorSetBeforeImport is not null &amp;&amp; isFirstOccurence) collisionInfo.PresentBeforeImport = errorSetBeforeImport.Value;
            collisionInfo.CollidingObjects.Add(jamfDevice);            
        }

        public bool ShouldLogCollision(int nodeId)
        {
            return nodeId &gt; 0 &amp;&amp; HasCollision(nodeId) &amp;&amp; !HadCollisionBeforeImportStarted(nodeId);
        }

        public bool HasCollision(int nodeId)
        {
            MatchMap.TryGetValue(nodeId, out var collisionInfo);
            if (collisionInfo is null) 
            { 
                return false; 
            }
            return collisionInfo.CollidingObjects.Count &gt;= 2;
        }

        public bool HadCollisionBeforeImportStarted(int nodeId)
        {
            MatchMap.TryGetValue(nodeId, out var collisionInfo);
            return collisionInfo?.PresentBeforeImport ?? false;
        }

        public tblLog GetCollisionLog(int nodeId, int personId)
        {
            return new tblLog()
            {
                lintNodeId = nodeId,
                liLogPersonId = personId,
                dteLog = DateTime.UtcNow,
                txtLog = GetCollisionLogMessage(nodeId)
            };
        }

        public Dictionary&lt;int, CollisionInfo&gt; GetCollidingRecords()
        {
            var collisionsOnExistingObjects = MatchMap.Where(it =&gt; it.Key &gt; 0 &amp;&amp; it.Value.CollidingObjects.Count &gt;= 2).ToDictionary(); // two incoming devices matched on one existing AM object (does not matter by which parameter)
            
            if (!MatchMap.ContainsKey(0))
            {
                return collisionsOnExistingObjects;
            }
            
            var collisionsOnObjectsToCreateByBiosSn = MatchMap[0].CollidingObjects.GroupBy(o =&gt; o.SerialNo).Where(gr =&gt; gr.Count() &gt;= 2).SelectMany(gr =&gt; gr); // two incoming devices do not have matching AM object yet, but they match each other based on BIOS SN or Hostname
            var collisionsOnObjectsToCreateByHostname = MatchMap[0].CollidingObjects.GroupBy(o =&gt; o.Hostname).Where(gr =&gt; gr.Count() &gt;= 2).SelectMany(gr =&gt; gr);

            var collisionsOnObjectsToCreate = collisionsOnObjectsToCreateByBiosSn.Union(collisionsOnObjectsToCreateByHostname).DistinctBy(d =&gt; d.JamfDeviceId).ToList();
            return collisionsOnExistingObjects.Concat(new Dictionary&lt;int, CollisionInfo&gt;()
            {
                { 0, new CollisionInfo() { PresentBeforeImport = false, CollidingObjects = collisionsOnObjectsToCreate } }
            }).ToDictionary();
        }

        public IEnumerable&lt;int&gt; GetNodesToUnsetCollisionFlag()
        {
            return MatchMap.Where(it =&gt; it.Key &gt; 0 &amp;&amp; it.Value.CollidingObjects.Count == 1 &amp;&amp; it.Value.PresentBeforeImport).Select(it =&gt; it.Key);
        }

        private string GetCollisionLogMessage(int nodeId)
        {
            var sb = new StringBuilder();
            sb.Append("Duplicate devices were found during import. JAMF device IDs: ");

            var ids = MatchMap[nodeId].CollidingObjects.Select(o =&gt; o.JamfDeviceId).ToList();
            var threeDots = "";
            if (ids.Count &gt; 3) threeDots = "...";

            var idsPart = string.Join(", ", ids.Take(3));
            sb.Append($"{idsPart}{threeDots}");
            return sb.ToString();
        }

        public class CollisionInfo
        {
            public bool PresentBeforeImport { get; set; } = false;
            public List&lt;JamfDevice&gt; CollidingObjects { get; set; } = [];
        }
    }</Code>
          <IsLibCode>false</IsLibCode>
          <Codesign>iOgnETYJQuD5AQhQpVlWFsurqIBbfOTdpSUglr3+OzLVANDYUScu8FTu3dnoo7o2NWBJjXTNUb2w/nEs0Ua9Gldp40Si9DTbKyyvIpGmHRzNHuXN8qaRM/Ui/Rl6nomrYq9ZhQiJKH0feVQyV+kIn2cQirrbjqJ/hCMU0f68NCN8PyjA8BiNjPzmVEr3CY2bt6GwDbeNh3actzKQCJ0MmXkYhnLaJA5R7k3DPeKIBNL//sVog3VeA8bSuIvjjdp6h0MFKj0jQmcsnE9bevowJVlBGVe4QFaoiEJXmhYZvAFvsvl+6Tckgf5sYJqiKs9WzNuCKPJ187DznMW6XNkLJA==</Codesign>
        </Script>
        <Script id="2040">
          <Name>Logger</Name>
          <Code>using System.IO;
using System;
using NLog;
using Alvao.API.Internal;

public static class Logger 
{
    public static ILogger Log = TenantDiagnosticsLog.Get();
}</Code>
          <IsLibCode>true</IsLibCode>
          <Codesign>sqUrA9TtUJOL3D7RxdMmLlTFaDMtcvgGwDoR4LhBpiCEKrb2zGhBa6eXV8mIlynGBPHZNJ+jmuQzrhKSs33lOTUF7zCW218NsblfdsE6NrwktVYltwV80YwWVCWISBLQ8ROcBnnUOHT4kxRdcqrLgKaQatiWZbFWAg66D6jLLlwI+UuDWr8EEFJse5XbmYsDJNudlHxChiFYWGFT7e0Yyt+PxI4lGOiZfOG9fXfMHFfGCJyrTWtxyFoj/L/UQvZ0S8LfIfow3kSfgzLJvjB/uVgs2z5zhCT3bmQZaf+FFcDkoLhcohEwuXf+zp2ixw5G5/rl9CNUdeA+mDYqS+779w==</Codesign>
        </Script>
        <Script id="2041">
          <Name>OpenInJamfObjectCommand</Name>
          <Code>using System;
using System.Data;
using Alvao.Global;
using Alvao.API.Common;
using Alvao.API.Common.Model.CustomApps;
using Alvao.Apps.API;
using Alvao.Context;
using Dapper;
using Alvao.API.AM.Model;

public class OpenInJamfObjectCommand : IEntityCommand 
{
    public string Id {get; set;}
    public Entity Entity {get; set;}

    public OpenInJamfObjectCommand()
    {
        Id = "OpenInJamfObjectCommand";
        Entity = Entity.Object;
    }

    public EntityCommandShowResult Show(int entityId, int personId)
    {   
        int position = 2; //Position (1-4)
        string icon = "open_20_regular"; //Fabric icon
        string name = "Open in Jamf"; //Command name 
        bool show = GetJamfDeviceUrl(entityId) != null; // Set if command should show for person
        return new EntityCommandShowResult (show, name, icon, position);
    }

    public CommandResult Run(int entityId, int personId)
    {
        MessageType messageType = MessageType.None;
        string messageText = null; 
        string navigateToUrl = $"{DbProperty.JamfUrl}/{GetJamfDeviceUrl(entityId)}";
        return new CommandResult(messageType, messageText, navigateToUrl);
    }

    private class JamfDevice 
    {
        public string Id { get; set; }
        public bool IsComputer { get; set; }
    }
    
    private string GetJamfDeviceUrl(int objectId)
    {
        using(var scope = AlvaoContext.GetConnectionScope())
        {
            var device = scope.Connection.QueryFirstOrDefault&lt;JamfDevice&gt;($@"
                select nc.JamfDeviceId Id, c.bComputer IsComputer
                from tblNode n
                join tblClass c on c.intClassId=n.lintClassId
                join NodeCust nc on n.intNodeId=nc.NodeId
                where n.intNodeId=@objectId and nc.JamfDeviceId is not null", new { objectId }, scope.Transaction);

            if(device is null)
                return null;
            
            var url = device.IsComputer ? $"computers.html?id=" : $"mobileDevices.html?id=";
            return url + device.Id;
        }
    }
}</Code>
          <IsLibCode>false</IsLibCode>
          <Codesign>aXXYqWc6iDUhozg1iEGsdG+V2UXGD77isXc3ybzgEd33nBD7oOHX9JiBiLKYYW+sZxupJg44m//kHWfhIKgCMTLAhLnP1/9H8A4Od/B2siXNtujJx1ySOX74qX9IEsaCSZFcKlvZhJAX5VwbyPPCX0AUgwkNtj1pwzS4dr/RG23t3Q0IPJsQomj3JOJ3E0kzsNle6EHZhG2kddQjRF8J2KuOi6d8UoSpwgLjDwzGLYG0+37YwuHJVyQ213dmiy4JqDPrwJMOOy5b/yJX7r+4icgdW3MuUwIwFgJ9LA3G1Tm9lfjLYhRRnoDR+yOfp/0Gt0ou/OrN9vN61Ku3R2DYpA==</Codesign>
        </Script>
      </Scripts>
    </Application>
  </Applications>
</AlvaoApplication>