diff --git a/AnonymousUserEventTracking.md b/AnonymousUserEventTracking.md new file mode 100644 index 00000000..a9c51e50 --- /dev/null +++ b/AnonymousUserEventTracking.md @@ -0,0 +1,113 @@ +# AnonymousUserManager Class + +## Class Introduction + +The `AnonymousUserManager` class is responsible for managing anonymous user sessions and tracking events. +The `AnonymousUserManager+Functions` class is contains util functions and `CriteriaCompletionChecker` struct which contains criteria checking logic. +It includes methods for updating sessions, tracking events (i.e custom event, update cart, update user and purchase) and create a user if criterias are met. +We call track methods of this class internally to make sure we have tracked the events even when user is NOT logged in and after certain criterias are met we create a user and logs them automatically and sync events through Iterable API. + +## Class Structure + +The `AnonymousUserManager` class includes the following key components: + +- **Methods:** + - `updateAnonSession()`: Updates the anonymous user session. + - `trackAnonEvent(name: String, dataFields: [AnyHashable: Any]?)`: Tracks an anonymous event and store it locally. + - `trackAnonPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?)`: Tracks an anonymous purchase event and store it locally. + - `trackAnonUpdateUser(_ dataFields: [AnyHashable: Any])`: Tracks an anonymous update user event and store it locally. + - `trackAnonUpdateCart(items: [CommerceItem])`: Tracks an anonymous cart event and store it locally. + - `trackAnonTokenRegistration(token: String)`: Tracks an anonymous token registration event and store it locally. + - `getAnonCriteria()`: Gets the anonymous criteria. + - `checkCriteriaCompletion()`: Checks if criterias are being met. + - `createKnownUser()`: Creates a user after criterias met and login the user and then sync the data through track APIs. + - `syncEvents()`: Syncs locally saved data through track APIs. + - `updateAnonSession()`: Stores an anonymous sessions locally. Update the last session time when new session is created. + - `storeEventData()`: Stores event data locally. + - `logout()`: Reset the locally saved data when user logs out to make sure no old data is left. + - `syncNonSyncedEvents()`: Syncs unsynced data which might have failed to sync when calling syncEvents for the first time after criterias met. + - `convertCommerceItems(from dictionaries: [[AnyHashable: Any]]) -> [CommerceItem]`: Convert to commerce items from dictionaries. + - `convertCommerceItemsToDictionary(_ items: [CommerceItem]) -> [[AnyHashable:Any]]`: Convert commerce items to dictionaries. + - `getUTCDateTime()`: Converts UTC Datetime from current time. + + +## Methods Description + +### `updateAnonSession()` + +This method updates the anonymous user session. It does the following: + +* Retrieves the previous session data from local storage. +* Increments the session number. +* Stores the updated session data back to local storage. + +### `trackAnonEvent(name: String, dataFields: [AnyHashable: Any]?)` + +This method tracks an anonymous event. It does the following: + +* Creates a dictionary object with event details, including the event name, timestamp, data fields, and tracking type. +* Stores the event data in local storage. +* Checks criteria completion and creates a known user if criteria are met. + +### `trackAnonPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?)` + +This method tracks an anonymous purchase event. It does the following: + +* Converts the list of commerce items to JSON. +* Creates a dictionary object with purchase event details, including items, total, timestamp, data fields, and tracking type. +* Stores the purchase event data in local storage. +* Checks criteria completion and creates a known user if criteria are met. + +### `trackAnonUpdateUser(dataFields: [AnyHashable: Any]?)` + +This method tracks an anonymous update user event. It does the following: + +* Creates a dictionary object with event details, including the event name, timestamp, data fields, and tracking type. +* Stores the event data in local storage, and if data of this event already exists it replaces the data. +* Checks criteria completion and creates a known user if criteria are met. + +### `trackAnonUpdateCart(items: [CommerceItem])` + +This method tracks an anonymous cart update. It does the following: + +* Converts the list of commerce items to dictionary. +* Creates a dictionary object with cart update details, including items, timestamp, and tracking type. +* Stores the cart update data in local storage. +* Checks criteria completion and creates a known user if criteria are met. + +### `trackAnonTokenRegistration(token: String)` + +This method tracks an anonymous token registration event and stores it locally. + +### `getAnonCriteria()` + +This method is responsible for fetching criteria data. It simulates calling an API and saving data in local storage. + +### `checkCriteriaCompletion()` + +This private method checks if criteria for creating a known user are met. It compares stored event data with predefined criteria and returns `criteriaId` if any of the criteria is matched. + +### `createKnownUser()` + +This method is responsible for creating a known user in the Iterable API. It does the following: + +* Sets a random user ID using a UUID (Universally Unique Identifier). +* Retrieves user session data from local storage. +* If user session data exists, it updates the user information in the Iterable API. +* Calls the syncEvents() method to synchronize anonymous tracked events. +* Finally, it clears locally stored data after data is syncronized. + +### `syncEvents()` + +This method is used to synchronize anonymous tracked events stored in local storage with the Iterable API. It performs the following tasks: + +* Retrieves the list of tracked events from local storage. +* Iterates through the list of events and processes each event based on its type. +* Supported event types include regular event tracking, purchase event tracking, and cart update tracking. +* For each event, it extracts relevant data, including event name, data fields, items (for purchase and cart update events), and timestamps. +* It then calls the Iterable API to sync these events. +* After processing all the events, it clears locally stored event data. + +### `updateAnonSession()` + +This method is responsible for storing/updating anonymous sessions locally. It updates the last session time each time when new session is created. diff --git a/AnonymousUserMerge.md b/AnonymousUserMerge.md new file mode 100644 index 00000000..5cf4cd35 --- /dev/null +++ b/AnonymousUserMerge.md @@ -0,0 +1,38 @@ +# AnonymousUserMerge Class + +## Class Introduction + +The `AnonymousUserMerge` class is responsible for merging anonymous user with logged-in one. +It includes methods for merge user by userId and emailId. +We call methods of this class internally to merge user when setUserId or setEmail method call. After merge we sync events through Iterable API. + +## Class Structure + +The `AnonymousUserMerge` class includes the following key components: + +- **Methods:** + - `mergeUserUsingUserId(apiClient: IterableApiClient, destinationUserId: String)`: Merge user using userID if anonymous user exists and sync events + - `mergeUserUsingEmail(apiClient: IterableApiClient, destinationEmail: String)`: Merge user using emailId if anonymous user exists and sync events + - `callMergeApi(apiClient: IterableApiClient, sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String)`: Call API to merge user and sync remaining events. + +## Methods Description + +### `mergeUserUsingUserId(apiClient: IterableApiClient, destinationUserId: String)` + +This method merge the anonymous user with the logged-in one. It does the following: + +* Check for user exists using userId. +* If user exists then call the merge user API. + +### `mergeUserUsingEmail(apiClient: IterableApiClient, destinationEmail: String)` + +This method merge the anonymous user with the logged-in one. It does the following: + +* Check for user exists using emailId. +* If user exists then call the merge user API. + +### `callMergeApi(apiClient: IterableApiClient, sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String)` + +This method call API to merge user. It does the following: + +* Call the Iterable API and sync remaining events. diff --git a/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj b/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj index 17659f0a..5e3d1d51 100644 --- a/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj +++ b/sample-apps/swift-sample-app/swift-sample-app.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 37088F332B3C38250000B218 /* IterableAppExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 37088F322B3C38250000B218 /* IterableAppExtensions */; }; + 37088F352B3C38250000B218 /* IterableSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 37088F342B3C38250000B218 /* IterableSDK */; }; 551A5FF1251AB1950004C9A0 /* IterableSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 551A5FF0251AB1950004C9A0 /* IterableSDK */; }; 551A5FF3251AB19B0004C9A0 /* IterableAppExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 551A5FF2251AB19B0004C9A0 /* IterableAppExtensions */; }; AC1BDF5820E304BC000010CA /* CoffeeListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5ECD9E20E304000081E1DA /* CoffeeListTableViewController.swift */; }; @@ -73,6 +75,7 @@ buildActionMask = 2147483647; files = ( 551A5FF1251AB1950004C9A0 /* IterableSDK in Frameworks */, + 37088F352B3C38250000B218 /* IterableSDK in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -80,6 +83,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 37088F332B3C38250000B218 /* IterableAppExtensions in Frameworks */, 551A5FF3251AB19B0004C9A0 /* IterableAppExtensions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -202,6 +206,7 @@ name = "swift-sample-app"; packageProductDependencies = ( 551A5FF0251AB1950004C9A0 /* IterableSDK */, + 37088F342B3C38250000B218 /* IterableSDK */, ); productName = "swift-sample-app"; productReference = ACA3A13520E2F6AF00FEF74F /* swift-sample-app.app */; @@ -222,6 +227,7 @@ name = "swift-sample-app-notification-extension"; packageProductDependencies = ( 551A5FF2251AB19B0004C9A0 /* IterableAppExtensions */, + 37088F322B3C38250000B218 /* IterableAppExtensions */, ); productName = "swift-sample-app-notification-extension"; productReference = ACA3A14E20E2F83D00FEF74F /* swift-sample-app-notification-extension.appex */; @@ -264,6 +270,8 @@ Base, ); mainGroup = ACA3A12C20E2F6AF00FEF74F; + packageReferences = ( + ); productRefGroup = ACA3A13620E2F6AF00FEF74F /* Products */; projectDirPath = ""; projectRoot = ""; @@ -572,6 +580,14 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 37088F322B3C38250000B218 /* IterableAppExtensions */ = { + isa = XCSwiftPackageProductDependency; + productName = IterableAppExtensions; + }; + 37088F342B3C38250000B218 /* IterableSDK */ = { + isa = XCSwiftPackageProductDependency; + productName = IterableSDK; + }; 551A5FF0251AB1950004C9A0 /* IterableSDK */ = { isa = XCSwiftPackageProductDependency; productName = IterableSDK; diff --git a/swift-sdk.xcodeproj/project.pbxproj b/swift-sdk.xcodeproj/project.pbxproj index fe5c2213..0295f38f 100644 --- a/swift-sdk.xcodeproj/project.pbxproj +++ b/swift-sdk.xcodeproj/project.pbxproj @@ -11,6 +11,17 @@ 00B6FACE210E88ED007535CF /* prod-1.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = 00B6FACD210E874D007535CF /* prod-1.mobileprovision */; }; 00B6FAD1210E8D90007535CF /* dev-1.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = 00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */; }; 00CB31B621096129004ACDEC /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00CB31B4210960C4004ACDEC /* TestUtils.swift */; }; + 370198B72B427F07007DBFEA /* anoncriteria_response.json in Resources */ = {isa = PBXBuildFile; fileRef = 370198B62B427F07007DBFEA /* anoncriteria_response.json */; }; + 373267FE2B4D51B200CC82C9 /* AnonymousUserMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373267FD2B4D51B200CC82C9 /* AnonymousUserMerge.swift */; }; + 373267FF2B4D51B200CC82C9 /* IterableSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AC2263DF20CF49B8009800EB /* IterableSDK.framework */; platformFilter = ios; }; + 373268062B4D52DA00CC82C9 /* AnonymousUserMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373268052B4D52DA00CC82C9 /* AnonymousUserMergeTests.swift */; }; + 379C34AA2B3F021B0077E631 /* AnonymousUserCriteriaMatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C34A82B3F021B0077E631 /* AnonymousUserCriteriaMatchTests.swift */; }; + 379C34AB2B3F021B0077E631 /* AnonymousUserMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C34A92B3F021B0077E631 /* AnonymousUserMergeTests.swift */; }; + 379C34B02B3F05090077E631 /* AnonymousUserMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C34AC2B3F05090077E631 /* AnonymousUserMerge.swift */; }; + 379C34B12B3F05090077E631 /* AnonymousUserManager+Functions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C34AD2B3F05090077E631 /* AnonymousUserManager+Functions.swift */; }; + 379C34B22B3F05090077E631 /* AnonymousUserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C34AE2B3F05090077E631 /* AnonymousUserManager.swift */; }; + 379C34B32B3F05090077E631 /* AnonymousUserManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C34AF2B3F05090077E631 /* AnonymousUserManagerProtocol.swift */; }; + 379C34B52B3F0FB00077E631 /* AnonymousUserMergeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379C34B42B3F0FB00077E631 /* AnonymousUserMergeProtocol.swift */; }; 552A0AA7280E1FDA00A80963 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552A0AA6280E1FDA00A80963 /* DeepLinkManager.swift */; }; 5531CDAC22A997A4000D05E2 /* IterableInboxViewControllerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5585DF9022A877E6000A32B9 /* IterableInboxViewControllerUITests.swift */; }; 5531CDAE22A9C992000D05E2 /* ClassExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5531CDAD22A9C992000D05E2 /* ClassExtensionsTests.swift */; }; @@ -388,6 +399,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 373268002B4D51B200CC82C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AC2263D620CF49B8009800EB /* Project object */; + proxyType = 1; + remoteGlobalIDString = AC2263DE20CF49B8009800EB; + remoteInfo = "swift-sdk"; + }; 5B38881D27FAE6DB00482BE7 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AC2263D620CF49B8009800EB /* Project object */; @@ -522,6 +540,17 @@ 00B6FACD210E874D007535CF /* prod-1.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "prod-1.mobileprovision"; sourceTree = ""; }; 00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "dev-1.mobileprovision"; sourceTree = ""; }; 00CB31B4210960C4004ACDEC /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; + 370198B62B427F07007DBFEA /* anoncriteria_response.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = anoncriteria_response.json; sourceTree = ""; }; + 373267FB2B4D51B200CC82C9 /* AnonymousUserMerge.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AnonymousUserMerge.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 373267FD2B4D51B200CC82C9 /* AnonymousUserMerge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnonymousUserMerge.swift; sourceTree = ""; }; + 373268052B4D52DA00CC82C9 /* AnonymousUserMergeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserMergeTests.swift; sourceTree = ""; }; + 379C34A82B3F021B0077E631 /* AnonymousUserCriteriaMatchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserCriteriaMatchTests.swift; sourceTree = ""; }; + 379C34A92B3F021B0077E631 /* AnonymousUserMergeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserMergeTests.swift; sourceTree = ""; }; + 379C34AC2B3F05090077E631 /* AnonymousUserMerge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserMerge.swift; sourceTree = ""; }; + 379C34AD2B3F05090077E631 /* AnonymousUserManager+Functions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnonymousUserManager+Functions.swift"; sourceTree = ""; }; + 379C34AE2B3F05090077E631 /* AnonymousUserManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserManager.swift; sourceTree = ""; }; + 379C34AF2B3F05090077E631 /* AnonymousUserManagerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnonymousUserManagerProtocol.swift; sourceTree = ""; }; + 379C34B42B3F0FB00077E631 /* AnonymousUserMergeProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnonymousUserMergeProtocol.swift; sourceTree = ""; }; 55298B222501A5AB00190BAE /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; 552A0AA6280E1FDA00A80963 /* DeepLinkManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; 5531CDAD22A9C992000D05E2 /* ClassExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassExtensionsTests.swift; sourceTree = ""; }; @@ -775,6 +804,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 373267F82B4D51B200CC82C9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 373267FF2B4D51B200CC82C9 /* IterableSDK.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DB20CF49B8009800EB /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -873,6 +910,24 @@ name = "Test Files"; sourceTree = ""; }; + 373267FC2B4D51B200CC82C9 /* AnonymousUserMerge */ = { + isa = PBXGroup; + children = ( + 373267FD2B4D51B200CC82C9 /* AnonymousUserMerge.swift */, + ); + path = AnonymousUserMerge; + sourceTree = ""; + }; + 379C34A32B3F00570077E631 /* anonymous-user-tests */ = { + isa = PBXGroup; + children = ( + 373268052B4D52DA00CC82C9 /* AnonymousUserMergeTests.swift */, + 379C34A82B3F021B0077E631 /* AnonymousUserCriteriaMatchTests.swift */, + 379C34A92B3F021B0077E631 /* AnonymousUserMergeTests.swift */, + ); + name = "anonymous-user-tests"; + sourceTree = ""; + }; 552A0AA8280E22B100A80963 /* device-token-tests */ = { isa = PBXGroup; children = ( @@ -944,6 +999,7 @@ AC90C4C520D8632E00EECA5D /* notification-extension */, ACFCA72920EB02DB00BFB277 /* tests */, 5550F22324217CFC0014456A /* misc */, + 373267FC2B4D51B200CC82C9 /* AnonymousUserMerge */, ); sourceTree = ""; }; @@ -961,6 +1017,7 @@ ACFF429E24656BDF00FDF10D /* ui-tests-app.app */, AC28480724AA44C600C1FC7F /* endpoint-tests.xctest */, ACFD5AB824C8200C008E497A /* offline-events-tests.xctest */, + 373267FB2B4D51B200CC82C9 /* AnonymousUserMerge.xctest */, ); name = Products; path = ../..; @@ -1130,6 +1187,7 @@ isa = PBXGroup; children = ( AC219C522260006600B98631 /* Assets.xcassets */, + 370198B62B427F07007DBFEA /* anoncriteria_response.json */, AC50865224C60172001DC132 /* IterableDataModel.xcdatamodeld */, AC219C4F225FEDBD00B98631 /* SampleInboxCell.xib */, ); @@ -1220,6 +1278,10 @@ ACC362BB24D21153002C67BA /* Task Processing */, AC72A0AC20CF4C08004D7997 /* Util */, AC72A0C420CF4CB8004D7997 /* ActionRunner.swift */, + 379C34AE2B3F05090077E631 /* AnonymousUserManager.swift */, + 379C34AD2B3F05090077E631 /* AnonymousUserManager+Functions.swift */, + 379C34AF2B3F05090077E631 /* AnonymousUserManagerProtocol.swift */, + 379C34AC2B3F05090077E631 /* AnonymousUserMerge.swift */, AC84256126D6167E0066C627 /* AppExtensionHelper.swift */, 557AE6BE24A56E5E00B57750 /* Auth.swift */, 55298B222501A5AB00190BAE /* AuthManager.swift */, @@ -1229,6 +1291,7 @@ AC2C668120D32F2800D46CC9 /* InternalIterableAppIntegration.swift */, AC2B79F621E6A38900A59080 /* NotificationHelper.swift */, ACEDF41C2183C2EC000B9BFE /* Pending.swift */, + 379C34B42B3F0FB00077E631 /* AnonymousUserMergeProtocol.swift */, ); path = Internal; sourceTree = ""; @@ -1245,6 +1308,7 @@ AC7B142C20D02CE200877BFE /* unit-tests */ = { isa = PBXGroup; children = ( + 379C34A32B3F00570077E631 /* anonymous-user-tests */, AC87172421A4E3FF00FEA369 /* Helper Classes */, 00B6FACF210E8B10007535CF /* Test Files */, 552A0AAA280E24E400A80963 /* api-tests */, @@ -1555,6 +1619,24 @@ /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ + 373267FA2B4D51B200CC82C9 /* AnonymousUserMerge */ = { + isa = PBXNativeTarget; + buildConfigurationList = 373268042B4D51B200CC82C9 /* Build configuration list for PBXNativeTarget "AnonymousUserMerge" */; + buildPhases = ( + 373267F72B4D51B200CC82C9 /* Sources */, + 373267F82B4D51B200CC82C9 /* Frameworks */, + 373267F92B4D51B200CC82C9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 373268012B4D51B200CC82C9 /* PBXTargetDependency */, + ); + name = AnonymousUserMerge; + productName = AnonymousUserMerge; + productReference = 373267FB2B4D51B200CC82C9 /* AnonymousUserMerge.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; AC2263DE20CF49B8009800EB /* swift-sdk */ = { isa = PBXNativeTarget; buildConfigurationList = AC2263F320CF49B8009800EB /* Build configuration list for PBXNativeTarget "swift-sdk" */; @@ -1770,10 +1852,13 @@ AC2263D620CF49B8009800EB /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1150; + LastSwiftUpdateCheck = 1510; LastUpgradeCheck = 1200; ORGANIZATIONNAME = Iterable; TargetAttributes = { + 373267FA2B4D51B200CC82C9 = { + CreatedOnToolsVersion = 15.1; + }; AC2263DE20CF49B8009800EB = { CreatedOnToolsVersion = 9.4; }; @@ -1850,16 +1935,25 @@ ACDA976B23159C39004C412E /* inbox-ui-tests */, AC28480624AA44C600C1FC7F /* endpoint-tests */, ACFD5AB724C8200C008E497A /* offline-events-tests */, + 373267FA2B4D51B200CC82C9 /* AnonymousUserMerge */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 373267F92B4D51B200CC82C9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DD20CF49B8009800EB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( AC219C532260006600B98631 /* Assets.xcassets in Resources */, + 370198B72B427F07007DBFEA /* anoncriteria_response.json in Resources */, AC219C51225FEDBD00B98631 /* SampleInboxCell.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1954,6 +2048,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 373267F72B4D51B200CC82C9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 373267FE2B4D51B200CC82C9 /* AnonymousUserMerge.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; AC2263DA20CF49B8009800EB /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1966,6 +2068,7 @@ AC3DD9C82142F3650046F886 /* ClassExtensions.swift in Sources */, AC219C50225FEDBD00B98631 /* IterableInboxCell.swift in Sources */, AC84256226D6167E0066C627 /* AppExtensionHelper.swift in Sources */, + 379C34B22B3F05090077E631 /* AnonymousUserManager.swift in Sources */, ACE6888D2228B86C00A95E5E /* InAppInternal.swift in Sources */, AC9355D12589F9F90056C903 /* RequestHandlerProtocol.swift in Sources */, AC1AA1C924EBB3C300F29C6B /* IterableNotifications.swift in Sources */, @@ -1977,6 +2080,7 @@ ACF40621250781F1005FD775 /* NetworkConnectivityManager.swift in Sources */, AC942BC62539DEDA002988C9 /* ResourceHelper.swift in Sources */, AC4B039622A8743F0043185B /* InAppManager+Functions.swift in Sources */, + 379C34B02B3F05090077E631 /* AnonymousUserMerge.swift in Sources */, ACA95D2F2754AA6800AF4666 /* IterableInboxView.swift in Sources */, ACC51A6B22A879070095E81F /* EmptyInAppManager.swift in Sources */, AC52C5B62729CE44000DCDCF /* IterableUserDefaults.swift in Sources */, @@ -1990,6 +2094,7 @@ ACC362B624D16D91002C67BA /* IterableRequest.swift in Sources */, ACB1DFDB26369D2F00A31597 /* HealthMonitor.swift in Sources */, 5B88BC482805D09D004016E5 /* NetworkSession.swift in Sources */, + 379C34B52B3F0FB00077E631 /* AnonymousUserMergeProtocol.swift in Sources */, 55E9BE3429F9F5E6000C9FF2 /* DependencyContainerProtocol.swift in Sources */, AC06E4D327948C32007A6F20 /* InboxState.swift in Sources */, ACC362BD24D21172002C67BA /* IterableAPICallTaskProcessor.swift in Sources */, @@ -2014,6 +2119,7 @@ 55DD2015269E5A4200773CC7 /* IterableInboxViewControllerViewDelegate.swift in Sources */, AC2C668220D32F2800D46CC9 /* InternalIterableAppIntegration.swift in Sources */, ACD2B83D25B0A74A005D7A90 /* Models.swift in Sources */, + 379C34B12B3F05090077E631 /* AnonymousUserManager+Functions.swift in Sources */, 55DD2027269E5EA300773CC7 /* InboxViewControllerViewModelView.swift in Sources */, AC1BED9523F1D4C700FDD75F /* MiscInboxClasses.swift in Sources */, 556FB1EA244FAF6A00EDF6BD /* InAppPresenter.swift in Sources */, @@ -2042,6 +2148,7 @@ AC8874AA22178BD80075B54B /* InAppContentParser.swift in Sources */, AC50865624C603AC001DC132 /* IterablePersistence.swift in Sources */, ACC362BA24D20BBB002C67BA /* IterableAPICallRequest.swift in Sources */, + 379C34B32B3F05090077E631 /* AnonymousUserManagerProtocol.swift in Sources */, 55B3119B251015CF0056E4FC /* AuthManager.swift in Sources */, AC72A0CB20CF4CE2004D7997 /* InternalIterableAPI.swift in Sources */, AC72A0C720CF4CE2004D7997 /* CommerceItem.swift in Sources */, @@ -2108,6 +2215,7 @@ files = ( ACA8D1A62196309C001B1332 /* Common.swift in Sources */, 55AEA95925F05B7D00B38CED /* InAppMessageProcessorTests.swift in Sources */, + 379C34AB2B3F021B0077E631 /* AnonymousUserMergeTests.swift in Sources */, ACC362B824D17005002C67BA /* IterableRequestTests.swift in Sources */, AC2C668720D3435700D46CC9 /* ActionRunnerTests.swift in Sources */, 00CB31B621096129004ACDEC /* TestUtils.swift in Sources */, @@ -2124,7 +2232,9 @@ 5585DF8F22A73390000A32B9 /* IterableInboxViewControllerTests.swift in Sources */, 55B9F15124B3D33700E8198A /* AuthTests.swift in Sources */, 55B06F3829D5102800C3B1BC /* BlankApiClient.swift in Sources */, + 373268062B4D52DA00CC82C9 /* AnonymousUserMergeTests.swift in Sources */, 5588DFE928C046D7000697D7 /* MockInboxState.swift in Sources */, + 379C34AA2B3F021B0077E631 /* AnonymousUserCriteriaMatchTests.swift in Sources */, ACED4C01213F50B30055A497 /* LoggingTests.swift in Sources */, AC52C5B8272A8B32000DCDCF /* KeychainWrapperTests.swift in Sources */, ACC3FD9E2536D7A30004A2E0 /* InAppFilePersistenceTests.swift in Sources */, @@ -2381,6 +2491,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 373268012B4D51B200CC82C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = AC2263DE20CF49B8009800EB /* swift-sdk */; + targetProxy = 373268002B4D51B200CC82C9 /* PBXContainerItemProxy */; + }; 5B38881E27FAE6DB00482BE7 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = ACF560D220E443BF000AAC23 /* host-app */; @@ -2500,6 +2616,58 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 373268022B4D51B200CC82C9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = AnonymousUserMergeTests.AnonymousUserMerge; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 373268032B4D51B200CC82C9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 14.2; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = AnonymousUserMergeTests.AnonymousUserMerge; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; AC2263F120CF49B8009800EB /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3147,6 +3315,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 373268042B4D51B200CC82C9 /* Build configuration list for PBXNativeTarget "AnonymousUserMerge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 373268022B4D51B200CC82C9 /* Debug */, + 373268032B4D51B200CC82C9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; AC2263D920CF49B8009800EB /* Build configuration list for PBXProject "swift-sdk" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/swift-sdk/Constants.swift b/swift-sdk/Constants.swift index f71ae948..ca868df4 100644 --- a/swift-sdk/Constants.swift +++ b/swift-sdk/Constants.swift @@ -10,6 +10,15 @@ enum Endpoint { static let api = Endpoint.apiHostName + Const.apiPath } +enum EventType { + static let customEvent = "customEvent" + static let purchase = "purchase" + static let updateUser = "updateUser" + static let cartUpdate = "cartUpdate" + static let anonSession = "anonSession" + static let tokenRegistration = "tokenRegistration" +} + enum Const { static let apiPath = "/api/" @@ -39,6 +48,9 @@ enum Const { static let updateEmail = "users/updateEmail" static let updateSubscriptions = "users/updateSubscriptions" static let getRemoteConfiguration = "mobile/getRemoteConfiguration" + static let userByUserId = "users/byUserId"; + static let userByEmail = "users/getByEmail"; + static let mergeUser = "users/merge"; } public enum UserDefault { @@ -50,7 +62,10 @@ enum Const { static let deviceId = "itbl_device_id" static let sdkVersion = "itbl_sdk_version" static let offlineMode = "itbl_offline_mode" - + static let anonymousUserEvents = "itbl_anonymous_user_events" + static let criteriaData = "itbl_criteria_data" + static let anonymousSessions = "itbl_anon_sessions" + static let attributionInfoExpiration = 24 } @@ -102,6 +117,11 @@ enum JsonKey { static let subscribedMessageTypeIds = "subscribedMessageTypeIds" static let preferUserId = "preferUserId" + static let sourceEmail = "sourceEmail" + static let sourceUserId = "sourceUserId" + static let destinationEmail = "destinationEmail" + static let destinationUserId = "destinationUserId" + static let mergeNestedObjects = "mergeNestedObjects" static let inboxMetadata = "inboxMetadata" @@ -167,6 +187,10 @@ enum JsonKey { static let contentType = "Content-Type" + static let createNewFields = "createNewFields" + static let eventType = "dataType" + static let eventTimeStamp = "eventTimeStamp" + enum ActionButton { static let identifier = "identifier" static let action = "action" diff --git a/swift-sdk/Internal/AnonymousUserManager+Functions.swift b/swift-sdk/Internal/AnonymousUserManager+Functions.swift new file mode 100644 index 00000000..7fdf4d4c --- /dev/null +++ b/swift-sdk/Internal/AnonymousUserManager+Functions.swift @@ -0,0 +1,327 @@ +// +// File.swift +// +// +// Created by HARDIK MASHRU on 13/11/23. +// + +import Foundation + +// Convert commerce items to dictionaries +func convertCommerceItemsToDictionary(_ items: [CommerceItem]) -> [[AnyHashable:Any]] { + let dictionaries = items.map { item in + return item.toDictionary() + } + return dictionaries +} + +// Convert to commerce items from dictionaries +func convertCommerceItems(from dictionaries: [[AnyHashable: Any]]) -> [CommerceItem] { + return dictionaries.compactMap { dictionary in + let item = CommerceItem(id: dictionary[JsonKey.CommerceItem.id] as? String ?? "", name: dictionary[JsonKey.CommerceItem.name] as? String ?? "", price: dictionary[JsonKey.CommerceItem.price] as? NSNumber ?? 0, quantity: dictionary[JsonKey.CommerceItem.quantity] as? UInt ?? 0) + item.sku = dictionary[JsonKey.CommerceItem.sku] as? String + item.itemDescription = dictionary[JsonKey.CommerceItem.description] as? String + item.url = dictionary[JsonKey.CommerceItem.url] as? String + item.imageUrl = dictionary[JsonKey.CommerceItem.imageUrl] as? String + item.categories = dictionary[JsonKey.CommerceItem.categories] as? [String] + item.dataFields = dictionary[JsonKey.CommerceItem.dataFields] as? [AnyHashable: Any] + + return item + } +} + +func convertToDictionary(data: Codable) -> [AnyHashable: Any] { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(data) + if let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [AnyHashable: Any] { + return dictionary + } + } catch { + print("Error converting to dictionary: \(error)") + } + return [:] +} + +// Converts UTC Datetime from current time +func getUTCDateTime() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + dateFormatter.timeZone = TimeZone(identifier: "UTC") + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + let utcDate = Date() + return dateFormatter.string(from: utcDate) +} + +struct CriteriaCompletionChecker { + init(anonymousCriteria: Data, anonymousEvents: [[AnyHashable: Any]]) { + self.anonymousEvents = anonymousEvents + self.anonymousCriteria = anonymousCriteria + } + + func getMatchedCriteria() -> Int? { + var criteriaId: Int? = nil + if let json = try? JSONSerialization.jsonObject(with: anonymousCriteria, options: []) as? [String: Any] { + // Access the criteriaList + if let criteriaList = json["criteriaList"] as? [[String: Any]] { + // Iterate over the criteria + for criteria in criteriaList { + // Perform operations on each criteria + if let searchQuery = criteria["searchQuery"] as? [String: Any], let currentCriteriaId = criteria["criteriaId"] as? Int { + // we will split purhase/updatecart event items as seperate events because we need to compare it against the single item in criteria json + var eventsToProcess = getEventsWithCartItems() + eventsToProcess.append(contentsOf: getNonCartEvents()) + let result = evaluateTree(node: searchQuery, localEventData: eventsToProcess) + if (result) { + criteriaId = currentCriteriaId + break + } + } + } + } + } + return criteriaId + } + + func getMappedKeys(event: [AnyHashable: Any]) -> [String] { + var itemKeys: [String] = [] + for (_ , value) in event { + if let arrayValue = value as? [[AnyHashable: Any]], arrayValue.count > 0 { // this is a special case of items array in purchase event + // If the value is an array, handle it + itemKeys.append(contentsOf: extractKeys(dict: arrayValue[0])) + } else { + itemKeys.append(contentsOf: extractKeys(dict: event)) + } + } + return itemKeys + } + + func getNonCartEvents() -> [[AnyHashable: Any]] { + let nonPurchaseEvents = anonymousEvents.filter { dictionary in + if let dataType = dictionary[JsonKey.eventType] as? String { + return dataType != EventType.purchase && dataType != EventType.cartUpdate + } + return false + } + var processedEvents: [[AnyHashable: Any]] = [[:]] + for eventItem in nonPurchaseEvents { + var updatedItem = eventItem + // handle dataFields if any + if let dataFields = eventItem["dataFields"] as? [AnyHashable: Any] { + for (key, value) in dataFields { + if key is String { + updatedItem[key] = value + } + } + updatedItem.removeValue(forKey: "dataFields") + } + processedEvents.append(updatedItem) + } + return processedEvents + } + + func getEventsWithCartItems() -> [[AnyHashable: Any]] { + let purchaseEvents = anonymousEvents.filter { dictionary in + if let dataType = dictionary[JsonKey.eventType] as? String { + return dataType == EventType.purchase || dataType == EventType.cartUpdate + } + return false + } + + var processedEvents: [[AnyHashable: Any]] = [[:]] + for eventItem in purchaseEvents { + if let items = eventItem["items"] as? [[AnyHashable: Any]] { + let itemsWithOtherProps = items.map { item -> [AnyHashable: Any] in + var updatedItem = [AnyHashable: Any]() + + for (key, value) in item { + if let stringKey = key as? String { + updatedItem["shoppingCartItems." + stringKey] = value + } + } + + // handle dataFields if any + if let dataFields = eventItem["dataFields"] as? [AnyHashable: Any] { + for (key, value) in dataFields { + if key is String { + updatedItem[key] = value + } + } + } + + for (key, value) in eventItem { + if (key as! String != "items" && key as! String != "dataFields") { + updatedItem[key] = value + } + } + return updatedItem + } + processedEvents.append(contentsOf: itemsWithOtherProps) + } + } + return processedEvents + } + + func extractKeys(jsonObject: [String: Any]) -> [String] { + return Array(jsonObject.keys) + } + + func extractKeys(dict: [AnyHashable: Any]) -> [String] { + var keys: [String] = [] + for key in dict.keys { + if let stringKey = key as? String { + // If needed, use stringKey which is now guaranteed to be a String + keys.append(stringKey) + } + } + return keys + } + + func evaluateTree(node: [String: Any], localEventData: [[AnyHashable: Any]]) -> Bool { + if let searchQueries = node["searchQueries"] as? [[String: Any]], let combinator = node["combinator"] as? String { + if combinator == "And" { + for query in searchQueries { + if !evaluateTree(node: query, localEventData: localEventData) { + return false // If any subquery fails, return false + } + } + return true // If all subqueries pass, return true + } else if combinator == "Or" { + for query in searchQueries { + if evaluateTree(node: query, localEventData: localEventData) { + return true // If any subquery passes, return true + } + } + return false // If all subqueries fail, return false + } + } else if let searchCombo = node["searchCombo"] as? [String: Any] { + return evaluateTree(node: searchCombo, localEventData: localEventData) + } else if node["field"] != nil { + return evaluateField(node: node, localEventData: localEventData) + } + + return false + } + + func evaluateField(node: [String: Any], localEventData: [[AnyHashable: Any]]) -> Bool { + do { + return try evaluateFieldLogic(node: node, localEventData: localEventData) + } catch { + print("evaluateField JSON ERROR: \(error)") + } + return false + } + + func evaluateFieldLogic(node: [String: Any], localEventData: [[AnyHashable: Any]]) throws -> Bool { + var isEvaluateSuccess = false + for eventData in localEventData { + let localDataKeys = eventData.keys + if node["dataType"] as? String == eventData["dataType"] as? String { + if let field = node["field"] as? String, + let comparatorType = node["comparatorType"] as? String, + let fieldType = node["fieldType"] as? String { + for key in localDataKeys { + if field == key as! String, let matchObj = eventData[key] { + if evaluateComparison(comparatorType: comparatorType, fieldType: fieldType, matchObj: matchObj, valueToCompare: node["value"] as? String) { + isEvaluateSuccess = true + break + } + } + } + } + } + } + return isEvaluateSuccess + } + + func evaluateComparison(comparatorType: String, fieldType: String, matchObj: Any, valueToCompare: String?) -> Bool { + guard let stringValue = valueToCompare else { + return false + } + + switch comparatorType { + case "Equals": + return compareValueEquality(matchObj, stringValue) + case "DoesNotEquals": + return !compareValueEquality(matchObj, stringValue) + case "GreaterThan": + print("GreatherThan:: \(compareNumericValues(matchObj, stringValue, compareOperator: >))") + return compareNumericValues(matchObj, stringValue, compareOperator: >) + case "LessThan": + return compareNumericValues(matchObj, stringValue, compareOperator: <) + case "GreaterThanOrEqualTo": + print("GreaterThanOrEqualTo:: \(compareNumericValues(matchObj, stringValue, compareOperator: >=))") + return compareNumericValues(matchObj, stringValue, compareOperator: >=) + case "LessThanOrEqualTo": + return compareNumericValues(matchObj, stringValue, compareOperator: <=) + case "Contains": + return compareStringContains(matchObj, stringValue) + case "StartsWith": + return compareStringStartsWith(matchObj, stringValue) + case "MatchesRegex": + return compareWithRegex(matchObj as? String ?? "", pattern: stringValue) + default: + return false + } + } + + func compareValueEquality(_ sourceTo: Any, _ stringValue: String) -> Bool { + switch (sourceTo, stringValue) { + case (let doubleNumber as Double, let value): return doubleNumber == Double(value) + case (let intNumber as Int, let value): return intNumber == Int(value) + case (let longNumber as Int64, let value): return longNumber == Int64(value) + case (let booleanValue as Bool, let value): return booleanValue == Bool(value) + case (let stringTypeValue as String, let value): return stringTypeValue == value + default: return false + } + } + + func compareNumericValues(_ sourceTo: Any, _ stringValue: String, compareOperator: (Double, Double) -> Bool) -> Bool { + if let sourceNumber = Double(stringValue) { + switch sourceTo { + case let doubleNumber as Double: + return compareOperator(doubleNumber, sourceNumber) + case let intNumber as Int: + return compareOperator(Double(intNumber), sourceNumber) + case let longNumber as Int64: + return compareOperator(Double(longNumber), sourceNumber) + case let stringNumber as String: + if let doubleFromString = Double(stringNumber) { + return compareOperator(doubleFromString, sourceNumber) + } else { + return false // Handle the case where string cannot be converted to a Double + } + default: + return false + } + } else { + return false // Handle the case where stringValue cannot be converted to a Double + } + } + + + func compareStringContains(_ sourceTo: Any, _ stringValue: String) -> Bool { + guard let stringTypeValue = sourceTo as? String else { return false } + return stringTypeValue.contains(stringValue) + } + + func compareStringStartsWith(_ sourceTo: Any, _ stringValue: String) -> Bool { + guard let stringTypeValue = sourceTo as? String else { return false } + return stringTypeValue.hasPrefix(stringValue) + } + + func compareWithRegex(_ sourceTo: String, pattern: String) -> Bool { + do { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(sourceTo.startIndex.. Int? { + guard let events = localStorage.anonymousUserEvents, let criteriaData = localStorage.criteriaData else { + return nil + } + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: criteriaData, anonymousEvents: events).getMatchedCriteria() + return matchedCriteriaId + } + // Gets the anonymous criteria + public func getAnonCriteria() { + // call API when it is available and save data in userdefaults, until then just save the data in userdefaults using static data from anoncriteria_response.json + if let path = Bundle.module.path(forResource: "anoncriteria_response", ofType: "json") { + let fileURL = URL(fileURLWithPath: path) + do { + let data = try Data(contentsOf: fileURL) + // Process your data here + localStorage.criteriaData = data + } catch { + print("Error reading file: \(error)") + } + } else { + print("File not found in the package") + } + } + + // Stores event data locally + private func storeEventData(type: String, data: [AnyHashable: Any], shouldOverWrite: Bool? = false) { + let storedData = localStorage.anonymousUserEvents + var eventsDataObjects: [[AnyHashable: Any]] = [[:]] + + if let _storedData = storedData { + eventsDataObjects = _storedData + } + var appendData = data + appendData.setValue(for: JsonKey.eventType, value: type) + appendData.setValue(for: JsonKey.eventTimeStamp, value: Int(dateProvider.currentDate.timeIntervalSince1970)) // this we use as unique idenfier too + + if shouldOverWrite == true { + eventsDataObjects = eventsDataObjects.map { var subDictionary = $0; subDictionary[type] = data; return subDictionary } + } else { + eventsDataObjects.append(appendData) + } + localStorage.anonymousUserEvents = eventsDataObjects + createKnownUserIfCriteriaMatched(criteriaId: evaluateCriteriaAndReturnID()) + } +} diff --git a/swift-sdk/Internal/AnonymousUserManagerProtocol.swift b/swift-sdk/Internal/AnonymousUserManagerProtocol.swift new file mode 100644 index 00000000..d03f3370 --- /dev/null +++ b/swift-sdk/Internal/AnonymousUserManagerProtocol.swift @@ -0,0 +1,18 @@ +// +// AnonymousUserManagerProtocol.swift +// +// +// Created by HARDIK MASHRU on 09/11/23. +// +import Foundation +@objc public protocol AnonymousUserManagerProtocol { + func trackAnonEvent(name: String, dataFields: [AnyHashable: Any]?) + func trackAnonPurchaseEvent(total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?) + func trackAnonUpdateCart(items: [CommerceItem]) + func trackAnonTokenRegistration(token: String) + func trackAnonUpdateUser(_ dataFields: [AnyHashable: Any]) + func updateAnonSession() + func getAnonCriteria() + func syncNonSyncedEvents() + func logout() +} diff --git a/swift-sdk/Internal/AnonymousUserMerge.swift b/swift-sdk/Internal/AnonymousUserMerge.swift new file mode 100644 index 00000000..075ea4b2 --- /dev/null +++ b/swift-sdk/Internal/AnonymousUserMerge.swift @@ -0,0 +1,49 @@ +// +// AnonymousUserMerge.swift +// Iterable-iOS-SDK +// +// Created by Hani Vora on 19/12/23. +// + +import Foundation + +class AnonymousUserMerge: AnonymousUserMergeProtocol { + + var anonymousUserManager: AnonymousUserManagerProtocol + var apiClient: ApiClient + + init(apiClient: ApiClient, anonymousUserManager: AnonymousUserManagerProtocol) { + self.apiClient = apiClient + self.anonymousUserManager = anonymousUserManager + } + + public func mergeUserUsingUserId(destinationUserId: String, sourceUserId: String, destinationEmail: String) { + + if IterableUtil.isNullOrEmpty(string: sourceUserId) || sourceUserId == destinationUserId { + return + } + apiClient.getUserByUserID(userId: sourceUserId).onSuccess { data in + if data["user"] is [String: Any] { + self.callMergeApi(sourceEmail: "", sourceUserId: sourceUserId, destinationEmail: destinationEmail, destinationUserId: destinationUserId) + } + } + } + + public func mergeUserUsingEmail(destinationUserId: String, destinationEmail: String, sourceEmail: String) { + + if IterableUtil.isNullOrEmpty(string: sourceEmail) || sourceEmail == destinationEmail { + return + } + apiClient.getUserByEmail(email: sourceEmail).onSuccess { data in + if data["user"] is [String: Any] { + self.callMergeApi(sourceEmail: sourceEmail, sourceUserId: "", destinationEmail: destinationEmail, destinationUserId: destinationUserId) + } + } + } + + private func callMergeApi(sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String) { + apiClient.mergeUser(sourceEmail: sourceEmail, sourceUserId: sourceUserId, destinationEmail: destinationEmail, destinationUserId: destinationUserId).onSuccess {_ in + self.anonymousUserManager.syncNonSyncedEvents() + } + } +} diff --git a/swift-sdk/Internal/AnonymousUserMergeProtocol.swift b/swift-sdk/Internal/AnonymousUserMergeProtocol.swift new file mode 100644 index 00000000..46d3def5 --- /dev/null +++ b/swift-sdk/Internal/AnonymousUserMergeProtocol.swift @@ -0,0 +1,13 @@ +// +// AnonymousUserMergeProtocol.swift +// swift-sdk +// +// Created by Hani Vora on 29/12/23. +// Copyright © 2023 Iterable. All rights reserved. +// + +import Foundation +@objc public protocol AnonymousUserMergeProtocol { + func mergeUserUsingUserId(destinationUserId: String, sourceUserId: String, destinationEmail: String) + func mergeUserUsingEmail(destinationUserId: String, destinationEmail: String, sourceEmail: String) +} diff --git a/swift-sdk/Internal/ApiClient.swift b/swift-sdk/Internal/ApiClient.swift index 104aac9b..093be979 100644 --- a/swift-sdk/Internal/ApiClient.swift +++ b/swift-sdk/Internal/ApiClient.swift @@ -41,6 +41,20 @@ class ApiClient { return apiCallRequest.convertToURLRequest(sentAt: currentDate) } + func convertToURLRequestWithoutCreatedAt(iterableRequest: IterableRequest) -> URLRequest? { + guard let authProvider = authProvider else { + return nil + } + + let currentDate = dateProvider.currentDate + let apiCallRequest = IterableAPICallRequest(apiKey: apiKey, + endpoint: endpoint, + authToken: authProvider.auth.authToken, + deviceMetadata: deviceMetadata, + iterableRequest: iterableRequest) + return apiCallRequest.convertToURLRequest(sentAt: currentDate) + } + func send(iterableRequestResult result: Result) -> Pending { switch result { case let .success(iterableRequest): @@ -50,6 +64,15 @@ class ApiClient { } } + func sendWithoutCreatedAt(iterableRequestResult result: Result) -> Pending { + switch result { + case let .success(iterableRequest): + return sendWithoutCreatedAt(iterableRequest: iterableRequest) + case let .failure(iterableError): + return SendRequestError.createErroredFuture(reason: iterableError.localizedDescription) + } + } + func send(iterableRequestResult result: Result) -> Pending where T: Decodable { switch result { case let .success(iterableRequest): @@ -67,6 +90,14 @@ class ApiClient { return RequestSender.sendRequest(urlRequest, usingSession: networkSession) } + func sendWithoutCreatedAt(iterableRequest: IterableRequest) -> Pending { + guard let urlRequest = convertToURLRequestWithoutCreatedAt(iterableRequest: iterableRequest) else { + return SendRequestError.createErroredFuture() + } + + return RequestSender.sendRequest(urlRequest, usingSession: networkSession) + } + func send(iterableRequest: IterableRequest) -> Pending where T: Decodable { guard let urlRequest = convertToURLRequest(iterableRequest: iterableRequest) else { return SendRequestError.createErroredFuture() @@ -130,6 +161,12 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func updateCart(items: [CommerceItem], withUser user: [AnyHashable:Any], createdAt: Int) -> Pending { + let result = createRequestCreator().flatMap { $0.createUpdateCartRequest(items: items, withUser: user, createdAt: createdAt) } + + return sendWithoutCreatedAt(iterableRequestResult: result) + } + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, @@ -143,6 +180,19 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func track(purchase total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + withUser user: [AnyHashable: Any], + createdAt: Int) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackPurchaseRequest(total, + items: items, + dataFields: dataFields, + withUser: user, + createdAt: createdAt) } + return send(iterableRequestResult: result) + } + func track(pushOpen campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Pending { let result = createRequestCreator().flatMap { $0.createTrackPushOpenRequest(campaignId, templateId: templateId, @@ -158,6 +208,18 @@ extension ApiClient: ApiClientProtocol { return send(iterableRequestResult: result) } + func track(event eventName: String, withBody body: [AnyHashable: Any]?) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackEventRequest(eventName, + withBody: body) } + return sendWithoutCreatedAt(iterableRequestResult: result) + } + + func track(event eventName: String, body: [AnyHashable: Any]?, dataFields: [AnyHashable: Any]?) -> Pending { + let result = createRequestCreator().flatMap { $0.createTrackEventRequest(eventName, + dataFields: dataFields) } + return send(iterableRequestResult: result) + } + func updateSubscriptions(_ emailListIds: [NSNumber]? = nil, unsubscribedChannelIds: [NSNumber]? = nil, unsubscribedMessageTypeIds: [NSNumber]? = nil, @@ -216,4 +278,19 @@ extension ApiClient: ApiClientProtocol { let result = createRequestCreator().flatMap { $0.createGetRemoteConfigurationRequest() } return send(iterableRequestResult: result) } + + func getUserByUserID(userId: String) -> Pending { + let result = createRequestCreator().flatMap { $0.createGetUserByUserIdRequest(userId) } + return send(iterableRequestResult: result) + } + + func getUserByEmail(email: String) -> Pending { + let result = createRequestCreator().flatMap { $0.createGetUserByEmailRequest(email) } + return send(iterableRequestResult: result) + } + + func mergeUser(sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String) -> Pending { + let result = createRequestCreator().flatMap { $0.createMergeUserRequest(sourceEmail, sourceUserId, destinationEmail, destinationUserId: destinationUserId) } + return send(iterableRequestResult: result) + } } diff --git a/swift-sdk/Internal/ApiClientProtocol.swift b/swift-sdk/Internal/ApiClientProtocol.swift index 86a57812..6a9e5475 100644 --- a/swift-sdk/Internal/ApiClientProtocol.swift +++ b/swift-sdk/Internal/ApiClientProtocol.swift @@ -13,12 +13,18 @@ protocol ApiClientProtocol: AnyObject { func updateCart(items: [CommerceItem]) -> Pending + func updateCart(items: [CommerceItem], withUser user:[AnyHashable:Any], createdAt: Int) -> Pending + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, campaignId: NSNumber?, templateId: NSNumber?) -> Pending + func track(purchase total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, withUser user: [AnyHashable: Any], createdAt: Int) -> Pending + func track(pushOpen campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Pending func track(event eventName: String, dataFields: [AnyHashable: Any]?) -> Pending + func track(event eventName: String, withBody body: [AnyHashable: Any]?) -> Pending + func updateSubscriptions(_ emailListIds: [NSNumber]?, unsubscribedChannelIds: [NSNumber]?, unsubscribedMessageTypeIds: [NSNumber]?, @@ -45,4 +51,10 @@ protocol ApiClientProtocol: AnyObject { func disableDevice(forAllUsers allUsers: Bool, hexToken: String) -> Pending func getRemoteConfiguration() -> Pending + + func getUserByUserID(userId: String) -> Pending + + func getUserByEmail(email: String) -> Pending + + func mergeUser(sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String) -> Pending } diff --git a/swift-sdk/Internal/DependencyContainerProtocol.swift b/swift-sdk/Internal/DependencyContainerProtocol.swift index ceba7198..4dc7e92d 100644 --- a/swift-sdk/Internal/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/DependencyContainerProtocol.swift @@ -121,6 +121,12 @@ extension DependencyContainerProtocol { RedirectNetworkSession(delegate: delegate) } + func createAnonymousUserManager() -> AnonymousUserManagerProtocol { + AnonymousUserManager(localStorage: localStorage, + dateProvider: dateProvider, + notificationStateProvider: notificationStateProvider) + } + private func createTaskScheduler(persistenceContextProvider: IterablePersistenceContextProvider, healthMonitor: HealthMonitor) -> IterableTaskScheduler { IterableTaskScheduler(persistenceContextProvider: persistenceContextProvider, @@ -137,4 +143,8 @@ extension DependencyContainerProtocol { notificationCenter: notificationCenter, connectivityManager: NetworkConnectivityManager()) } + + func createAnonymousUserMerge(apiClient: ApiClient, anonymousUserManager: AnonymousUserManagerProtocol) -> AnonymousUserMergeProtocol { + AnonymousUserMerge(apiClient: apiClient, anonymousUserManager: anonymousUserManager) + } } diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index 181db805..8e7d0c63 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -82,6 +82,14 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { self.dependencyContainer.createAuthManager(config: self.config) }() + lazy var anonymousUserManager: AnonymousUserManagerProtocol = { + self.dependencyContainer.createAnonymousUserManager() + }() + + lazy var anonymousUserMerge: AnonymousUserMergeProtocol = { + self.dependencyContainer.createAnonymousUserMerge(apiClient: apiClient as! ApiClient, anonymousUserManager: anonymousUserManager) + }() + var apiEndPointForTest: String { get { apiEndPoint @@ -118,8 +126,16 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setEmail(_ email: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { + + if config.enableAnonTracking { + anonymousUserMerge.mergeUserUsingEmail(destinationUserId: _userId ?? "", destinationEmail: email ?? "", sourceEmail: _email ?? "") + } ITBInfo() + if email == nil && config.enableAnonTracking { + anonymousUserManager.logout() + } + if _email == email && email != nil && authToken != nil { checkAndUpdateAuthToken(authToken) return @@ -142,8 +158,16 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } func setUserId(_ userId: String?, authToken: String? = nil, successHandler: OnSuccessHandler? = nil, failureHandler: OnFailureHandler? = nil) { + + if config.enableAnonTracking { + anonymousUserMerge.mergeUserUsingUserId(destinationUserId: userId ?? "", sourceUserId: _userId ?? "", destinationEmail: _email ?? "") + } ITBInfo() + if userId == nil && config.enableAnonTracking { + anonymousUserManager.logout() + } + if _userId == userId && userId != nil && authToken != nil { checkAndUpdateAuthToken(authToken) return @@ -182,6 +206,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { return } + if !isEitherUserIdOrEmailSet() && config.enableAnonTracking { + anonymousUserManager.trackAnonTokenRegistration(token: token.hexString()) + } + hexToken = token.hexString() let registerTokenInfo = RegisterTokenInfo(hexToken: token.hexString(), appName: appName, @@ -236,7 +264,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { mergeNestedObjects: Bool, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.updateUser(dataFields, mergeNestedObjects: mergeNestedObjects, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && config.enableAnonTracking { + anonymousUserManager.trackAnonUpdateUser(dataFields) + } + return requestHandler.updateUser(dataFields, mergeNestedObjects: mergeNestedObjects, onSuccess: onSuccess, onFailure: onFailure) } @discardableResult @@ -261,7 +292,19 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { func updateCart(items: [CommerceItem], onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && config.enableAnonTracking { + anonymousUserManager.trackAnonUpdateCart(items: items) + } + return requestHandler.updateCart(items: items, onSuccess: onSuccess, onFailure: onFailure) + } + + @discardableResult + func updateCart(items: [CommerceItem], + withUser user: [AnyHashable:Any], + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + return requestHandler.updateCart(items: items, withUser: user, createdAt: createdAt, onSuccess: onSuccess, onFailure: onFailure) } @discardableResult @@ -272,7 +315,10 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { templateId: NSNumber? = nil, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.trackPurchase(total, + if !isEitherUserIdOrEmailSet() && config.enableAnonTracking { + anonymousUserManager.trackAnonPurchaseEvent(total: total, items: items, dataFields: dataFields) + } + return requestHandler.trackPurchase(total, items: items, dataFields: dataFields, campaignId: campaignId, @@ -280,6 +326,23 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onSuccess: onSuccess, onFailure: onFailure) } + + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]? = nil, + withUser user: [AnyHashable: Any], + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + return requestHandler.trackPurchase(total, + items: items, + dataFields: dataFields, + withUser: user, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } @discardableResult @@ -324,7 +387,18 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { dataFields: [AnyHashable: Any]? = nil, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { - requestHandler.track(event: eventName, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) + if !isEitherUserIdOrEmailSet() && config.enableAnonTracking { + anonymousUserManager.trackAnonEvent(name: eventName, dataFields: dataFields) + } + return requestHandler.track(event: eventName, dataFields: dataFields, onSuccess: onSuccess, onFailure: onFailure) + } + + @discardableResult + func track(_ eventName: String, + withBody body: [AnyHashable: Any], + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + requestHandler.track(event: eventName, withBody: body, onSuccess: onSuccess, onFailure: onFailure) } @discardableResult @@ -494,7 +568,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { } } - private func isEitherUserIdOrEmailSet() -> Bool { + public func isEitherUserIdOrEmailSet() -> Bool { IterableUtil.isNotNullOrEmpty(string: _email) || IterableUtil.isNotNullOrEmpty(string: _userId) } diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index 5b5fdaad..bd3b8a89 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -70,6 +70,62 @@ class IterableUserDefaults { } } + var anonymousUserEvents: [[AnyHashable: Any]]? { + get { + return eventData(withKey: .anonymousUserEvents) + } set { + saveEventData(anonymousUserEvents: newValue, withKey: .anonymousUserEvents) + } + } + + var criteriaData: Data? { + get { + return getCriteriaData(withKey: .criteriaData) + } set { + saveCriteriaData(data: newValue, withKey: .criteriaData) + } + } + + var anonymousSessions: IterableAnonSessionsWrapper? { + get { + return anonSessionsData(withKey: .anonymousSessions) + } set { + saveAnonSessionsData(data: newValue, withKey: .anonymousSessions) + } + } + + var body = [AnyHashable: Any]() + + private func anonSessionsData(withKey key: UserDefaultsKey) -> IterableAnonSessionsWrapper? { + if let savedData = UserDefaults.standard.data(forKey: key.value) { + let decodedData = try? JSONDecoder().decode(IterableAnonSessionsWrapper.self, from: savedData) + return decodedData + } + return nil + } + + private func saveAnonSessionsData(data: IterableAnonSessionsWrapper?, withKey key: UserDefaultsKey) { + if let encodedData = try? JSONEncoder().encode(data) { + userDefaults.set(encodedData, forKey: key.value) + } + } + + private func criteriaData(withKey key: UserDefaultsKey) -> [Criteria]? { + if let savedData = UserDefaults.standard.data(forKey: key.value) { + let decodedData = try? JSONDecoder().decode([Criteria].self, from: savedData) + return decodedData + } + return nil + } + + private func saveCriteriaData(data: Data?, withKey key: UserDefaultsKey) { + userDefaults.set(data, forKey: key.value) + } + + private func saveEventData(anonymousUserEvents: [[AnyHashable: Any]]?, withKey key: UserDefaultsKey) { + userDefaults.set(anonymousUserEvents, forKey: key.value) + } + func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { (try? codable(withKey: .attributionInfo, currentDate: currentDate)) ?? nil } @@ -104,6 +160,16 @@ class IterableUserDefaults { } } + private func dict(withKey key: UserDefaultsKey) throws -> [AnyHashable: Any]? { + guard let encodedEnvelope = userDefaults.value(forKey: key.value) as? Data else { + return nil + } + + let envelope = try JSONDecoder().decode(EnvelopeNoExpiration.self, from: encodedEnvelope) + let decoded = try JSONSerialization.jsonObject(with: envelope.payload, options: []) as? [AnyHashable: Any] + return decoded + } + private func codable(withKey key: UserDefaultsKey, currentDate: Date) throws -> T? { guard let encodedEnvelope = userDefaults.value(forKey: key.value) as? Data else { return nil @@ -128,6 +194,14 @@ class IterableUserDefaults { userDefaults.bool(forKey: key.value) } + private func eventData(withKey key: UserDefaultsKey) -> [[AnyHashable: Any]]? { + userDefaults.array(forKey: key.value) as? [[AnyHashable: Any]] + } + + private func getCriteriaData(withKey key: UserDefaultsKey) -> Data? { + userDefaults.object(forKey: key.value) as? Data + } + private static func isExpired(expiration: Date?, currentDate: Date) -> Bool { if let expiration = expiration { if expiration.timeIntervalSinceReferenceDate > currentDate.timeIntervalSinceReferenceDate { @@ -182,6 +256,17 @@ class IterableUserDefaults { userDefaults.set(encodedEnvelope, forKey: key.value) } + private func save(data: Data?, withKey key: UserDefaultsKey) throws { + guard let data = data else { + userDefaults.removeObject(forKey: key.value) + return + } + + let envelope = EnvelopeNoExpiration(payload: data) + let encodedEnvelope = try JSONEncoder().encode(envelope) + userDefaults.set(encodedEnvelope, forKey: key.value) + } + private struct UserDefaultsKey { let value: String @@ -196,10 +281,16 @@ class IterableUserDefaults { static let deviceId = UserDefaultsKey(value: Const.UserDefault.deviceId) static let sdkVersion = UserDefaultsKey(value: Const.UserDefault.sdkVersion) static let offlineMode = UserDefaultsKey(value: Const.UserDefault.offlineMode) + static let anonymousUserEvents = UserDefaultsKey(value: Const.UserDefault.offlineMode) + static let criteriaData = UserDefaultsKey(value: Const.UserDefault.criteriaData) + static let anonymousSessions = UserDefaultsKey(value: Const.UserDefault.anonymousSessions) } - private struct Envelope: Codable { let payload: Data let expiration: Date? } + + private struct EnvelopeNoExpiration: Codable { + let payload: Data + } } diff --git a/swift-sdk/Internal/LocalStorage.swift b/swift-sdk/Internal/LocalStorage.swift index 9e4a6fcc..b94be977 100644 --- a/swift-sdk/Internal/LocalStorage.swift +++ b/swift-sdk/Internal/LocalStorage.swift @@ -67,6 +67,30 @@ struct LocalStorage: LocalStorageProtocol { } } + var anonymousUserEvents: [[AnyHashable: Any]]? { + get { + iterableUserDefaults.anonymousUserEvents + } set { + iterableUserDefaults.anonymousUserEvents = newValue + } + } + + var anonymousSessions: IterableAnonSessionsWrapper? { + get { + iterableUserDefaults.anonymousSessions + } set { + iterableUserDefaults.anonymousSessions = newValue + } + } + + var criteriaData: Data? { + get { + iterableUserDefaults.criteriaData + } set { + iterableUserDefaults.criteriaData = newValue + } + } + func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { iterableUserDefaults.getAttributionInfo(currentDate: currentDate) } diff --git a/swift-sdk/Internal/LocalStorageProtocol.swift b/swift-sdk/Internal/LocalStorageProtocol.swift index 254ce329..01a62f9c 100644 --- a/swift-sdk/Internal/LocalStorageProtocol.swift +++ b/swift-sdk/Internal/LocalStorageProtocol.swift @@ -19,6 +19,12 @@ protocol LocalStorageProtocol { var offlineMode: Bool { get set } + var anonymousUserEvents: [[AnyHashable: Any]]? { get set } + + var criteriaData: Data? { get set } + + var anonymousSessions: IterableAnonSessionsWrapper? { get set } + func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? func save(attributionInfo: IterableAttributionInfo?, withExpiration expiration: Date?) diff --git a/swift-sdk/Internal/Models.swift b/swift-sdk/Internal/Models.swift index 9e36ea3f..36db621a 100644 --- a/swift-sdk/Internal/Models.swift +++ b/swift-sdk/Internal/Models.swift @@ -8,3 +8,26 @@ import Foundation struct RemoteConfiguration: Codable, Equatable { let offlineMode: Bool } + +struct Criteria: Codable { + let criteriaId: String + let criteriaList: [CriteriaItem] +} + +struct CriteriaItem: Codable { + let criteriaType: String + let comparator: String? + let name: String? + let aggregateCount: Int? + let total: Int? +} + +struct IterableAnonSessions: Codable { + var number_of_sessions: Int + var last_session: String + var first_session: String +} + +struct IterableAnonSessionsWrapper: Codable { + var itbl_anon_sessions: IterableAnonSessions +} diff --git a/swift-sdk/Internal/OfflineRequestProcessor.swift b/swift-sdk/Internal/OfflineRequestProcessor.swift index 9fd94917..a9a4ceda 100644 --- a/swift-sdk/Internal/OfflineRequestProcessor.swift +++ b/swift-sdk/Internal/OfflineRequestProcessor.swift @@ -49,6 +49,22 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func updateCart(items: [CommerceItem], + withUser user: [AnyHashable:Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createUpdateCartRequest(items: items, withUser: user, createdAt: createdAt) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -71,6 +87,28 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + withUser user: [AnyHashable: Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createTrackPurchaseRequest(total, + items: items, + dataFields: dataFields, + withUser: user, + createdAt: createdAt) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -110,6 +148,23 @@ struct OfflineRequestProcessor: RequestProcessorProtocol { identifier: #function) } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + ITBInfo() + let requestGenerator = { (requestCreator: RequestCreator) in + requestCreator.createTrackEventRequest(event, + withBody: body) + } + + return sendIterableRequest(requestGenerator: requestGenerator, + successHandler: onSuccess, + failureHandler: onFailure, + identifier: #function) + } + @discardableResult func trackInAppOpen(_ message: IterableInAppMessage, location: InAppLocation, diff --git a/swift-sdk/Internal/OnlineRequestProcessor.swift b/swift-sdk/Internal/OnlineRequestProcessor.swift index 0a51524d..4c1b8119 100644 --- a/swift-sdk/Internal/OnlineRequestProcessor.swift +++ b/swift-sdk/Internal/OnlineRequestProcessor.swift @@ -85,6 +85,18 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "updateCart") } + @discardableResult + func updateCart(items: [CommerceItem], + withUser user: [AnyHashable:Any], + createdAt: Int, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + sendRequest(requestProvider: { apiClient.updateCart(items: items, withUser: user, createdAt: createdAt) }, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "updateCart") + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -103,6 +115,17 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "trackPurchase") } + func trackPurchase(_ total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable : Any]?, withUser user: [AnyHashable : Any], createdAt: Int, onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending { + sendRequest(requestProvider: { apiClient.track(purchase: total, + items: items, + dataFields: dataFields, + withUser: user, + createdAt: createdAt)}, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "trackPurchase") + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -132,6 +155,17 @@ struct OnlineRequestProcessor: RequestProcessorProtocol { requestIdentifier: "trackEvent") } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]? = nil, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) -> Pending { + sendRequest(requestProvider: { apiClient.track(event: event, withBody: body) }, + successHandler: onSuccess, + failureHandler: onFailure, + requestIdentifier: "trackEvent") + } + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler? = nil, diff --git a/swift-sdk/Internal/RequestCreator.swift b/swift-sdk/Internal/RequestCreator.swift index c80f4a6f..52debfe7 100644 --- a/swift-sdk/Internal/RequestCreator.swift +++ b/swift-sdk/Internal/RequestCreator.swift @@ -107,6 +107,20 @@ struct RequestCreator { return .success(.post(createPostRequest(path: Const.Path.updateCart, body: body))) } + func createUpdateCartRequest(items: [CommerceItem], withUser user: [AnyHashable: Any], createdAt: Int) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + let itemsToSerialize = items.map { $0.toDictionary() } + + let body: [String: Any] = [JsonKey.Commerce.user: user, + JsonKey.Body.createdAt: createdAt, + JsonKey.Commerce.items: itemsToSerialize] + + return .success(.post(createPostRequest(path: Const.Path.updateCart, body: body))) + } + func createTrackPurchaseRequest(_ total: NSNumber, items: [CommerceItem], dataFields: [AnyHashable: Any]?, @@ -141,6 +155,30 @@ struct RequestCreator { return .success(.post(createPostRequest(path: Const.Path.trackPurchase, body: body))) } + func createTrackPurchaseRequest(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + withUser user: [AnyHashable: Any], + createdAt: Int) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + + let itemsToSerialize = items.map { $0.toDictionary() } + + var body: [String: Any] = [JsonKey.Commerce.user: user, + JsonKey.Body.createdAt: createdAt, + JsonKey.Commerce.items: itemsToSerialize, + JsonKey.Commerce.total: total] + + if let dataFields = dataFields { + body[JsonKey.dataFields] = dataFields + } + + return .success(.post(createPostRequest(path: Const.Path.trackPurchase, body: body))) + } + func createTrackPushOpenRequest(_ campaignId: NSNumber, templateId: NSNumber?, messageId: String, appAlreadyRunning: Bool, dataFields: [AnyHashable: Any]?) -> Result { if case .none = auth.emailOrUserId { ITBError(Self.authMissingMessage) @@ -191,6 +229,24 @@ struct RequestCreator { return .success(.post(createPostRequest(path: Const.Path.trackEvent, body: body))) } + func createTrackEventRequest(_ eventName: String, withBody body: [AnyHashable: Any]?) -> Result { + if case .none = auth.emailOrUserId { + ITBError(Self.authMissingMessage) + return .failure(IterableError.general(description: Self.authMissingMessage)) + } + + var postBody = [AnyHashable: Any]() + if let _body = body { + postBody = _body + } + + setCurrentUser(inDict: &postBody) + + postBody.setValue(for: JsonKey.eventName, value: eventName) + + return .success(.post(createPostRequest(path: Const.Path.trackEvent, body: postBody))) + } + func createUpdateSubscriptionsRequest(_ emailListIds: [NSNumber]? = nil, unsubscribedChannelIds: [NSNumber]? = nil, unsubscribedMessageTypeIds: [NSNumber]? = nil, @@ -442,6 +498,37 @@ struct RequestCreator { return .success(.get(createGetRequest(forPath: Const.Path.getRemoteConfiguration, withArgs: args as! [String: String]))) } + func createGetUserByUserIdRequest(_ userId: String) -> Result { + var body: [AnyHashable: Any] = [JsonKey.userId: userId] + return .success(.get(createGetRequest(forPath: Const.Path.userByUserId, withArgs: body as! [String: String]))) + } + + func createGetUserByEmailRequest(_ email: String) -> Result { + var body: [AnyHashable: Any] = [JsonKey.email: email] + return .success(.get(createGetRequest(forPath: Const.Path.userByEmail, withArgs: body as! [String: String]))) + } + + func createMergeUserRequest(_ sourceEmail: String, _ sourceUserId: String, _ destinationEmail: String, destinationUserId: String) -> Result { + var body = [AnyHashable: Any]() + + if IterableUtil.isNotNullOrEmpty(string: sourceEmail) { + body.setValue(for: JsonKey.sourceEmail, value: sourceEmail) + } + + if IterableUtil.isNotNullOrEmpty(string: sourceUserId) { + body.setValue(for: JsonKey.sourceUserId, value: sourceUserId) + } + + if IterableUtil.isNotNullOrEmpty(string: destinationEmail) { + body.setValue(for: JsonKey.destinationEmail, value: destinationEmail) + } + + if IterableUtil.isNotNullOrEmpty(string: destinationUserId) { + body.setValue(for: JsonKey.destinationUserId, value: destinationUserId) + } + return .success(.post(createPostRequest(path: Const.Path.mergeUser, body: body))) + } + // MARK: - PRIVATE private static let authMissingMessage = "Both email and userId are nil" diff --git a/swift-sdk/Internal/RequestHandler.swift b/swift-sdk/Internal/RequestHandler.swift index b56a2f18..b9ec6e0d 100644 --- a/swift-sdk/Internal/RequestHandler.swift +++ b/swift-sdk/Internal/RequestHandler.swift @@ -96,6 +96,21 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func updateCart(items: [CommerceItem], + withUser user: [AnyHashable:Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.updateCart(items: items, + withUser: user, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -115,6 +130,25 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + withUser user: [AnyHashable: Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.trackPurchase(total, + items: items, + dataFields: dataFields, + withUser: user, + createdAt: createdAt, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -147,6 +181,19 @@ class RequestHandler: RequestHandlerProtocol { } } + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending { + sendUsingRequestProcessor { processor in + processor.track(event: event, + withBody: body, + onSuccess: onSuccess, + onFailure: onFailure) + } + } + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler?, diff --git a/swift-sdk/Internal/RequestHandlerProtocol.swift b/swift-sdk/Internal/RequestHandlerProtocol.swift index a3b37083..8f09999d 100644 --- a/swift-sdk/Internal/RequestHandlerProtocol.swift +++ b/swift-sdk/Internal/RequestHandlerProtocol.swift @@ -43,6 +43,13 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func updateCart(items: [CommerceItem], + withUser user: [AnyHashable:Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -52,6 +59,15 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + withUser user: [AnyHashable: Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -67,6 +83,12 @@ protocol RequestHandlerProtocol: AnyObject { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func updateSubscriptions(info: UpdateSubscriptionsInfo, onSuccess: OnSuccessHandler?, diff --git a/swift-sdk/Internal/RequestProcessorProtocol.swift b/swift-sdk/Internal/RequestProcessorProtocol.swift index 111d3719..e1f7e173 100644 --- a/swift-sdk/Internal/RequestProcessorProtocol.swift +++ b/swift-sdk/Internal/RequestProcessorProtocol.swift @@ -30,6 +30,13 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func updateCart(items: [CommerceItem], + withUser user: [AnyHashable:Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPurchase(_ total: NSNumber, items: [CommerceItem], @@ -39,6 +46,15 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func trackPurchase(_ total: NSNumber, + items: [CommerceItem], + dataFields: [AnyHashable: Any]?, + withUser user: [AnyHashable: Any], + createdAt: Int, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackPushOpen(_ campaignId: NSNumber, templateId: NSNumber?, @@ -54,6 +70,12 @@ protocol RequestProcessorProtocol { onSuccess: OnSuccessHandler?, onFailure: OnFailureHandler?) -> Pending + @discardableResult + func track(event: String, + withBody body: [AnyHashable: Any]?, + onSuccess: OnSuccessHandler?, + onFailure: OnFailureHandler?) -> Pending + @discardableResult func trackInAppOpen(_ message: IterableInAppMessage, location: InAppLocation, diff --git a/swift-sdk/IterableAPI.swift b/swift-sdk/IterableAPI.swift index 1eb2b952..4f6185c0 100644 --- a/swift-sdk/IterableAPI.swift +++ b/swift-sdk/IterableAPI.swift @@ -125,6 +125,18 @@ import UIKit }.onError { _ in callback?(false) } + + if(config.enableAnonTracking) { + if let _implementation = implementation { + if _implementation.isEitherUserIdOrEmailSet() { + _implementation.anonymousUserManager.syncNonSyncedEvents() + } else { + // call this to fetch anon criteria from API and save it into userdefaults + _implementation.anonymousUserManager.getAnonCriteria() + _implementation.anonymousUserManager.updateAnonSession() + } + } + } } // MARK: - SDK diff --git a/swift-sdk/IterableConfig.swift b/swift-sdk/IterableConfig.swift index c254db7a..b3f1474e 100644 --- a/swift-sdk/IterableConfig.swift +++ b/swift-sdk/IterableConfig.swift @@ -127,4 +127,7 @@ public class IterableConfig: NSObject { /// Sets data region which determines data center and endpoints used by the SDK public var dataRegion: String = IterableDataRegion.US + + /// When set to `true`, IterableSDK will track all events when users are not logged into the application. + public var enableAnonTracking = false } diff --git a/swift-sdk/Resources/anoncriteria_response.json b/swift-sdk/Resources/anoncriteria_response.json new file mode 100644 index 00000000..67907a8a --- /dev/null +++ b/swift-sdk/Resources/anoncriteria_response.json @@ -0,0 +1,130 @@ +{ + "count":2, + "criteriaList":[ + { + "criteriaId":12345, + "searchQuery":{ + "combinator":"And", + "searchQueries":[ + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"purchase", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"shoppingCartItems.price", + "fieldType":"double", + "comparatorType":"Equals", + "dataType":"purchase", + "id":2, + "value":"4.67" + }, + { + "field":"shoppingCartItems.quantity", + "fieldType":"long", + "comparatorType":"GreaterThan", + "dataType":"purchase", + "id":3, + "valueLong":2, + "value":"2" + }, + { + "field":"total", + "fieldType":"long", + "comparatorType":"GreaterThanOrEqualTo", + "dataType":"purchase", + "id":4, + "valueLong":10, + "value":"10" + } + ] + } + } + ] + }, + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"customEvent", + "searchCombo":{ + "combinator":"Or", + "searchQueries":[ + { + "field":"eventName", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":9, + "value":"processing_cancelled" + } + ] + } + } + ] + } + ] + } + }, + { + "criteriaId":5678, + "searchQuery":{ + "combinator":"Or", + "searchQueries":[ + { + "combinator":"Or", + "searchQueries":[ + { + "dataType":"user", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"itblInternal.emailDomain", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"user", + "id":6, + "value":"gmail.com" + } + ] + } + }, + { + "dataType":"customEvent", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"eventName", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":9, + "value":"processing_cancelled" + }, + { + "field":"createdAt", + "fieldType":"date", + "comparatorType":"GreaterThan", + "dataType":"customEvent", + "id":10, + "dateRange":{ + + }, + "isRelativeDate":false, + "value":"1731513963000" + } + ] + } + } + ] + } + ] + } + } + ] +} diff --git a/tests/common/MockLocalStorage.swift b/tests/common/MockLocalStorage.swift index ab148e71..aa115845 100644 --- a/tests/common/MockLocalStorage.swift +++ b/tests/common/MockLocalStorage.swift @@ -7,6 +7,12 @@ import Foundation @testable import IterableSDK class MockLocalStorage: LocalStorageProtocol { + var anonymousUserEvents: [[AnyHashable : Any]]? + + var criteriaData: Data? + + var anonymousSessions: IterableSDK.IterableAnonSessionsWrapper? + var userId: String? = nil var email: String? = nil diff --git a/tests/unit-tests/AnonymousUserCriteriaMatchTests.swift b/tests/unit-tests/AnonymousUserCriteriaMatchTests.swift new file mode 100644 index 00000000..41455193 --- /dev/null +++ b/tests/unit-tests/AnonymousUserCriteriaMatchTests.swift @@ -0,0 +1,211 @@ +// +// File.swift +// +// +// Created by HARDIK MASHRU on 14/11/23. +// + +import XCTest + +@testable import IterableSDK + +class AnonymousUserCriteriaMatchTests: XCTestCase { + + private let mockDataWithOr = """ + { + "count":1, + "criteriaList":[ + { + "criteriaId":12345, + "searchQuery":{ + "combinator":"Or", + "searchQueries":[ + { + "dataType":"purchase", + "searchCombo":{ + "combinator":"Or", + "searchQueries":[ + { + "field":"shoppingCartItems.price", + "fieldType":"double", + "comparatorType":"Equals", + "dataType":"purchase", + "id":2, + "value":"5.9" + }, + { + "field":"shoppingCartItems.quantity", + "fieldType":"long", + "comparatorType":"GreaterThan", + "dataType":"purchase", + "id":3, + "valueLong":2, + "value":"2" + }, + { + "field":"total", + "fieldType":"long", + "comparatorType":"GreaterThanOrEqualTo", + "dataType":"purchase", + "id":4, + "valueLong":10, + "value":"10" + } + ] + } + } + ] + } + } + ] + } + """ + + private let mockDataWithAnd = """ + { + "count":1, + "criteriaList":[ + { + "criteriaId":12345, + "searchQuery":{ + "combinator":"And", + "searchQueries":[ + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"purchase", + "searchCombo":{ + "combinator":"And", + "searchQueries":[ + { + "field":"shoppingCartItems.price", + "fieldType":"double", + "comparatorType":"Equals", + "dataType":"purchase", + "id":2, + "value":"4.67" + }, + { + "field":"shoppingCartItems.quantity", + "fieldType":"long", + "comparatorType":"GreaterThan", + "dataType":"purchase", + "id":3, + "valueLong":2, + "value":"2" + }, + { + "field":"total", + "fieldType":"long", + "comparatorType":"GreaterThanOrEqualTo", + "dataType":"purchase", + "id":4, + "valueLong":10, + "value":"10" + }, + { + "field":"campaignId", + "fieldType":"long", + "comparatorType":"Equals", + "dataType":"purchase", + "id":11, + "value":"1234" + } + ] + } + }, + { + "combinator":"And", + "searchQueries":[ + { + "dataType":"customEvent", + "searchCombo":{ + "combinator":"Or", + "searchQueries":[ + { + "field":"eventName", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":9, + "value":"processing_cancelled" + }, + { + "field":"messageId", + "fieldType":"string", + "comparatorType":"Equals", + "dataType":"customEvent", + "id":10, + "value":"1234" + } + ] + } + } + ] + } + ] + } + ] + } + } + ] + } + """ + + override func setUp() { + super.setUp() + } + + func data(from jsonString: String) -> Data? { + return jsonString.data(using: .utf8) + } + + func testCompareDataWithANDCombinatorSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 4.67, "quantity": 3]], + "total": 11.0, + "createdAt": 1699246745093, + "dataType": "purchase", + "dataFields": ["campaignId": 1234] + ], ["dataType": "customEvent", "eventName": "processing_cancelled"]] + let expectedCriteriaId = 12345 + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataWithAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithANDCombinatorFail() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 4.67, "quantity": 3]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataWithAnd)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } + + + func testCompareDataWithORCombinatorSuccess() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 5.9, "quantity": 1]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let expectedCriteriaId = 12345 + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataWithOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, expectedCriteriaId) + } + + func testCompareDataWithORCombinatorFail() { + let eventItems: [[AnyHashable: Any]] = [[ + "items": [["id": "12", "name": "Mocha", "price": 2.9, "quantity": 1]], + "total": 9.0, + "createdAt": 1699246745093, + "dataType": "purchase" + ]] + let matchedCriteriaId = CriteriaCompletionChecker(anonymousCriteria: data(from: mockDataWithOr)!, anonymousEvents: eventItems).getMatchedCriteria() + XCTAssertEqual(matchedCriteriaId, nil) + } +} diff --git a/tests/unit-tests/AnonymousUserMergeTests.swift b/tests/unit-tests/AnonymousUserMergeTests.swift new file mode 100644 index 00000000..976bff97 --- /dev/null +++ b/tests/unit-tests/AnonymousUserMergeTests.swift @@ -0,0 +1,62 @@ +// +// AnonymousUserMergeTests.swift +// +// +// Created by Hani Vora on 29/12/23. +// + + +import XCTest +import Foundation + +@testable import IterableSDK + +class AnonymousUserMergeTests: XCTestCase, AuthProvider { + public var auth: Auth { + Auth(userId: nil, email: "user@example.com", authToken: "asdf") + } + + private static let apiKey = "zeeApiKey" + + override func setUp() { + super.setUp() + } + + func testMergeUserUsingUserId() { + let networkSession: NetworkSessionProtocol = MockNetworkSession() + let mockApiClient = ApiClient(apiKey: AnonymousUserMergeTests.apiKey, + authProvider: self, + endpoint: Endpoint.api, + networkSession: networkSession, + deviceMetadata: InternalIterableAPI.initializeForTesting().deviceMetadata, + dateProvider: MockDateProvider()) + + mockApiClient.getUserByUserID(userId: "123").onSuccess { data in + self.callMergeApi(sourceEmail: "", sourceUserId: "123", destinationEmail: "destination@example.com", destinationUserId: "456", apiClient: mockApiClient) + } + } + + func testMergeUserUsingEmail() { + let networkSession: NetworkSessionProtocol = MockNetworkSession() + let mockApiClient = ApiClient(apiKey: AnonymousUserMergeTests.apiKey, + authProvider: self, + endpoint: Endpoint.api, + networkSession: networkSession, + deviceMetadata: InternalIterableAPI.initializeForTesting().deviceMetadata, + dateProvider: MockDateProvider()) + + mockApiClient.getUserByEmail(email: "source@example.com").onSuccess { data in + self.callMergeApi(sourceEmail: "source@example.com", sourceUserId: "", destinationEmail: "destination@example.com", destinationUserId: "456", apiClient: mockApiClient) + } + } + + private func callMergeApi(sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String, apiClient: ApiClient) { + + let expectation1 = expectation(description: #function) + + apiClient.mergeUser(sourceEmail: sourceEmail, sourceUserId: sourceUserId, destinationEmail: destinationEmail, destinationUserId: destinationUserId).onSuccess { _ in + expectation1.fulfill() + } + } + +} diff --git a/tests/unit-tests/BlankApiClient.swift b/tests/unit-tests/BlankApiClient.swift index 43571ae1..5e8d97bd 100644 --- a/tests/unit-tests/BlankApiClient.swift +++ b/tests/unit-tests/BlankApiClient.swift @@ -7,6 +7,24 @@ import Foundation @testable import IterableSDK class BlankApiClient: ApiClientProtocol { + + func track(event eventName: String, dataFields: [AnyHashable : Any]?) -> IterableSDK.Pending { + Pending() + } + + func track(event eventName: String, withBody body: [AnyHashable : Any]?) -> IterableSDK.Pending { + Pending() + } + + + func updateCart(items: [IterableSDK.CommerceItem], withUser user: [AnyHashable : Any], createdAt: Int) -> IterableSDK.Pending { + Pending() + } + + func track(purchase total: NSNumber, items: [IterableSDK.CommerceItem], dataFields: [AnyHashable : Any]?, withUser user: [AnyHashable : Any], createdAt: Int) -> IterableSDK.Pending { + Pending() + } + func register(registerTokenInfo: RegisterTokenInfo, notificationsEnabled: Bool) -> Pending { Pending() } @@ -31,10 +49,6 @@ class BlankApiClient: ApiClientProtocol { Pending() } - func track(event eventName: String, dataFields: [AnyHashable : Any]?) -> Pending { - Pending() - } - func updateSubscriptions(_ emailListIds: [NSNumber]?, unsubscribedChannelIds: [NSNumber]?, unsubscribedMessageTypeIds: [NSNumber]?, subscribedMessageTypeIds: [NSNumber]?, campaignId: NSNumber?, templateId: NSNumber?) -> Pending { Pending() } @@ -78,4 +92,17 @@ class BlankApiClient: ApiClientProtocol { func getRemoteConfiguration() -> Pending { Pending() } + + func getUserByUserID(userId: String) -> IterableSDK.Pending { + Pending() + } + + func getUserByEmail(email: String) -> IterableSDK.Pending { + Pending() + } + + func mergeUser(sourceEmail: String, sourceUserId: String, destinationEmail: String, destinationUserId: String) -> IterableSDK.Pending { + Pending() + } + }