diff --git a/ClickwrapDemoApp/SpotDraftClickwrap/Core/ClickwrapService.swift b/ClickwrapDemoApp/SpotDraftClickwrap/Core/ClickwrapService.swift index 019cf27..f678cab 100644 --- a/ClickwrapDemoApp/SpotDraftClickwrap/Core/ClickwrapService.swift +++ b/ClickwrapDemoApp/SpotDraftClickwrap/Core/ClickwrapService.swift @@ -60,7 +60,7 @@ internal class ClickwrapService { /// - Throws: `ClickwrapError.networkUnavailable` If there's an issue with the network during submission. /// - Throws: `ClickwrapError.apiError` If the API returns an error status during submission. /// - Throws: `ClickwrapError.decodingError` If the submission response cannot be parsed. - func submitAcceptance(userIdentifier: String) async throws -> String? { + func submitAcceptance(userIdentifier: String, metadata: ClickwrapSubmitMetadata? = nil) async throws -> String? { guard NetworkUtils.isNetworkAvailable() else { Logger.log(logLevel: .error, "\(UtilsConstants.LogCore.tagNetworkClient): \(UtilsConstants.ErrorMessages.errorNetworkUnavailable)") throw ClickwrapError.networkUnavailable @@ -79,7 +79,8 @@ internal class ClickwrapService { userIdentifier: userIdentifier, clickwrapPublicId: data.publicId, policies: data.policies, - agreements: data.agreements + agreements: data.agreements, + metadata: metadata ) } diff --git a/ClickwrapDemoApp/SpotDraftClickwrap/Core/SpotDraftManager.swift b/ClickwrapDemoApp/SpotDraftClickwrap/Core/SpotDraftManager.swift index bd3244d..4802c50 100644 --- a/ClickwrapDemoApp/SpotDraftClickwrap/Core/SpotDraftManager.swift +++ b/ClickwrapDemoApp/SpotDraftClickwrap/Core/SpotDraftManager.swift @@ -155,9 +155,14 @@ public class SpotDraftManager { /// on the listener is called. If validation fails or an error occurs during submission, /// the `onError` method is called. /// - /// - Parameter userIdentifier: A unique string identifier for the user performing the submission. + /// - Parameters: + /// - userIdentifier: A unique string identifier for the user performing the submission. + /// - metadata: Optional metadata to enrich the consent record stored on the backend + /// (first name, last name, email, custom information, key pointer information, + /// and external metadata). All fields within `metadata` are optional. public static func submitAcceptance( - userIdentifier: String + userIdentifier: String, + metadata: ClickwrapSubmitMetadata? = nil ) { Logger.info(String(format: UtilsConstants.LogCore.managerSubmittingAcceptance, userIdentifier)) guard let currentService = actualServiceInstance else { @@ -169,7 +174,7 @@ public class SpotDraftManager { if !currentService.validateAllAccepted() { throw ClickwrapError.policiesNotAccepted } - let submissionId = try await currentService.submitAcceptance(userIdentifier: userIdentifier) + let submissionId = try await currentService.submitAcceptance(userIdentifier: userIdentifier, metadata: metadata) await MainActor.run { actualListenerInstance?.onSubmitSuccessful(submissionPublicId: submissionId) } diff --git a/ClickwrapDemoApp/SpotDraftClickwrap/Domain/Models/ClickwrapSubmitMetadata.swift b/ClickwrapDemoApp/SpotDraftClickwrap/Domain/Models/ClickwrapSubmitMetadata.swift new file mode 100644 index 0000000..e2be42d --- /dev/null +++ b/ClickwrapDemoApp/SpotDraftClickwrap/Domain/Models/ClickwrapSubmitMetadata.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Optional metadata that can be attached to a clickwrap acceptance submission. +/// +/// Pass an instance of this struct to `SpotDraftManager.submitAcceptance(userIdentifier:metadata:)` +/// to enrich the consent record stored on the SpotDraft backend. All fields are optional — +/// supply only what is available in your application context. +/// +/// ## Example +/// ```swift +/// let metadata = ClickwrapSubmitMetadata( +/// firstName: "Jane", +/// lastName: "Doe", +/// userEmail: "jane@example.com", +/// additionalCustomInformation: ["plan": "enterprise"], +/// keyPointerInformation: ["contractRef": "MSA-2024"], +/// externalMetadata: ["crmId": "SF-00001"] +/// ) +/// SpotDraftManager.submitAcceptance(userIdentifier: userId, metadata: metadata) +/// ``` +public struct ClickwrapSubmitMetadata { + /// The user's first name. + public let firstName: String? + + /// The user's last name. + public let lastName: String? + + /// The user's email address. + public let userEmail: String? + + /// Arbitrary key–value pairs to attach as custom information on the consent record. + /// Values must be JSON-serialisable types (`String`, `Int`, `Double`, `Bool`, `[Any]`, `[String: Any]`). + public let additionalCustomInformation: [String: Any]? + + /// Structured metadata used to populate contract field pointers on the consent record. + /// Values must be JSON-serialisable types. + public let keyPointerInformation: [String: Any]? + + /// External-system context to store alongside the consent record (e.g. CRM IDs, order references). + /// Values must be JSON-serialisable types. + public let externalMetadata: [String: Any]? + + /// Creates a new `ClickwrapSubmitMetadata` instance. + /// + /// - Parameters: + /// - firstName: The user's first name. Defaults to `nil`. + /// - lastName: The user's last name. Defaults to `nil`. + /// - userEmail: The user's email address. Defaults to `nil`. + /// - additionalCustomInformation: Arbitrary custom key–value pairs. Defaults to `nil`. + /// - keyPointerInformation: Contract-field pointer metadata. Defaults to `nil`. + /// - externalMetadata: External system context data. Defaults to `nil`. + public init( + firstName: String? = nil, + lastName: String? = nil, + userEmail: String? = nil, + additionalCustomInformation: [String: Any]? = nil, + keyPointerInformation: [String: Any]? = nil, + externalMetadata: [String: Any]? = nil + ) { + self.firstName = firstName + self.lastName = lastName + self.userEmail = userEmail + self.additionalCustomInformation = additionalCustomInformation + self.keyPointerInformation = keyPointerInformation + self.externalMetadata = externalMetadata + } +} diff --git a/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/ConsentStatusRequest.swift b/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/ConsentStatusRequest.swift index c5693e2..12be18c 100644 --- a/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/ConsentStatusRequest.swift +++ b/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/ConsentStatusRequest.swift @@ -1,118 +1,16 @@ - import Foundation -/// A request model used to send the consent status of a user for various Clickwrap agreements -/// to the backend. This typically includes the user's identifier and details about -/// which agreements they have clicked (consented to). -public struct ConsentStatusRequest: Codable { - /// A unique identifier for the user whose consent status is being reported. +/// Request body for `POST .../consent_status/`. +/// +/// The backend only consumes `user_identifier`; the previous iteration of this model +/// included `additional_custom_information`, `key_pointer_information`, and `agreements` +/// which the API ignores entirely. Those fields have been removed to keep the wire +/// format accurate and the model self-documenting. +public struct ConsentStatusRequest: Encodable { + /// A unique identifier for the user whose consent status is being checked. let userIdentifier: String - - /// Optional: Additional custom information related to the user or the consent event. - /// This can be any JSON-encodable data. - let additionalCustomInformation: [String: AnyCodable]? - - /// Optional: Information related to key pointers, which might be used for tracking - /// or linking consent events to other system data. - let keyPointerInformation: [String: AnyCodable]? - - /// An array of `AgreementConsentStatus` objects, each detailing the consent status - /// for a specific agreement. - let agreements: [AgreementConsentStatus]? - /// Coding keys to map Swift property names to the corresponding JSON keys - /// expected by the backend API. enum CodingKeys: String, CodingKey { case userIdentifier = "user_identifier" - case additionalCustomInformation = "additional_custom_information" - case keyPointerInformation = "key_pointer_information" - case agreements = "agreements" - } -} - -/// A nested structure within `ConsentStatusRequest` that represents the consent status -/// for a single Clickwrap agreement. -public struct AgreementConsentStatus: Codable { - /// The unique identifier of the agreement. - let id: Int - - /// The specific version identifier of the agreement. - let versionId: Int - - /// A boolean indicating whether the user has clicked (consented to) this agreement. - let hasClicked: Bool - - /// Coding keys to map Swift property names to the corresponding JSON keys. - enum CodingKeys: String, CodingKey { - case id = "id" - case versionId = "version_id" - case hasClicked = "has_clicked" - } -} - -/// A type-erasing wrapper that allows encoding and decoding of values of any type -/// that conforms to `Codable` within a dictionary or array. -/// This is particularly useful when dealing with JSON structures where the type -/// of a value might not be known at compile time (e.g., `additionalCustomInformation`). -public struct AnyCodable: Codable { - /// The underlying value, which can be of any type. - public let value: Any - - /// Initializes `AnyCodable` with a given value. - /// - Parameter value: The value to wrap. - public init(_ value: Any) { - self.value = value - } - - /// Initializes `AnyCodable` by decoding from a `Decoder`. - /// It attempts to decode the value into various common types (String, Int, Double, Bool, Array, Dictionary) - /// until a successful decode is achieved. - /// - Parameter decoder: The decoder to read data from. - /// - Throws: `DecodingError.dataCorruptedError` if the value cannot be decoded into any supported type. - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let string = try? container.decode(String.self) { - value = string - } else if let int = try? container.decode(Int.self) { - value = int - } else if let double = try? container.decode(Double.self) { - value = double - } else if let bool = try? container.decode(Bool.self) { - value = bool - } else if let array = try? container.decode([AnyCodable].self) { - value = array.map { $0.value } - } else if let dictionary = try? container.decode([String: AnyCodable].self) { - value = dictionary.mapValues { $0.value } - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") - } - } - - /// Encodes the `AnyCodable` value into an `Encoder`. - /// It attempts to encode the underlying `value` based on its runtime type. - /// - Parameter encoder: The encoder to write data to. - /// - Throws: An error if the value cannot be encoded. - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if let string = value as? String { - try container.encode(string) - } else if let int = value as? Int { - try container.encode(int) - } else if let double = value as? Double { - try container.encode(double) - } else if let bool = value as? Bool { - try container.encode(bool) - } else if let array = value as? [Any?] { - try container.encode(array.map { AnyCodable($0 ?? "") }) - } else if let dictionary = value as? [String: Any?] { - try container.encode(dictionary.mapValues { AnyCodable($0 ?? "") }) - } else if let value = value as Any? { - // Fallback for other types, encoding them as their string description. - // This might not be ideal for all types and could lead to data loss - // if the string representation is not sufficient for re-decoding. - try container.encode(String(describing: value)) - } else { - try container.encodeNil() - } } } diff --git a/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/SubmissionRequestModel.swift b/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/SubmissionRequestModel.swift index 06a51f8..2aaa24e 100644 --- a/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/SubmissionRequestModel.swift +++ b/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Models/Request/SubmissionRequestModel.swift @@ -3,7 +3,12 @@ import Foundation /// Represents the complete request body structure for submitting clickwrap acceptance. /// This model is serialized into JSON and sent to the `/execute` API endpoint. -public struct SubmissionRequestModel: Codable { +/// +/// All fields except `userIdentifier` and `agreements` are optional. The optional +/// metadata fields (`firstName`, `lastName`, `userEmail`, `additionalCustomInformation`, +/// `keyPointerInformation`, `externalMetadata`) are omitted from the JSON payload when +/// `nil`, keeping the wire format backward-compatible. +public struct SubmissionRequestModel: Encodable { /// A unique string identifying the user performing the acceptance. let userIdentifier: String /// The public ID (UUID) of the clickwrap configuration being executed. @@ -11,10 +16,44 @@ public struct SubmissionRequestModel: Codable { /// A list of `AgreementBody` objects, each detailing an agreement's acceptance status. let agreements: [AgreementBody] + // MARK: - Optional metadata fields (Gap 1 fix) + + /// The user's first name. + let firstName: String? + /// The user's last name. + let lastName: String? + /// The user's email address. + let userEmail: String? + /// Arbitrary key–value pairs to store as custom information on the consent record. + let additionalCustomInformation: [String: AnyCodable]? + /// Structured contract-field pointer metadata. + let keyPointerInformation: [String: AnyCodable]? + /// External-system context data to store alongside the consent record. + let externalMetadata: [String: AnyCodable]? + enum CodingKeys: String, CodingKey { case userIdentifier = "user_identifier" case clickwrapPublicId = "clickwrap_public_id" case agreements = "agreements" + case firstName = "first_name" + case lastName = "last_name" + case userEmail = "user_email" + case additionalCustomInformation = "additional_custom_information" + case keyPointerInformation = "key_pointer_information" + case externalMetadata = "external_metadata" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(userIdentifier, forKey: .userIdentifier) + try container.encodeIfPresent(clickwrapPublicId, forKey: .clickwrapPublicId) + try container.encode(agreements, forKey: .agreements) + try container.encodeIfPresent(firstName, forKey: .firstName) + try container.encodeIfPresent(lastName, forKey: .lastName) + try container.encodeIfPresent(userEmail, forKey: .userEmail) + try container.encodeIfPresent(additionalCustomInformation, forKey: .additionalCustomInformation) + try container.encodeIfPresent(keyPointerInformation, forKey: .keyPointerInformation) + try container.encodeIfPresent(externalMetadata, forKey: .externalMetadata) } } diff --git a/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Utils/AnyCodable.swift b/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Utils/AnyCodable.swift new file mode 100644 index 0000000..2106c1e --- /dev/null +++ b/ClickwrapDemoApp/SpotDraftClickwrap/Networking/Utils/AnyCodable.swift @@ -0,0 +1,57 @@ +import Foundation + +/// A type-erasing wrapper that allows encoding of values whose concrete type is not known +/// at compile time (e.g. values stored in `[String: Any]` dictionaries that must be sent +/// as JSON). +/// +/// Used internally by `SubmissionRequestModel` to encode the dictionary-typed metadata +/// fields (`additionalCustomInformation`, `keyPointerInformation`, `externalMetadata`). +public struct AnyCodable: Codable { + /// The underlying value. + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + value = string + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "AnyCodable value cannot be decoded" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let string = value as? String { + try container.encode(string) + } else if let int = value as? Int { + try container.encode(int) + } else if let double = value as? Double { + try container.encode(double) + } else if let bool = value as? Bool { + try container.encode(bool) + } else if let array = value as? [Any?] { + try container.encode(array.map { AnyCodable($0 ?? "") }) + } else if let dictionary = value as? [String: Any?] { + try container.encode(dictionary.mapValues { AnyCodable($0 ?? "") }) + } else { + try container.encode(String(describing: value)) + } + } +} diff --git a/ClickwrapDemoApp/SpotDraftClickwrap/Repository/ClickwrapRepository.swift b/ClickwrapDemoApp/SpotDraftClickwrap/Repository/ClickwrapRepository.swift index dbb35b8..5425946 100644 --- a/ClickwrapDemoApp/SpotDraftClickwrap/Repository/ClickwrapRepository.swift +++ b/ClickwrapDemoApp/SpotDraftClickwrap/Repository/ClickwrapRepository.swift @@ -48,7 +48,7 @@ public class DefaultClickwrapRepository { } } - public func submitAcceptance(userIdentifier: String, clickwrapPublicId: String?, policies: [Policy], agreements: [Agreement]) async throws -> String? { + public func submitAcceptance(userIdentifier: String, clickwrapPublicId: String?, policies: [Policy], agreements: [Agreement], metadata: ClickwrapSubmitMetadata? = nil) async throws -> String? { Logger.info("\(UtilsConstants.LogCore.tagClickwrapRepo): Preparing to submit acceptance for user: \(userIdentifier)...") // Corrected let agreementsById = agreements.reduce(into: [Int: Agreement]()) { $0[$1.id] = $1 } @@ -72,10 +72,21 @@ public class DefaultClickwrapRepository { Logger.debug("\(UtilsConstants.LogCore.tagClickwrapRepo): Submission payload details (policyId -> hasClicked): \(String(describing: debugPayload))") // Corrected } + // Map optional ClickwrapSubmitMetadata into AnyCodable dictionaries required by the request model. + let toAnyCodable: ([String: Any]?) -> [String: AnyCodable]? = { dict in + dict?.mapValues { AnyCodable($0) } + } + let requestBody = SubmissionRequestModel( userIdentifier: userIdentifier, clickwrapPublicId: clickwrapPublicId, - agreements: agreementPayloads + agreements: agreementPayloads, + firstName: metadata?.firstName, + lastName: metadata?.lastName, + userEmail: metadata?.userEmail, + additionalCustomInformation: toAnyCodable(metadata?.additionalCustomInformation), + keyPointerInformation: toAnyCodable(metadata?.keyPointerInformation), + externalMetadata: toAnyCodable(metadata?.externalMetadata) ) do { @@ -97,12 +108,7 @@ public class DefaultClickwrapRepository { public func checkConsentStatus(userIdentifier: String) async throws -> ConsentStatusResponse { Logger.info("\(UtilsConstants.LogCore.tagClickwrapRepo): Checking consent status for user: \(userIdentifier) (Clickwrap ID: \(self.clickwrapId))") // Corrected - let requestBody = ConsentStatusRequest( - userIdentifier: userIdentifier, - additionalCustomInformation: nil, - keyPointerInformation: nil, - agreements: nil - ) + let requestBody = ConsentStatusRequest(userIdentifier: userIdentifier) do { let response: ConsentStatusResponse = try await networkClient.send(endpoint: .consentStatusCheck(clickwrapId: clickwrapId), body: requestBody)