Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -79,7 +79,8 @@ internal class ClickwrapService {
userIdentifier: userIdentifier,
clickwrapPublicId: data.publicId,
policies: data.policies,
agreements: data.agreements
agreements: data.agreements,
metadata: metadata
)
}

Expand Down
11 changes: 8 additions & 3 deletions ClickwrapDemoApp/SpotDraftClickwrap/Core/SpotDraftManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,57 @@ 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.
let clickwrapPublicId: String?
/// 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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Loading