@explorins/pers-sdk-react-native 2.2.1 → 2.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2624,6 +2624,8 @@ exports.ApiKeyType = void 0;
2624
2624
  ApiKeyType["BLOCKCHAIN_READER_JWT"] = "BLOCKCHAIN_READER_JWT";
2625
2625
  ApiKeyType["TRANSACTION_JWT_ACCESS_TOKEN"] = "TRANSACTION_JWT_ACCESS_TOKEN";
2626
2626
  ApiKeyType["WEBHOOK_OUTBOUND_JWT"] = "WEBHOOK_OUTBOUND_JWT";
2627
+ /** One-Time Token for passwordless auth flows (magic link, SMS, etc.) */
2628
+ ApiKeyType["ONE_TIME_TOKEN"] = "ONE_TIME_TOKEN";
2627
2629
  // TODO: this needs to be removed. However, there is still dependency
2628
2630
  ApiKeyType["TENANT_LEGACY_ADMIN"] = "TENANT_ADMIN_JWT";
2629
2631
  // TENANT_SYSTEM_API_SECRET = 'TENANT_SYSTEM_API_SECRET',
@@ -3152,6 +3154,8 @@ exports.WebhookSource = void 0;
3152
3154
  WebhookSource["USER"] = "USER";
3153
3155
  WebhookSource["BUSINESS"] = "BUSINESS";
3154
3156
  WebhookSource["TENANT"] = "TENANT";
3157
+ // System-level internal invocation (e.g., token metadata webhook fetch)
3158
+ WebhookSource["INTERNAL"] = "INTERNAL";
3155
3159
  // External verified sources
3156
3160
  WebhookSource["STRIPE"] = "STRIPE";
3157
3161
  // Allow any external traffic (no verification)
@@ -3182,6 +3186,629 @@ exports.WebhookCallbackStatus = void 0;
3182
3186
  WebhookCallbackStatus["CANCELLED"] = "CANCELLED";
3183
3187
  })(exports.WebhookCallbackStatus || (exports.WebhookCallbackStatus = {}));
3184
3188
 
3189
+ /**
3190
+ * One-Time Token Authentication Methods
3191
+ *
3192
+ * Defines the passwordless authentication methods supported by OTT flow.
3193
+ * Used in magic link, SMS verification, and email code authentication.
3194
+ */
3195
+ const OttAuthMethod = {
3196
+ /** Magic link sent via email */
3197
+ MAGIC_LINK: 'magic-link',
3198
+ /** Verification code sent via SMS */
3199
+ SMS: 'sms',
3200
+ /** Verification code sent via email */
3201
+ EMAIL_CODE: 'email-code',
3202
+ };
3203
+
3204
+ /**
3205
+ * Base array of all string-valued user model property keys.
3206
+ * Single source of truth for user field names across the codebase.
3207
+ */
3208
+ const userModelFieldsArray = [
3209
+ 'id',
3210
+ 'email',
3211
+ 'identifierEmail',
3212
+ 'firstName',
3213
+ 'lastName',
3214
+ 'externalId',
3215
+ 'instagramAccountId',
3216
+ 'googleAccountName',
3217
+ ];
3218
+ /** Iterable array of all user model fields */
3219
+ const userModelFields = [...userModelFieldsArray];
3220
+ /**
3221
+ * Fields that can be used to uniquely identify/lookup a user.
3222
+ * Used for wildcard search, user lookup queries.
3223
+ */
3224
+ const uniqueUserIdentifierKeysArray = [
3225
+ 'id',
3226
+ 'email',
3227
+ 'identifierEmail',
3228
+ 'instagramAccountId',
3229
+ 'externalId',
3230
+ ];
3231
+ // Export the array
3232
+ const uniqueUserIdentifierKeys = [...uniqueUserIdentifierKeysArray];
3233
+ // Define identifier type configurations
3234
+ const identifierTypes = {
3235
+ id: {
3236
+ key: 'id',
3237
+ detect: (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value), // UUID pattern
3238
+ process: (value) => value
3239
+ },
3240
+ email: {
3241
+ key: 'email',
3242
+ detect: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
3243
+ process: (value) => value
3244
+ },
3245
+ instagramAccountId: {
3246
+ key: 'instagramAccountId',
3247
+ detect: (value) => /^@/.test(value) || value.toLowerCase().includes('instagram'),
3248
+ process: (value) => value.replace(/^@/, '') // Remove @ if present for consistency
3249
+ },
3250
+ externalId: {
3251
+ key: 'externalId',
3252
+ detect: () => true, // Default fallback
3253
+ process: (value) => value
3254
+ }
3255
+ };
3256
+ /**
3257
+ * Register a new identifier type configuration
3258
+ * @param key The identifier key
3259
+ * @param config The identifier configuration (without the key)
3260
+ */
3261
+ const registerIdentifierType = (key, config) => {
3262
+ if (!uniqueUserIdentifierKeysArray.includes(key)) {
3263
+ console.warn(`Warning: Registering identifier type '${key}' that is not in uniqueUserIdentifierKeys`);
3264
+ }
3265
+ identifierTypes[key] = {
3266
+ key,
3267
+ ...config
3268
+ };
3269
+ };
3270
+ /**
3271
+ * Detects the type of identifier from a wildcard input
3272
+ * @param wildcardValue The value to analyze
3273
+ * @returns Object containing the type and processed value
3274
+ */
3275
+ const detectIdentifierType = (wildcardValue) => {
3276
+ if (wildcardValue === null || wildcardValue === undefined) {
3277
+ return { type: 'externalId', value: '' };
3278
+ }
3279
+ const value = String(wildcardValue);
3280
+ // Try each identifier type in priority order (id first to catch UUIDs)
3281
+ for (const type of ['id', 'email', 'instagramAccountId', 'externalId']) {
3282
+ const config = identifierTypes[type];
3283
+ if (config.detect(value)) {
3284
+ return { type, value: config.process(value) };
3285
+ }
3286
+ }
3287
+ // Default fallback should never be reached due to externalId's detect always returning true
3288
+ return { type: 'externalId', value };
3289
+ };
3290
+ /**
3291
+ * Checks if an object is a properly formed UserIdentifierObject
3292
+ * @param obj Object to check
3293
+ * @returns Boolean indicating if the object is a UserIdentifierObject
3294
+ */
3295
+ const isUserIdentifierObject = (obj) => {
3296
+ return obj &&
3297
+ obj.type === 'identifierObject' &&
3298
+ uniqueUserIdentifierKeys.every(key => key in obj);
3299
+ };
3300
+ /**
3301
+ * Creates a UserIdentifierObject with all required fields, setting missing values to null
3302
+ * @param params Partial parameters for the identifier object
3303
+ * @returns A properly formed UserIdentifierObject
3304
+ */
3305
+ const createUserIdentifierObject = (params) => {
3306
+ // Extract the wildcard or use empty string
3307
+ const wildcard = params.wildcard || '';
3308
+ // Create base object with all identifier fields set to null
3309
+ const identifierObject = {
3310
+ type: 'identifierObject',
3311
+ id: null,
3312
+ email: null,
3313
+ instagramAccountId: null,
3314
+ externalId: null,
3315
+ identifierEmail: null,
3316
+ wildcard
3317
+ };
3318
+ // Override nulls with any provided values
3319
+ uniqueUserIdentifierKeys.forEach(key => {
3320
+ if (params[key]) {
3321
+ identifierObject[key] = params[key];
3322
+ }
3323
+ });
3324
+ // If wildcard is provided but no specific fields, auto-detect the field type
3325
+ if (wildcard && !params.id && !params.email && !params.instagramAccountId && !params.externalId) {
3326
+ const detected = detectIdentifierType(wildcard);
3327
+ identifierObject[detected.type] = detected.value;
3328
+ }
3329
+ return identifierObject;
3330
+ };
3331
+
3332
+ /**
3333
+ * Validation Types
3334
+ * Shared type definitions for field validation.
3335
+ */
3336
+ // ============================================================================
3337
+ // Operator Text Mapping
3338
+ // ============================================================================
3339
+ const operatorText = {
3340
+ '<': 'less than',
3341
+ '<=': 'less than or equal to',
3342
+ '>': 'greater than',
3343
+ '>=': 'greater than or equal to',
3344
+ '==': 'equal to',
3345
+ '!=': 'not equal to',
3346
+ };
3347
+
3348
+ /**
3349
+ * Standalone Validators
3350
+ * Pure validation functions that can be used independently.
3351
+ * Shared between backend and frontend.
3352
+ */
3353
+ // ============================================================================
3354
+ // Regex Patterns
3355
+ // ============================================================================
3356
+ /** RFC-compliant email regex */
3357
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3358
+ const EMAIL_MAX_LENGTH = 254; // RFC 5321
3359
+ /** Basic URL pattern - http(s) with domain */
3360
+ const URL_REGEX = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
3361
+ /** Ethereum address format: 0x followed by 40 hexadecimal characters */
3362
+ const WALLET_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/;
3363
+ /** UUID v4 format */
3364
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
3365
+ // ============================================================================
3366
+ // Validators
3367
+ // ============================================================================
3368
+ /**
3369
+ * Validates email format according to RFC standards.
3370
+ * RFC 5321 length limit (254 characters) enforced.
3371
+ */
3372
+ function isValidEmail(email) {
3373
+ if (!email || email.length > EMAIL_MAX_LENGTH)
3374
+ return false;
3375
+ return EMAIL_REGEX.test(email);
3376
+ }
3377
+ /**
3378
+ * Validates URL format (http/https only).
3379
+ */
3380
+ function isValidUrl(url) {
3381
+ if (!url)
3382
+ return false;
3383
+ return URL_REGEX.test(url);
3384
+ }
3385
+ /**
3386
+ * Validates Ethereum wallet address format.
3387
+ * Format: 0x followed by 40 hexadecimal characters.
3388
+ */
3389
+ function isValidWalletAddress(address) {
3390
+ if (!address)
3391
+ return false;
3392
+ return WALLET_ADDRESS_REGEX.test(address);
3393
+ }
3394
+ /**
3395
+ * Validates that a string is not empty and meets minimum length.
3396
+ */
3397
+ function isValidNonEmptyString(value, minLength = 1) {
3398
+ return typeof value === 'string' && value.trim().length >= minLength;
3399
+ }
3400
+ /**
3401
+ * Validates UUID v4 format.
3402
+ */
3403
+ function isValidUuid(uuid) {
3404
+ if (!uuid)
3405
+ return false;
3406
+ return UUID_REGEX.test(uuid);
3407
+ }
3408
+
3409
+ // ============================================================================
3410
+ // Comparison Functions
3411
+ // ============================================================================
3412
+ /**
3413
+ * Compare two values using the specified operator.
3414
+ * Works with numbers, dates, and strings.
3415
+ */
3416
+ const compareValues = (a, b, op) => {
3417
+ switch (op) {
3418
+ case '<': return a < b;
3419
+ case '<=': return a <= b;
3420
+ case '>': return a > b;
3421
+ case '>=': return a >= b;
3422
+ case '==': return a === b;
3423
+ case '!=': return a !== b;
3424
+ }
3425
+ };
3426
+ /** Get human-readable operator text for error messages. */
3427
+ const getOperatorText = (op) => operatorText[op];
3428
+ // ============================================================================
3429
+ // Single Field Validation
3430
+ // ============================================================================
3431
+ /**
3432
+ * Validate a single field value against validation rules.
3433
+ * Does not check cross-field comparisons (use validateFieldComparisons for that).
3434
+ */
3435
+ const validateFieldValue = (value, rules, fieldType) => {
3436
+ // No rules = valid
3437
+ if (!rules) {
3438
+ return null;
3439
+ }
3440
+ const stringValue = value !== null && value !== undefined ? String(value) : '';
3441
+ const isEmpty = value === null || value === undefined || value === '';
3442
+ // Skip validation if empty - required-ness is determined by requiredUserFields
3443
+ // (the merged field on RedemptionDTO), not by a validation rule.
3444
+ if (isEmpty) {
3445
+ return null;
3446
+ }
3447
+ // Type-specific validation
3448
+ switch (fieldType) {
3449
+ case 'text':
3450
+ if (rules.minLength && stringValue.length < rules.minLength) {
3451
+ return { field: '', message: `Minimum ${rules.minLength} characters required`, code: 'minLength' };
3452
+ }
3453
+ if (rules.maxLength && stringValue.length > rules.maxLength) {
3454
+ return { field: '', message: `Maximum ${rules.maxLength} characters allowed`, code: 'maxLength' };
3455
+ }
3456
+ if (rules.pattern && !new RegExp(rules.pattern).test(stringValue)) {
3457
+ return { field: '', message: rules.patternMessage || 'Invalid format', code: 'pattern' };
3458
+ }
3459
+ break;
3460
+ case 'email':
3461
+ if (stringValue.length > EMAIL_MAX_LENGTH || !EMAIL_REGEX.test(stringValue)) {
3462
+ return { field: '', message: 'Invalid email format', code: 'email' };
3463
+ }
3464
+ if (rules.maxLength && stringValue.length > rules.maxLength) {
3465
+ return { field: '', message: `Maximum ${rules.maxLength} characters allowed`, code: 'maxLength' };
3466
+ }
3467
+ break;
3468
+ case 'url':
3469
+ if (!URL_REGEX.test(stringValue)) {
3470
+ return { field: '', message: 'Invalid URL format', code: 'url' };
3471
+ }
3472
+ if (rules.maxLength && stringValue.length > rules.maxLength) {
3473
+ return { field: '', message: `Maximum ${rules.maxLength} characters allowed`, code: 'maxLength' };
3474
+ }
3475
+ break;
3476
+ case 'number':
3477
+ const numValue = typeof value === 'number' ? value : parseFloat(stringValue);
3478
+ if (isNaN(numValue)) {
3479
+ return { field: '', message: 'Must be a valid number', code: 'type' };
3480
+ }
3481
+ if (rules.min !== undefined && numValue < rules.min) {
3482
+ return { field: '', message: `Minimum value is ${rules.min}`, code: 'min' };
3483
+ }
3484
+ if (rules.max !== undefined && numValue > rules.max) {
3485
+ return { field: '', message: `Maximum value is ${rules.max}`, code: 'max' };
3486
+ }
3487
+ break;
3488
+ case 'boolean':
3489
+ if (typeof value !== 'boolean' && stringValue !== 'true' && stringValue !== 'false') {
3490
+ return { field: '', message: 'Must be true or false', code: 'type' };
3491
+ }
3492
+ break;
3493
+ case 'date':
3494
+ const dateValue = new Date(stringValue);
3495
+ if (isNaN(dateValue.getTime())) {
3496
+ return { field: '', message: 'Must be a valid date', code: 'type' };
3497
+ }
3498
+ const now = new Date();
3499
+ if (rules.minDate) {
3500
+ const minDate = rules.minDate === 'now' ? now : new Date(rules.minDate);
3501
+ if (dateValue < minDate) {
3502
+ return {
3503
+ field: '',
3504
+ message: rules.minDate === 'now' ? 'Date must be in the future' : `Date must be after ${rules.minDate}`,
3505
+ code: 'minDate'
3506
+ };
3507
+ }
3508
+ }
3509
+ if (rules.maxDate) {
3510
+ const maxDate = rules.maxDate === 'now' ? now : new Date(rules.maxDate);
3511
+ if (dateValue > maxDate) {
3512
+ return {
3513
+ field: '',
3514
+ message: rules.maxDate === 'now' ? 'Date must be in the past' : `Date must be before ${rules.maxDate}`,
3515
+ code: 'maxDate'
3516
+ };
3517
+ }
3518
+ }
3519
+ break;
3520
+ case 'objectArray':
3521
+ if (!Array.isArray(value)) {
3522
+ return { field: '', message: 'Must be an array', code: 'type' };
3523
+ }
3524
+ if (rules.minItems !== undefined && value.length < rules.minItems) {
3525
+ return { field: '', message: `Minimum ${rules.minItems} items required`, code: 'minItems' };
3526
+ }
3527
+ if (rules.maxItems !== undefined && value.length > rules.maxItems) {
3528
+ return { field: '', message: `Maximum ${rules.maxItems} items allowed`, code: 'maxItems' };
3529
+ }
3530
+ break;
3531
+ }
3532
+ return null;
3533
+ };
3534
+ // ============================================================================
3535
+ // Cross-Field / Literal Comparison Validation
3536
+ // ============================================================================
3537
+ /** Type guard for field comparison */
3538
+ const isFieldComparison = (c) => 'field' in c && c.field !== undefined;
3539
+ /**
3540
+ * Validate comparison rules for a single field.
3541
+ * Supports comparing to another field OR a literal value.
3542
+ */
3543
+ const validateFieldComparisons = (fieldKey, value, comparisons, allData, fieldType) => {
3544
+ if (!comparisons || comparisons.length === 0)
3545
+ return [];
3546
+ const errors = [];
3547
+ for (const comparison of comparisons) {
3548
+ // Skip if this field's value is empty
3549
+ if (value === null || value === undefined || value === '')
3550
+ continue;
3551
+ // Get the comparison target (field value or literal)
3552
+ const isField = isFieldComparison(comparison);
3553
+ const targetValue = isField ? allData[comparison.field] : comparison.value;
3554
+ const targetLabel = isField ? comparison.field : String(comparison.value);
3555
+ // Skip if comparing to field and that field is empty
3556
+ if (isField && (targetValue === null || targetValue === undefined || targetValue === ''))
3557
+ continue;
3558
+ let isValid = true;
3559
+ const { operator, message } = comparison;
3560
+ switch (fieldType) {
3561
+ case 'date': {
3562
+ const thisDate = new Date(String(value));
3563
+ const otherDate = new Date(String(targetValue));
3564
+ if (isNaN(thisDate.getTime()) || isNaN(otherDate.getTime()))
3565
+ continue;
3566
+ isValid = compareValues(thisDate.getTime(), otherDate.getTime(), operator);
3567
+ break;
3568
+ }
3569
+ case 'number': {
3570
+ const thisNum = typeof value === 'number' ? value : parseFloat(String(value));
3571
+ const otherNum = typeof targetValue === 'number' ? targetValue : parseFloat(String(targetValue));
3572
+ if (isNaN(thisNum) || isNaN(otherNum))
3573
+ continue;
3574
+ isValid = compareValues(thisNum, otherNum, operator);
3575
+ break;
3576
+ }
3577
+ case 'boolean': {
3578
+ const thisBool = typeof value === 'boolean' ? value : value === 'true';
3579
+ const otherBool = typeof targetValue === 'boolean' ? targetValue : targetValue === 'true';
3580
+ isValid = operator === '==' ? thisBool === otherBool :
3581
+ operator === '!=' ? thisBool !== otherBool : true;
3582
+ break;
3583
+ }
3584
+ default: {
3585
+ isValid = compareValues(String(value), String(targetValue), operator);
3586
+ break;
3587
+ }
3588
+ }
3589
+ if (!isValid) {
3590
+ errors.push({
3591
+ field: fieldKey,
3592
+ message: message || `Must be ${getOperatorText(operator)} ${targetLabel}`,
3593
+ code: `comparison:${operator}:${targetLabel}`,
3594
+ });
3595
+ }
3596
+ }
3597
+ return errors;
3598
+ };
3599
+ // ============================================================================
3600
+ // Object Array Item Validation
3601
+ // ============================================================================
3602
+ /**
3603
+ * Validate each item in an object array against the object schema.
3604
+ * Checks required properties, type validation, and validates each property.
3605
+ *
3606
+ * @param fieldKey - Parent field key (for error reporting)
3607
+ * @param items - Array of objects to validate
3608
+ * @param schema - Schema defining expected properties
3609
+ * @returns Array of validation errors with item index in field path (e.g., "contacts[0].phone")
3610
+ */
3611
+ const validateObjectArrayItems = (fieldKey, items, schema) => {
3612
+ const errors = [];
3613
+ if (!Array.isArray(items))
3614
+ return errors;
3615
+ items.forEach((item, index) => {
3616
+ if (typeof item !== 'object' || item === null) {
3617
+ errors.push({
3618
+ field: `${fieldKey}[${index}]`,
3619
+ code: 'type',
3620
+ message: 'Item must be an object',
3621
+ });
3622
+ return;
3623
+ }
3624
+ const obj = item;
3625
+ // Check each schema property
3626
+ for (const prop of schema) {
3627
+ const value = obj[prop.key];
3628
+ const propPath = `${fieldKey}[${index}].${prop.key}`;
3629
+ const isEmpty = value === null || value === undefined || value === '';
3630
+ // Required check for nested properties (ObjectFieldSchema.required)
3631
+ if (prop.required && isEmpty) {
3632
+ errors.push({
3633
+ field: propPath,
3634
+ code: 'required',
3635
+ message: `${prop.label} is required`,
3636
+ });
3637
+ continue;
3638
+ }
3639
+ if (isEmpty)
3640
+ continue;
3641
+ // Convert ObjectFieldSchema inline rules to FieldValidationRules
3642
+ const rules = {
3643
+ min: prop.min,
3644
+ max: prop.max,
3645
+ minLength: prop.minLength,
3646
+ maxLength: prop.maxLength,
3647
+ pattern: prop.pattern,
3648
+ patternMessage: prop.patternMessage,
3649
+ };
3650
+ // Validate the property value
3651
+ const error = validateFieldValue(value, rules, prop.fieldType);
3652
+ if (error) {
3653
+ errors.push({ ...error, field: propPath });
3654
+ }
3655
+ }
3656
+ // Cross-property comparisons within the same object item
3657
+ // e.g., checkOutDate > checkInDate within each reservation
3658
+ for (const prop of schema) {
3659
+ const value = obj[prop.key];
3660
+ if (value === null || value === undefined || value === '')
3661
+ continue;
3662
+ // ObjectFieldSchema doesn't currently have comparisons, but we could
3663
+ // validate date fields against 'now' using minDate/maxDate if we extend the schema
3664
+ }
3665
+ });
3666
+ return errors;
3667
+ };
3668
+ // ============================================================================
3669
+ // Full Data Validation
3670
+ // ============================================================================
3671
+ /**
3672
+ * Validate all fields in a data object against their definitions.
3673
+ * Runs both single-field and comparison validations.
3674
+ *
3675
+ * @param data - The data object to validate
3676
+ * @param definitions - Field definitions with validation rules
3677
+ * @returns Array of all validation errors
3678
+ */
3679
+ const validateAllFields = (data, definitions) => {
3680
+ const errors = [];
3681
+ const definitionMap = new Map(definitions.map(d => [d.key, d]));
3682
+ for (const [key, value] of Object.entries(data)) {
3683
+ const def = definitionMap.get(key);
3684
+ if (!def)
3685
+ continue;
3686
+ // Single-field validation
3687
+ const error = validateFieldValue(value, def.validation, def.fieldType);
3688
+ if (error) {
3689
+ errors.push({ ...error, field: key });
3690
+ }
3691
+ // Comparison validation
3692
+ if (def.validation?.comparisons) {
3693
+ const comparisonErrors = validateFieldComparisons(key, value, def.validation.comparisons, data, def.fieldType);
3694
+ errors.push(...comparisonErrors);
3695
+ }
3696
+ // Object array item validation (validate each item's properties against objectSchema)
3697
+ if (def.fieldType === 'objectArray' && def.objectSchema && Array.isArray(value)) {
3698
+ const itemErrors = validateObjectArrayItems(key, value, def.objectSchema);
3699
+ errors.push(...itemErrors);
3700
+ }
3701
+ }
3702
+ return errors;
3703
+ };
3704
+
3705
+ /**
3706
+ * Built-in user profile fields that can be required for redemptions.
3707
+ * These are a subset of UserModelField - excludes 'id' and 'identifierEmail'
3708
+ * since those are system-managed, not user-fillable profile fields.
3709
+ *
3710
+ * Used for:
3711
+ * - RedemptionType base required fields (RedemptionType.baseRequiredUserFields)
3712
+ * - Redemption specific required fields (Redemption.specificRequiredUserFields)
3713
+ * - User profile validation before redemption claims
3714
+ * - Form generation for missing field prompts
3715
+ */
3716
+ // Fields that can be required for user profile validation (excludes system fields)
3717
+ const builtInUserFieldsArray = [
3718
+ 'email',
3719
+ 'firstName',
3720
+ 'lastName',
3721
+ 'externalId',
3722
+ 'instagramAccountId',
3723
+ 'googleAccountName',
3724
+ ];
3725
+ // Export the array for iteration
3726
+ const builtInUserFields = [...builtInUserFieldsArray];
3727
+ /**
3728
+ * Static metadata mapping for all built-in user fields.
3729
+ * Used by validation services and UI form generation.
3730
+ */
3731
+ const builtInUserFieldMetadata = {
3732
+ email: {
3733
+ label: 'Email Address',
3734
+ description: 'User email address',
3735
+ fieldType: 'email',
3736
+ },
3737
+ firstName: {
3738
+ label: 'First Name',
3739
+ description: 'User first name',
3740
+ fieldType: 'text',
3741
+ },
3742
+ lastName: {
3743
+ label: 'Last Name',
3744
+ description: 'User last name',
3745
+ fieldType: 'text',
3746
+ },
3747
+ externalId: {
3748
+ label: 'External ID',
3749
+ description: 'External identifier from third-party system',
3750
+ fieldType: 'text',
3751
+ },
3752
+ instagramAccountId: {
3753
+ label: 'Instagram Account',
3754
+ description: 'Instagram account ID',
3755
+ fieldType: 'text',
3756
+ },
3757
+ googleAccountName: {
3758
+ label: 'Google Account',
3759
+ description: 'Google account name',
3760
+ fieldType: 'text',
3761
+ },
3762
+ };
3763
+ /**
3764
+ * Check if a string is a valid built-in user field
3765
+ */
3766
+ const isBuiltInUserField = (value) => {
3767
+ return builtInUserFields.includes(value);
3768
+ };
3769
+ const getBuiltInFieldDefinition = (field) => {
3770
+ if (!isBuiltInUserField(field)) {
3771
+ return null;
3772
+ }
3773
+ const metadata = builtInUserFieldMetadata[field];
3774
+ const sortOrder = builtInUserFields.indexOf(field);
3775
+ return {
3776
+ id: `builtin:${field}`,
3777
+ tenantId: null,
3778
+ entityType: 'user',
3779
+ key: field,
3780
+ label: metadata.label,
3781
+ description: metadata.description,
3782
+ fieldType: metadata.fieldType,
3783
+ objectSchema: null,
3784
+ selectOptions: null,
3785
+ selectOptionsSource: null,
3786
+ validation: null,
3787
+ sortOrder,
3788
+ createdAt: null,
3789
+ updatedAt: null,
3790
+ isBuiltIn: true,
3791
+ };
3792
+ };
3793
+ /**
3794
+ * Get all built-in field definitions as an array.
3795
+ * Useful for populating field definitions list.
3796
+ */
3797
+ const getAllBuiltInFieldDefinitions = () => {
3798
+ return builtInUserFields.map(field => getBuiltInFieldDefinition(field));
3799
+ };
3800
+ /**
3801
+ * Entity types for custom fields and dynamic select options.
3802
+ * Used for both field definitions (entityType) and select option sources.
3803
+ * - 'user': User profile fields (stored in user.customData)
3804
+ * - 'business': Business profile fields (stored in business.customData)
3805
+ * - 'campaign': Campaign-specific custom fields
3806
+ * - 'redemption': Redemption-specific custom fields
3807
+ */
3808
+ const customFieldEntityTypesArray = ['user', 'business', 'campaign', 'redemption'];
3809
+ /** All supported custom field entity types for iteration/validation */
3810
+ const customFieldEntityTypes = [...customFieldEntityTypesArray];
3811
+
3185
3812
  /**
3186
3813
  * Core Entity Types
3187
3814
  *
@@ -3344,119 +3971,17 @@ function isPingMessage(msg) {
3344
3971
  return msg.type === 'ping';
3345
3972
  }
3346
3973
 
3347
- const apiPublicKeyTestPrefix = 'pk_test_';
3348
- const testnetPrefix = 'testnet-';
3349
- const testnetShortPrefix = 'tn-';
3350
-
3351
- // Define the keys as a constant array
3352
- const uniqueUserIdentifierKeysArray = [
3353
- 'id',
3354
- 'email',
3355
- 'identifierEmail',
3356
- 'instagramAccountId',
3357
- 'externalId', // This is a special key that can be used to search all fields
3358
- ];
3359
- // Export the array
3360
- const uniqueUserIdentifierKeys = [...uniqueUserIdentifierKeysArray];
3361
- // Define identifier type configurations
3362
- const identifierTypes = {
3363
- id: {
3364
- key: 'id',
3365
- detect: (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value), // UUID pattern
3366
- process: (value) => value
3367
- },
3368
- email: {
3369
- key: 'email',
3370
- detect: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
3371
- process: (value) => value
3372
- },
3373
- instagramAccountId: {
3374
- key: 'instagramAccountId',
3375
- detect: (value) => /^@/.test(value) || value.toLowerCase().includes('instagram'),
3376
- process: (value) => value.replace(/^@/, '') // Remove @ if present for consistency
3377
- },
3378
- externalId: {
3379
- key: 'externalId',
3380
- detect: () => true, // Default fallback
3381
- process: (value) => value
3382
- }
3383
- };
3384
- /**
3385
- * Register a new identifier type configuration
3386
- * @param key The identifier key
3387
- * @param config The identifier configuration (without the key)
3388
- */
3389
- const registerIdentifierType = (key, config) => {
3390
- if (!uniqueUserIdentifierKeysArray.includes(key)) {
3391
- console.warn(`Warning: Registering identifier type '${key}' that is not in uniqueUserIdentifierKeys`);
3392
- }
3393
- identifierTypes[key] = {
3394
- key,
3395
- ...config
3396
- };
3397
- };
3398
3974
  /**
3399
- * Detects the type of identifier from a wildcard input
3400
- * @param wildcardValue The value to analyze
3401
- * @returns Object containing the type and processed value
3975
+ * Check if a string matches UUID format
3402
3976
  */
3403
- const detectIdentifierType = (wildcardValue) => {
3404
- if (wildcardValue === null || wildcardValue === undefined) {
3405
- return { type: 'externalId', value: '' };
3406
- }
3407
- const value = String(wildcardValue);
3408
- // Try each identifier type in priority order (id first to catch UUIDs)
3409
- for (const type of ['id', 'email', 'instagramAccountId', 'externalId']) {
3410
- const config = identifierTypes[type];
3411
- if (config.detect(value)) {
3412
- return { type, value: config.process(value) };
3413
- }
3414
- }
3415
- // Default fallback should never be reached due to externalId's detect always returning true
3416
- return { type: 'externalId', value };
3417
- };
3418
- /**
3419
- * Checks if an object is a properly formed UserIdentifierObject
3420
- * @param obj Object to check
3421
- * @returns Boolean indicating if the object is a UserIdentifierObject
3422
- */
3423
- const isUserIdentifierObject = (obj) => {
3424
- return obj &&
3425
- obj.type === 'identifierObject' &&
3426
- uniqueUserIdentifierKeys.every(key => key in obj);
3427
- };
3428
- /**
3429
- * Creates a UserIdentifierObject with all required fields, setting missing values to null
3430
- * @param params Partial parameters for the identifier object
3431
- * @returns A properly formed UserIdentifierObject
3432
- */
3433
- const createUserIdentifierObject = (params) => {
3434
- // Extract the wildcard or use empty string
3435
- const wildcard = params.wildcard || '';
3436
- // Create base object with all identifier fields set to null
3437
- const identifierObject = {
3438
- type: 'identifierObject',
3439
- id: null,
3440
- email: null,
3441
- instagramAccountId: null,
3442
- externalId: null,
3443
- identifierEmail: null,
3444
- wildcard
3445
- };
3446
- // Override nulls with any provided values
3447
- uniqueUserIdentifierKeys.forEach(key => {
3448
- if (params[key]) {
3449
- identifierObject[key] = params[key];
3450
- }
3451
- });
3452
- // If wildcard is provided but no specific fields, auto-detect the field type
3453
- if (wildcard && !params.id && !params.email && !params.instagramAccountId && !params.externalId) {
3454
- const detected = detectIdentifierType(wildcard);
3455
- identifierObject[detected.type] = detected.value;
3456
- }
3457
- return identifierObject;
3977
+ const isUUID = (value) => {
3978
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
3458
3979
  };
3459
3980
 
3981
+ const apiPublicKeyTestPrefix = 'pk_test_';
3982
+ const testnetPrefix = 'testnet-';
3983
+ const testnetShortPrefix = 'tn-';
3984
+
3460
3985
  // Use this pattern:
3461
3986
  const NativeTokenTypes = {
3462
3987
  ERC20: 'ERC20', // default; should be used for fungible tokens eg. credit tokens; There should be only one active ERC20 token per programme
@@ -3557,7 +4082,8 @@ function isValidTransactionRelation(value) {
3557
4082
  const VALID_CAMPAIGN_RELATIONS = [
3558
4083
  'triggerSources',
3559
4084
  'businesses',
3560
- 'claimCount'
4085
+ 'claimCount',
4086
+ 'ownerBusiness'
3561
4087
  ];
3562
4088
  /**
3563
4089
  * Type guard to validate campaign include relation at runtime
@@ -3595,7 +4121,9 @@ function isValidCampaignClaimRelation(value) {
3595
4121
  * Used for runtime validation
3596
4122
  */
3597
4123
  const VALID_REDEMPTION_RELATIONS = [
3598
- 'redeemCount'
4124
+ 'redeemCount',
4125
+ 'ownerBusiness',
4126
+ 'requiredUserFieldDefinitions'
3599
4127
  ];
3600
4128
  /**
3601
4129
  * Type guard to validate redemption include relation at runtime
@@ -3653,6 +4181,26 @@ function isValidUserRelation(value) {
3653
4181
  return VALID_USER_RELATIONS.includes(value);
3654
4182
  }
3655
4183
 
4184
+ /**
4185
+ * Array of all valid token metadata include relations
4186
+ * Used for runtime validation
4187
+ */
4188
+ const VALID_TOKEN_METADATA_RELATIONS = [
4189
+ 'ownerBusiness',
4190
+ 'mintCount',
4191
+ 'burnCount',
4192
+ 'token'
4193
+ ];
4194
+ /**
4195
+ * Type guard to validate token metadata include relation at runtime
4196
+ *
4197
+ * @param value - String to validate
4198
+ * @returns True if value is a valid TokenMetadataIncludeRelation
4199
+ */
4200
+ function isValidTokenMetadataRelation(value) {
4201
+ return VALID_TOKEN_METADATA_RELATIONS.includes(value);
4202
+ }
4203
+
3656
4204
  /**
3657
4205
  * Normalizes filter options to use new field names while accepting deprecated ones.
3658
4206
  * Use at controller level to support both old and new API contracts.
@@ -3673,6 +4221,134 @@ function normalizeFilters(filters) {
3673
4221
  };
3674
4222
  }
3675
4223
 
4224
+ /**
4225
+ * Analytics Constants
4226
+ *
4227
+ * Shared definitions for consistent analytics across frontend and backend.
4228
+ */
4229
+ /**
4230
+ * Active User Definition
4231
+ *
4232
+ * A user qualifies as "active" in a given period if they performed
4233
+ * any of the qualifying actions during that period.
4234
+ *
4235
+ * @example
4236
+ * // Display in UI
4237
+ * `${engagementRate}% engagement. ${ACTIVE_USER_DEFINITION.description}`
4238
+ *
4239
+ * // Result: "25.5% engagement. Active = sent transaction, claimed, or redeemed in period"
4240
+ */
4241
+ const ACTIVE_USER_DEFINITION = {
4242
+ /** Actions that qualify a user as "active" */
4243
+ qualifyingActions: ['TRANSACTION_SEND', 'CLAIM_COMPLETED', 'REDEMPTION_COMPLETED'],
4244
+ /** Short description for UI display */
4245
+ description: 'Active = sent transaction, claimed, or redeemed in period',
4246
+ /** Detailed explanation for tooltips/documentation */
4247
+ details: {
4248
+ TRANSACTION_SEND: 'User initiated a transaction (spending, transferring, or purchasing)',
4249
+ CLAIM_COMPLETED: 'User successfully claimed a campaign reward',
4250
+ REDEMPTION_COMPLETED: 'User successfully redeemed an offer',
4251
+ },
4252
+ /** What does NOT count as active */
4253
+ excluded: [
4254
+ 'Receiving tokens (passive)',
4255
+ 'Failed or pending claims/redemptions',
4256
+ 'System-generated transactions (REDEMPTION_RECEIVE)',
4257
+ ],
4258
+ };
4259
+ /**
4260
+ * Engagement rate formula
4261
+ * engagementRate = (activeUsers / totalUsers) × 100
4262
+ */
4263
+ const ENGAGEMENT_RATE_FORMULA = 'activeUsers / totalUsers × 100';
4264
+ /**
4265
+ * Transaction statuses that count for analytics
4266
+ */
4267
+ const ANALYTICS_VALID_TRANSACTION_STATUSES = ['SUCCEEDED', 'BROADCASTED'];
4268
+ /**
4269
+ * Process statuses that count for analytics (claims, redemptions)
4270
+ */
4271
+ const ANALYTICS_VALID_PROCESS_STATUS = 'COMPLETED';
4272
+
4273
+ /**
4274
+ * Image CDN Configuration
4275
+ *
4276
+ * Shared constants and types for Image CDN (CloudFront + Lambda@Edge)
4277
+ * Used by backend Lambda handler and frontend SDKs.
4278
+ *
4279
+ * @see documentation/api-docs/image-cdn.md
4280
+ */
4281
+ // ============================================
4282
+ // CONSTANTS
4283
+ // ============================================
4284
+ /** Maximum output dimension when no preset specified */
4285
+ const IMAGE_CDN_MAX_DIMENSION = 2048;
4286
+ /** Default quality percentage */
4287
+ const IMAGE_CDN_DEFAULT_QUALITY = 80;
4288
+ /** Default fit mode */
4289
+ const IMAGE_CDN_DEFAULT_FIT = 'inside';
4290
+ /** Default format (auto-detect based on browser Accept header) */
4291
+ const IMAGE_CDN_DEFAULT_FORMAT = 'auto';
4292
+ /** Available presets with their dimensions */
4293
+ const IMAGE_CDN_PRESETS = {
4294
+ thumb: { width: 150, height: 150 },
4295
+ medium: { width: 600, height: 600 },
4296
+ large: { width: 1200, height: 1200 },
4297
+ hero: { width: 1920, height: 800 },
4298
+ avatar: { width: 200, height: 200 },
4299
+ card: { width: 400, height: 300 },
4300
+ };
4301
+ /** Supported input/output formats */
4302
+ const IMAGE_CDN_SUPPORTED_FORMATS = ['jpeg', 'jpg', 'png', 'webp', 'gif', 'avif'];
4303
+ /** Cache TTL in seconds (1 year) */
4304
+ const IMAGE_CDN_CACHE_TTL = 31536000;
4305
+ /** Maximum input file size in bytes (10MB) */
4306
+ const IMAGE_CDN_MAX_INPUT_SIZE = 10 * 1024 * 1024;
4307
+ // ============================================
4308
+ // HELPER FUNCTIONS
4309
+ // ============================================
4310
+ /**
4311
+ * Build CDN URL with options
4312
+ *
4313
+ * @example
4314
+ * buildImageCdnUrl('https://cdn.example.com/photo.jpg', { preset: 'thumb' })
4315
+ * // => 'https://cdn.example.com/photo.jpg?preset=thumb'
4316
+ *
4317
+ * buildImageCdnUrl('https://cdn.example.com/photo.jpg', { preset: 'large', q: 95 })
4318
+ * // => 'https://cdn.example.com/photo.jpg?preset=large&q=95'
4319
+ */
4320
+ function buildImageCdnUrl(baseUrl, options = {}) {
4321
+ const params = new URLSearchParams();
4322
+ if (options.preset)
4323
+ params.set('preset', options.preset);
4324
+ if (options.q !== undefined)
4325
+ params.set('q', options.q.toString());
4326
+ if (options.f)
4327
+ params.set('f', options.f);
4328
+ if (options.fit)
4329
+ params.set('fit', options.fit);
4330
+ const query = params.toString();
4331
+ return query ? `${baseUrl}?${query}` : baseUrl;
4332
+ }
4333
+ /**
4334
+ * Check if a preset name is valid
4335
+ */
4336
+ function isValidPreset(preset) {
4337
+ return preset in IMAGE_CDN_PRESETS;
4338
+ }
4339
+ /**
4340
+ * Get dimensions for a preset
4341
+ */
4342
+ function getPresetDimensions(preset) {
4343
+ return IMAGE_CDN_PRESETS[preset];
4344
+ }
4345
+ /**
4346
+ * Check if a file extension is a supported image format
4347
+ */
4348
+ function isSupportedImageFormat(extension) {
4349
+ return IMAGE_CDN_SUPPORTED_FORMATS.includes(extension.toLowerCase());
4350
+ }
4351
+
3676
4352
  /**
3677
4353
  * Chain Data Types
3678
4354
  *
@@ -3807,6 +4483,12 @@ const CommonErrorCodes = {
3807
4483
  FIREBASE_AUTH_ERROR: 'FIREBASE_AUTH_ERROR',
3808
4484
  MULTIPLE_CONTEXT_SELECTION_REQUIRED: 'MULTIPLE_CONTEXT_SELECTION_REQUIRED',
3809
4485
  ADMIN_TENANT_REQUIRED: 'ADMIN_TENANT_REQUIRED',
4486
+ // Magic link errors
4487
+ MAGIC_LINK_EMAIL_SEND_FAILED: 'MAGIC_LINK_EMAIL_SEND_FAILED',
4488
+ MAGIC_LINK_EMAIL_MISMATCH: 'MAGIC_LINK_EMAIL_MISMATCH',
4489
+ MAGIC_LINK_VERIFICATION_FAILED: 'MAGIC_LINK_VERIFICATION_FAILED',
4490
+ // Rate limiting (generic)
4491
+ RATE_LIMITED: 'RATE_LIMITED',
3810
4492
  // Generic fallbacks
3811
4493
  INTERNAL_ERROR: 'INTERNAL_ERROR',
3812
4494
  VALIDATION_ERROR: 'VALIDATION_ERROR',
@@ -5166,30 +5848,30 @@ class TokenApi {
5166
5848
  return this.apiClient.get(url);
5167
5849
  }
5168
5850
  /**
5169
- * Get all token metadata with filtering and pagination
5851
+ * Get all token metadata with filtering, pagination, and include relations
5170
5852
  *
5171
5853
  * Useful for displaying rewards (ERC1155) or stamps (ERC721) in tables.
5172
5854
  * Non-admin users always get active metadata only.
5173
5855
  *
5174
- * @param options - Filter and pagination options
5856
+ * @param options - Filter, pagination, and include options
5175
5857
  * @returns Paginated response with token metadata
5176
5858
  *
5177
- * @example Get all active rewards
5859
+ * @example Get all active rewards with mint/burn counts
5178
5860
  * ```typescript
5179
5861
  * const rewards = await tokenApi.getTokenMetadata({
5180
5862
  * tokenType: 'ERC1155',
5181
5863
  * active: true,
5182
- * limit: 10
5864
+ * limit: 10,
5865
+ * include: ['mintCount', 'burnCount', 'token']
5183
5866
  * });
5184
5867
  * ```
5185
5868
  *
5186
- * @example Get stamps with search
5869
+ * @example Get stamps with owner business included
5187
5870
  * ```typescript
5188
5871
  * const stamps = await tokenApi.getTokenMetadata({
5189
5872
  * tokenType: 'ERC721',
5190
5873
  * search: 'gold',
5191
- * sortBy: 'name',
5192
- * sortOrder: 'ASC'
5874
+ * include: ['ownerBusiness', 'token']
5193
5875
  * });
5194
5876
  * ```
5195
5877
  */
@@ -5214,6 +5896,14 @@ class TokenApi {
5214
5896
  params.set('tags', tagsArray.join(','));
5215
5897
  }
5216
5898
  }
5899
+ // Filter by owner business
5900
+ if (options?.ownerBusinessId) {
5901
+ params.set('ownerBusinessId', options.ownerBusinessId);
5902
+ }
5903
+ // Include relations (ownerBusiness, mintCount, burnCount, token)
5904
+ if (options?.include && options.include.length > 0) {
5905
+ params.set('include', options.include.join(','));
5906
+ }
5217
5907
  const response = await this.apiClient.get(`${this.basePath}/metadata?${params.toString()}`);
5218
5908
  return normalizeToPaginated(response);
5219
5909
  }
@@ -5273,6 +5963,30 @@ class TokenApi {
5273
5963
  async createTokenType(tokenType) {
5274
5964
  return this.apiClient.post(`${this.basePath}/types`, tokenType);
5275
5965
  }
5966
+ /**
5967
+ * ADMIN: Delete token metadata (soft delete)
5968
+ *
5969
+ * Soft deletes token metadata by setting deletedAt timestamp.
5970
+ * Will fail with 409 Conflict if metadata is in use by active campaigns or redemptions.
5971
+ *
5972
+ * @param metadataId - ID of the token metadata to delete
5973
+ * @throws {PersApiError} 409 Conflict if metadata is in use
5974
+ *
5975
+ * @example
5976
+ * ```typescript
5977
+ * try {
5978
+ * await tokenApi.deleteTokenMetadata('metadata-123');
5979
+ * console.log('Metadata deleted');
5980
+ * } catch (error) {
5981
+ * if (error.statusCode === 409) {
5982
+ * console.log('Cannot delete: metadata is in use');
5983
+ * }
5984
+ * }
5985
+ * ```
5986
+ */
5987
+ async deleteTokenMetadata(metadataId) {
5988
+ return this.apiClient.delete(`${this.basePath}/metadata/${metadataId}`);
5989
+ }
5276
5990
  }
5277
5991
 
5278
5992
  /**
@@ -5327,35 +6041,57 @@ class TokenService {
5327
6041
  return this.tokenApi.getTokenByContractAddress(contractAddress, contractTokenId);
5328
6042
  }
5329
6043
  /**
5330
- * Get all token metadata with filtering and pagination
6044
+ * Get all token metadata with filtering, pagination, and include relations
5331
6045
  *
5332
6046
  * Useful for displaying rewards (ERC1155) or stamps (ERC721) in tables.
5333
6047
  * Non-admin users always get active metadata only.
5334
6048
  *
5335
- * @param options - Filter and pagination options
6049
+ * @param options - Filter, pagination, and include options
5336
6050
  * @returns Paginated response with token metadata
6051
+ *
6052
+ * @example Get rewards with counts
6053
+ * ```typescript
6054
+ * const rewards = await tokenService.getTokenMetadata({
6055
+ * tokenType: 'ERC1155',
6056
+ * include: ['mintCount', 'burnCount', 'token']
6057
+ * });
6058
+ * ```
5337
6059
  */
5338
6060
  async getTokenMetadata(options) {
5339
6061
  return this.tokenApi.getTokenMetadata(options);
5340
6062
  }
5341
6063
  /**
5342
- * Get rewards (ERC1155 token metadata) with filtering and pagination
6064
+ * Get rewards (ERC1155 token metadata) with filtering, pagination, and include relations
5343
6065
  *
5344
6066
  * Convenience method for fetching reward metadata.
5345
6067
  *
5346
- * @param options - Filter and pagination options (tokenType is set automatically)
6068
+ * @param options - Filter, pagination, and include options (tokenType is set automatically)
5347
6069
  * @returns Paginated response with reward metadata
6070
+ *
6071
+ * @example Get rewards with mint/burn counts and token info
6072
+ * ```typescript
6073
+ * const rewards = await tokenService.getRewards({
6074
+ * include: ['mintCount', 'burnCount', 'token', 'ownerBusiness']
6075
+ * });
6076
+ * ```
5348
6077
  */
5349
6078
  async getRewards(options) {
5350
6079
  return this.tokenApi.getTokenMetadata({ ...options, tokenType: NativeTokenTypes.ERC1155 });
5351
6080
  }
5352
6081
  /**
5353
- * Get stamps (ERC721 token metadata) with filtering and pagination
6082
+ * Get stamps (ERC721 token metadata) with filtering, pagination, and include relations
5354
6083
  *
5355
6084
  * Convenience method for fetching stamp metadata.
5356
6085
  *
5357
- * @param options - Filter and pagination options (tokenType is set automatically)
6086
+ * @param options - Filter, pagination, and include options (tokenType is set automatically)
5358
6087
  * @returns Paginated response with stamp metadata
6088
+ *
6089
+ * @example Get stamps with mint/burn counts and token info
6090
+ * ```typescript
6091
+ * const stamps = await tokenService.getStamps({
6092
+ * include: ['mintCount', 'burnCount', 'token', 'ownerBusiness']
6093
+ * });
6094
+ * ```
5359
6095
  */
5360
6096
  async getStamps(options) {
5361
6097
  return this.tokenApi.getTokenMetadata({ ...options, tokenType: NativeTokenTypes.ERC721 });
@@ -5412,6 +6148,17 @@ class TokenService {
5412
6148
  async createTokenType(tokenType) {
5413
6149
  return this.tokenApi.createTokenType(tokenType);
5414
6150
  }
6151
+ /**
6152
+ * ADMIN: Delete token metadata (soft delete)
6153
+ *
6154
+ * Soft deletes token metadata. Will fail if metadata is in use.
6155
+ *
6156
+ * @param metadataId - ID of the token metadata to delete
6157
+ * @throws {PersApiError} 409 Conflict if metadata is in use by campaigns/redemptions
6158
+ */
6159
+ async deleteTokenMetadata(metadataId) {
6160
+ return this.tokenApi.deleteTokenMetadata(metadataId);
6161
+ }
5415
6162
  }
5416
6163
 
5417
6164
  /**
@@ -5514,6 +6261,10 @@ class BusinessApi {
5514
6261
  *
5515
6262
  * // Filter by business type
5516
6263
  * const hotels = await businessApi.getBusinesses({ businessTypeId: 1 });
6264
+ *
6265
+ * // Filter by location (geocoding)
6266
+ * const dohaBusinesses = await businessApi.getBusinesses({ city: 'Doha', countryCode: 'QA' });
6267
+ * const westBay = await businessApi.getBusinesses({ neighborhood: 'West Bay' });
5517
6268
  * ```
5518
6269
  */
5519
6270
  async getBusinesses(options) {
@@ -5536,6 +6287,22 @@ class BusinessApi {
5536
6287
  if (options?.businessTypeId !== undefined) {
5537
6288
  params.set('businessTypeId', String(options.businessTypeId));
5538
6289
  }
6290
+ // Location filter options (geocoding)
6291
+ if (options?.city) {
6292
+ params.set('city', options.city);
6293
+ }
6294
+ if (options?.countryCode) {
6295
+ params.set('countryCode', options.countryCode);
6296
+ }
6297
+ if (options?.country) {
6298
+ params.set('country', options.country);
6299
+ }
6300
+ if (options?.neighborhood) {
6301
+ params.set('neighborhood', options.neighborhood);
6302
+ }
6303
+ if (options?.district) {
6304
+ params.set('district', options.district);
6305
+ }
5539
6306
  const response = await this.apiClient.get(`${this.basePath}?${params.toString()}`);
5540
6307
  return normalizeToPaginated(response);
5541
6308
  }
@@ -7237,6 +8004,12 @@ class RedemptionService {
7237
8004
  async updateRedemptionType(data) {
7238
8005
  return this.redemptionApi.updateRedemptionType(data);
7239
8006
  }
8007
+ /**
8008
+ * ADMIN: Delete redemption
8009
+ */
8010
+ async deleteRedemption(id) {
8011
+ return this.redemptionApi.deleteRedemption(id);
8012
+ }
7240
8013
  /**
7241
8014
  * ADMIN: Delete redemption type
7242
8015
  */
@@ -9172,7 +9945,7 @@ class TriggerSourceApi {
9172
9945
  /**
9173
9946
  * PUBLIC: Get trigger sources with optional filters and pagination
9174
9947
  *
9175
- * @param options - Filter and pagination options
9948
+ * @param options - Filter and pagination options (includes location filters)
9176
9949
  * @returns Paginated list of trigger sources
9177
9950
  *
9178
9951
  * @example
@@ -9186,6 +9959,12 @@ class TriggerSourceApi {
9186
9959
  * page: 1,
9187
9960
  * limit: 20
9188
9961
  * });
9962
+ *
9963
+ * // Filter by location (geocoding)
9964
+ * const dubaiSources = await triggerSourceApi.getTriggerSources({
9965
+ * city: 'Dubai',
9966
+ * countryCode: 'AE'
9967
+ * });
9189
9968
  * ```
9190
9969
  */
9191
9970
  async getTriggerSources(options) {
@@ -9198,6 +9977,19 @@ class TriggerSourceApi {
9198
9977
  params.set('campaignId', options.campaignId);
9199
9978
  if (options?.active !== undefined)
9200
9979
  params.set('active', String(options.active));
9980
+ if (options?.includeDeleted)
9981
+ params.set('includeDeleted', String(options.includeDeleted));
9982
+ // Location filter options (geocoding)
9983
+ if (options?.city)
9984
+ params.set('city', options.city);
9985
+ if (options?.countryCode)
9986
+ params.set('countryCode', options.countryCode);
9987
+ if (options?.country)
9988
+ params.set('country', options.country);
9989
+ if (options?.neighborhood)
9990
+ params.set('neighborhood', options.neighborhood);
9991
+ if (options?.district)
9992
+ params.set('district', options.district);
9201
9993
  const response = await this.apiClient.get(`/trigger-sources?${params.toString()}`);
9202
9994
  return normalizeToPaginated(response);
9203
9995
  }
@@ -9782,6 +10574,9 @@ class AuthService {
9782
10574
  * Extracts context (tenantId/businessId) from the current refresh token to ensure
9783
10575
  * the recovered session maintains the same context.
9784
10576
  *
10577
+ * This is a PUBLIC method that can be called by TokenRefreshManager when internal
10578
+ * refresh fails, to try external (provider token) recovery before giving up.
10579
+ *
9785
10580
  * @returns true if session was successfully recovered
9786
10581
  */
9787
10582
  async attemptProviderTokenRecovery() {
@@ -9934,6 +10729,8 @@ class TokenRefreshManager {
9934
10729
  // Retry configuration for transient failures
9935
10730
  this.MAX_RETRY_ATTEMPTS = 3;
9936
10731
  this.RETRY_DELAY_MS = 1000;
10732
+ // Active refresh promise for deduplication
10733
+ this.activeRefreshPromise = null;
9937
10734
  }
9938
10735
  /**
9939
10736
  * Ensures the access token is valid, refreshing if needed.
@@ -10034,11 +10831,27 @@ class TokenRefreshManager {
10034
10831
  return new Promise(resolve => setTimeout(resolve, ms));
10035
10832
  }
10036
10833
  /**
10037
- * @deprecated Use ensureValidToken() instead
10834
+ * Attempt token refresh: internal first, then provider token fallback.
10835
+ * DEDUPLICATES concurrent calls - all callers share the same promise.
10038
10836
  */
10039
10837
  async attemptInternalRefresh() {
10040
- const result = await this.attemptRefreshWithRetry();
10041
- return result.success;
10838
+ if (this.activeRefreshPromise) {
10839
+ return this.activeRefreshPromise;
10840
+ }
10841
+ this.activeRefreshPromise = (async () => {
10842
+ const result = await this.attemptRefreshWithRetry();
10843
+ if (result.success)
10844
+ return result;
10845
+ // Internal failed - try provider token recovery
10846
+ const recovered = await this.authService.attemptProviderTokenRecovery();
10847
+ return recovered ? { success: true, retryable: false } : result;
10848
+ })();
10849
+ try {
10850
+ return await this.activeRefreshPromise;
10851
+ }
10852
+ finally {
10853
+ this.activeRefreshPromise = null;
10854
+ }
10042
10855
  }
10043
10856
  }
10044
10857
 
@@ -10844,7 +11657,7 @@ class DefaultAuthProvider {
10844
11657
  /** SDK package name */
10845
11658
  const SDK_NAME = "@explorins/pers-sdk";
10846
11659
  /** SDK version - injected from package.json at build time */
10847
- const SDK_VERSION = "2.1.37";
11660
+ const SDK_VERSION = "2.2.0-alpha.1";
10848
11661
  /** Full SDK identifier for headers */
10849
11662
  const SDK_USER_AGENT = `${SDK_NAME}/${SDK_VERSION}`;
10850
11663
 
@@ -10954,12 +11767,16 @@ class PersApiClient {
10954
11767
  }
10955
11768
  const { retryCount = 0, responseType = 'json', bypassAuth = false } = options || {};
10956
11769
  const url = `${this.apiRoot}${endpoint}`;
10957
- // SMART TOKEN VALIDATION: Only check if we suspect the token might be expired
11770
+ // TOKEN VALIDATION: Ensure token is valid before request
11771
+ // This blocks the request until token validation completes - prevents 401s from expired tokens
10958
11772
  if (!bypassAuth && this.mergedConfig.authProvider && retryCount === 0) {
10959
- // Fire-and-forget validation - don't block the request unless we know there's an issue
10960
- this.ensureValidToken().catch(error => {
10961
- console.debug('[PersApiClient] Background token validation failed:', error);
10962
- });
11773
+ try {
11774
+ await this.ensureValidToken();
11775
+ }
11776
+ catch (error) {
11777
+ console.debug('[PersApiClient] Token validation failed:', error);
11778
+ // Continue with request - it will fail with 401 and trigger retry logic
11779
+ }
10963
11780
  }
10964
11781
  const requestOptions = {
10965
11782
  headers: await this.getHeaders(!bypassAuth, method, url),
@@ -10987,49 +11804,63 @@ class PersApiClient {
10987
11804
  return result;
10988
11805
  }
10989
11806
  catch (error) {
10990
- // Error handling - proactive token refresh should prevent most 401s
10991
11807
  const status = ErrorUtils.getStatus(error);
10992
11808
  const errorMessage = ErrorUtils.getMessage(error);
10993
- // Handle 401 errors centrally through AuthService
11809
+ // Handle 401 Unauthorized errors
10994
11810
  if (status === 401) {
10995
11811
  const backendError = ErrorUtils.extractBackendErrorDetails(error);
10996
- // Check for FATAL auth error CODES (not just 401 status)
10997
- // Fatal codes: REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, etc. → immediate logout
10998
- // Non-fatal codes: TOKEN_EXPIRED → try refresh first
11812
+ // ===========================================
11813
+ // FATAL AUTH ERRORS → Immediate logout
11814
+ // ===========================================
11815
+ // Fatal codes: REFRESH_TOKEN_EXPIRED, TOKEN_REVOKED, INVALID_REFRESH_TOKEN, etc.
10999
11816
  if (this.authService.isFatalAuthError(backendError.code)) {
11000
- // Definitive auth failure - no retry, session is invalid
11817
+ console.warn(`[PersApiClient] Fatal auth error: ${backendError.code}`);
11001
11818
  await this.authService.handleAuthFailure();
11002
11819
  this.emitErrorEvent(backendError, errorMessage, endpoint, method, status, error);
11003
11820
  throw new AuthenticationError(errorMessage, endpoint, method, backendError);
11004
11821
  }
11005
- // If this IS a refresh request that failed with 401, don't retry to avoid infinite loop
11822
+ // ===========================================
11823
+ // REFRESH REQUEST FAILED → Logout (prevents infinite loop)
11824
+ // ===========================================
11006
11825
  if (options?.isRefreshRequest) {
11007
- // Refresh failed - this is a real auth failure
11826
+ console.warn('[PersApiClient] Refresh request itself failed with 401');
11008
11827
  await this.authService.handleAuthFailure();
11009
11828
  this.emitErrorEvent(backendError, errorMessage, endpoint, method, status, error);
11010
11829
  throw new AuthenticationError(errorMessage, endpoint, method, backendError);
11011
11830
  }
11012
- // TOKEN_EXPIRED or unknown 401: try refresh once, then retry
11831
+ // ===========================================
11832
+ // RETRYABLE 401 (TOKEN_EXPIRED) → Try refresh, then retry
11833
+ // ===========================================
11013
11834
  if (retryCount === 0 && this.mergedConfig.authProvider) {
11014
- try {
11015
- const refreshSuccessful = await this.refreshManager.attemptInternalRefresh();
11016
- if (refreshSuccessful) {
11017
- // Emit TOKEN_REFRESHED event so WS clients can retry if they gave up
11018
- this._events?.emitSuccess({
11019
- type: 'token_refreshed',
11020
- domain: 'authentication',
11021
- userMessage: 'Authentication token refreshed',
11022
- });
11023
- // Retry with new token
11024
- return await this.request(method, endpoint, body, { ...options, retryCount: 1 });
11025
- }
11835
+ const refreshResult = await this.refreshManager.attemptInternalRefresh();
11836
+ if (refreshResult.success) {
11837
+ // Refresh succeeded → retry request with new token
11838
+ this._events?.emitSuccess({
11839
+ type: 'token_refreshed',
11840
+ domain: 'authentication',
11841
+ userMessage: 'Authentication token refreshed',
11842
+ });
11843
+ return await this.request(method, endpoint, body, { ...options, retryCount: 1 });
11026
11844
  }
11027
- catch (refreshError) {
11028
- // Refresh failed - fall through to handleAuthFailure
11845
+ // Refresh failed - check if it's a definitive failure
11846
+ if (!refreshResult.retryable) {
11847
+ // Non-retryable failure (e.g., refresh token expired) → logout
11848
+ console.warn('[PersApiClient] Refresh failed with non-retryable error, logging out');
11849
+ await this.authService.handleAuthFailure();
11029
11850
  }
11851
+ else {
11852
+ // Retryable failure (network error, etc.) → DON'T logout, let app retry later
11853
+ console.warn('[PersApiClient] Refresh failed with retryable error, NOT logging out');
11854
+ }
11855
+ this.emitErrorEvent(backendError, errorMessage, endpoint, method, status, error);
11856
+ throw new AuthenticationError(errorMessage, endpoint, method, backendError);
11030
11857
  }
11031
- // All recovery attempts failed
11032
- await this.authService.handleAuthFailure();
11858
+ // ===========================================
11859
+ // RETRY FAILED (retryCount > 0) → Something wrong, but DON'T logout
11860
+ // ===========================================
11861
+ // We already refreshed successfully, but retry still got 401
11862
+ // This is unexpected - don't logout, let app investigate
11863
+ console.warn('[PersApiClient] Retry with new token still got 401 - not logging out');
11033
11864
  this.emitErrorEvent(backendError, errorMessage, endpoint, method, status, error);
11034
11865
  throw new AuthenticationError(errorMessage, endpoint, method, backendError);
11035
11866
  }
@@ -12542,10 +13373,11 @@ class UserStatusManager {
12542
13373
  * ```
12543
13374
  */
12544
13375
  class TokenManager {
12545
- constructor(apiClient) {
13376
+ constructor(apiClient, eventEmitter) {
12546
13377
  this.apiClient = apiClient;
12547
13378
  const tokenApi = new TokenApi(apiClient);
12548
13379
  this.tokenService = new TokenService(tokenApi);
13380
+ this.eventEmitter = eventEmitter;
12549
13381
  }
12550
13382
  /**
12551
13383
  * Get all available tokens
@@ -12694,16 +13526,17 @@ class TokenManager {
12694
13526
  return this.tokenService.getTokenByContractAddress(contractAddress, contractTokenId);
12695
13527
  }
12696
13528
  /**
12697
- * Get all token metadata with filtering and pagination
13529
+ * Get all token metadata with filtering, pagination, and include relations
12698
13530
  *
12699
- * Retrieves token metadata (rewards/stamps) with server-side filtering and pagination.
13531
+ * Retrieves token metadata (rewards/stamps) with server-side filtering, pagination,
13532
+ * and optional include relations for additional data.
12700
13533
  * Useful for displaying rewards (ERC1155) or stamps (ERC721) in admin tables.
12701
13534
  * Non-admin users always get active metadata only.
12702
13535
  *
12703
- * @param options - Filter and pagination options
13536
+ * @param options - Filter, pagination, and include options
12704
13537
  * @returns Promise resolving to paginated token metadata
12705
13538
  *
12706
- * @example Get paginated rewards
13539
+ * @example Get paginated rewards with counts
12707
13540
  * ```typescript
12708
13541
  * const rewards = await sdk.tokens.getTokenMetadata({
12709
13542
  * tokenType: 'ERC1155',
@@ -12711,19 +13544,26 @@ class TokenManager {
12711
13544
  * page: 1,
12712
13545
  * limit: 10,
12713
13546
  * sortBy: 'name',
12714
- * sortOrder: 'ASC'
13547
+ * sortOrder: 'ASC',
13548
+ * include: ['mintCount', 'burnCount', 'token']
12715
13549
  * });
12716
13550
  *
12717
- * console.log(`Page 1 of ${rewards.pagination.pages}`);
12718
- * rewards.data.forEach(r => console.log(r.name));
13551
+ * rewards.data.forEach(r => {
13552
+ * console.log(`${r.name}: ${r.mintCount} minted, ${r.burnCount} burned`);
13553
+ * console.log(`Contract: ${r.included?.token?.contractAddress}`);
13554
+ * });
12719
13555
  * ```
12720
13556
  *
12721
- * @example Get stamps with search
13557
+ * @example Get stamps with owner business
12722
13558
  * ```typescript
12723
13559
  * const stamps = await sdk.tokens.getTokenMetadata({
12724
13560
  * tokenType: 'ERC721',
12725
13561
  * search: 'gold',
12726
- * limit: 25
13562
+ * include: ['ownerBusiness', 'token']
13563
+ * });
13564
+ *
13565
+ * stamps.data.forEach(s => {
13566
+ * console.log(`${s.name} owned by ${s.included?.ownerBusiness?.displayName}`);
12727
13567
  * });
12728
13568
  * ```
12729
13569
  */
@@ -12731,31 +13571,27 @@ class TokenManager {
12731
13571
  return this.tokenService.getTokenMetadata(options);
12732
13572
  }
12733
13573
  /**
12734
- * Get rewards (ERC1155 token metadata) with filtering and pagination
13574
+ * Get rewards (ERC1155 token metadata) with filtering, pagination, and include relations
12735
13575
  *
12736
13576
  * Convenience method for fetching reward metadata. Rewards are ERC1155 tokens
12737
13577
  * that represent collectible items, vouchers, or other redeemable rewards.
12738
13578
  *
12739
- * @param options - Filter and pagination options (tokenType is set automatically)
13579
+ * @param options - Filter, pagination, and include options (tokenType is set automatically)
12740
13580
  * @returns Promise resolving to paginated reward metadata
12741
13581
  *
12742
- * @example Get active rewards
13582
+ * @example Get active rewards with mint counts
12743
13583
  * ```typescript
12744
13584
  * const rewards = await sdk.tokens.getRewards({
12745
13585
  * active: true,
12746
- * limit: 10
13586
+ * limit: 10,
13587
+ * include: ['mintCount', 'burnCount', 'token', 'ownerBusiness']
12747
13588
  * });
12748
13589
  *
12749
- * console.log(`Found ${rewards.pagination.total} rewards`);
12750
- * rewards.data.forEach(r => console.log(`${r.name}: ${r.description}`));
12751
- * ```
12752
- *
12753
- * @example Search rewards
12754
- * ```typescript
12755
- * const discountRewards = await sdk.tokens.getRewards({
12756
- * search: 'discount',
12757
- * sortBy: 'name',
12758
- * sortOrder: 'ASC'
13590
+ * rewards.data.forEach(r => {
13591
+ * console.log(`${r.name}: ${r.mintCount ?? 0} minted`);
13592
+ * if (r.included?.token) {
13593
+ * console.log(` Chain: ${r.included.token.chainId}, Contract: ${r.included.token.contractAddress}`);
13594
+ * }
12759
13595
  * });
12760
13596
  * ```
12761
13597
  */
@@ -12763,31 +13599,27 @@ class TokenManager {
12763
13599
  return this.tokenService.getRewards(options);
12764
13600
  }
12765
13601
  /**
12766
- * Get stamps (ERC721 token metadata) with filtering and pagination
13602
+ * Get stamps (ERC721 token metadata) with filtering, pagination, and include relations
12767
13603
  *
12768
13604
  * Convenience method for fetching stamp metadata. Stamps are ERC721 tokens
12769
13605
  * that represent achievements, badges, or collectible status markers.
12770
13606
  *
12771
- * @param options - Filter and pagination options (tokenType is set automatically)
13607
+ * @param options - Filter, pagination, and include options (tokenType is set automatically)
12772
13608
  * @returns Promise resolving to paginated stamp metadata
12773
13609
  *
12774
- * @example Get active stamps
13610
+ * @example Get active stamps with all includes
12775
13611
  * ```typescript
12776
13612
  * const stamps = await sdk.tokens.getStamps({
12777
13613
  * active: true,
12778
- * limit: 10
13614
+ * limit: 10,
13615
+ * include: ['mintCount', 'burnCount', 'token', 'ownerBusiness']
12779
13616
  * });
12780
13617
  *
12781
- * console.log(`Found ${stamps.pagination.total} stamps`);
12782
- * stamps.data.forEach(s => console.log(`${s.name}: ${s.description}`));
12783
- * ```
12784
- *
12785
- * @example Filter stamps by tags
12786
- * ```typescript
12787
- * const goldStamps = await sdk.tokens.getStamps({
12788
- * tags: ['gold', 'premium'],
12789
- * sortBy: 'createdAt',
12790
- * sortOrder: 'DESC'
13618
+ * stamps.data.forEach(s => {
13619
+ * console.log(`${s.name}: ${s.mintCount ?? 0} minted`);
13620
+ * if (s.included?.ownerBusiness) {
13621
+ * console.log(` Owner: ${s.included.ownerBusiness.displayName}`);
13622
+ * }
12791
13623
  * });
12792
13624
  * ```
12793
13625
  */
@@ -12885,6 +13717,32 @@ class TokenManager {
12885
13717
  async toggleTokenActive(tokenId) {
12886
13718
  return this.tokenService.toggleTokenActive(tokenId);
12887
13719
  }
13720
+ /**
13721
+ * Admin: Delete token metadata (soft delete)
13722
+ *
13723
+ * Soft deletes token metadata by setting deletedAt timestamp.
13724
+ * Requires administrator privileges. Will fail with 409 Conflict
13725
+ * if metadata is currently in use by active campaigns or redemptions.
13726
+ *
13727
+ * @param metadataId - ID of the token metadata to delete
13728
+ * @throws {PersApiError} When not authenticated as admin
13729
+ * @throws {PersApiError} 409 Conflict if metadata is in use
13730
+ *
13731
+ * @example
13732
+ * ```typescript
13733
+ * try {
13734
+ * await sdk.tokens.deleteTokenMetadata('metadata-123');
13735
+ * console.log('Token metadata deleted');
13736
+ * } catch (error) {
13737
+ * if (error.statusCode === 409) {
13738
+ * console.log('Cannot delete: metadata is used by campaigns/redemptions');
13739
+ * }
13740
+ * }
13741
+ * ```
13742
+ */
13743
+ async deleteTokenMetadata(metadataId) {
13744
+ return this.tokenService.deleteTokenMetadata(metadataId);
13745
+ }
12888
13746
  /**
12889
13747
  * Get the full token SDK for advanced operations
12890
13748
  *
@@ -15278,6 +16136,21 @@ class RedemptionManager {
15278
16136
  async deleteRedemptionType(id) {
15279
16137
  return this.redemptionService.deleteRedemptionType(id);
15280
16138
  }
16139
+ /**
16140
+ * Delete a redemption (soft delete)
16141
+ *
16142
+ * @param id - Redemption ID to delete
16143
+ * @returns Promise resolving to true if deleted successfully
16144
+ *
16145
+ * @example
16146
+ * ```typescript
16147
+ * const success = await sdk.redemptions.deleteRedemption('redemption-123');
16148
+ * console.log('Deleted:', success);
16149
+ * ```
16150
+ */
16151
+ async deleteRedemption(id) {
16152
+ return this.redemptionService.deleteRedemption(id);
16153
+ }
15281
16154
  /**
15282
16155
  * Get the full redemption service for advanced operations
15283
16156
  *
@@ -19305,6 +20178,429 @@ class WalletEventsManager {
19305
20178
  }
19306
20179
  }
19307
20180
 
20181
+ /**
20182
+ * Custom Field Definitions API
20183
+ *
20184
+ * Low-level API for custom field definition CRUD operations.
20185
+ * Used internally by CustomFieldDefinitionManager.
20186
+ *
20187
+ * @internal
20188
+ */
20189
+ class CustomFieldApi {
20190
+ constructor(apiClient) {
20191
+ this.apiClient = apiClient;
20192
+ this.basePath = '/custom-field-definitions';
20193
+ }
20194
+ /**
20195
+ * List all custom field definitions
20196
+ * Backend returns non-paginated array sorted by sortOrder
20197
+ */
20198
+ async getDefinitions(entityType) {
20199
+ const params = new URLSearchParams();
20200
+ if (entityType) {
20201
+ params.append('entityType', entityType);
20202
+ }
20203
+ const query = params.toString();
20204
+ const url = query ? `${this.basePath}?${query}` : this.basePath;
20205
+ return this.apiClient.get(url);
20206
+ }
20207
+ /**
20208
+ * Get a single custom field definition by ID
20209
+ */
20210
+ async getDefinition(id) {
20211
+ return this.apiClient.get(`${this.basePath}/${id}`);
20212
+ }
20213
+ /**
20214
+ * Create a new custom field definition
20215
+ */
20216
+ async createDefinition(data) {
20217
+ return this.apiClient.post(this.basePath, data);
20218
+ }
20219
+ /**
20220
+ * Update an existing custom field definition
20221
+ */
20222
+ async updateDefinition(id, data) {
20223
+ return this.apiClient.put(`${this.basePath}/${id}`, data);
20224
+ }
20225
+ /**
20226
+ * Delete a custom field definition
20227
+ */
20228
+ async deleteDefinition(id) {
20229
+ return this.apiClient.delete(`${this.basePath}/${id}`);
20230
+ }
20231
+ /**
20232
+ * Get usage count for a field definition
20233
+ */
20234
+ async getUsages(id) {
20235
+ return this.apiClient.get(`${this.basePath}/${id}/usages`);
20236
+ }
20237
+ /**
20238
+ * Resolve dynamic select options for a field
20239
+ */
20240
+ async resolveSelectOptions(id) {
20241
+ return this.apiClient.get(`${this.basePath}/${id}/options`);
20242
+ }
20243
+ }
20244
+
20245
+ /**
20246
+ * Custom Field Definition Manager - Manage tenant-specific custom fields
20247
+ *
20248
+ * Provides CRUD operations for custom field definitions that extend the built-in
20249
+ * user profile fields. Custom fields are defined per-tenant and can be used for
20250
+ * additional user data collection, redemption requirements, and form validation.
20251
+ *
20252
+ * @group Managers
20253
+ * @category Custom Fields
20254
+ *
20255
+ * @example Basic Operations
20256
+ * ```typescript
20257
+ * // List all user custom fields
20258
+ * const fields = await sdk.customFields.getDefinitions();
20259
+ *
20260
+ * // Create a new custom field
20261
+ * const field = await sdk.customFields.createDefinition({
20262
+ * key: 'employee_id',
20263
+ * label: 'Employee ID',
20264
+ * fieldType: 'text',
20265
+ * validation: { required: true, pattern: '^E[0-9]{5}$' }
20266
+ * });
20267
+ *
20268
+ * // Update a field
20269
+ * await sdk.customFields.updateDefinition(field.id, {
20270
+ * label: 'Company Employee ID'
20271
+ * });
20272
+ *
20273
+ * // Delete a field
20274
+ * await sdk.customFields.deleteDefinition(field.id);
20275
+ * ```
20276
+ *
20277
+ * @example Validation
20278
+ * ```typescript
20279
+ * // Validate user custom data against definitions
20280
+ * const definitions = await sdk.customFields.getDefinitions();
20281
+ * const errors = sdk.customFields.validateUserData(
20282
+ * { employee_id: 'INVALID' },
20283
+ * definitions
20284
+ * );
20285
+ * if (errors.length > 0) {
20286
+ * console.log('Validation errors:', errors);
20287
+ * }
20288
+ * ```
20289
+ */
20290
+ class CustomFieldDefinitionManager {
20291
+ constructor(apiClient, events) {
20292
+ this.events = events;
20293
+ this.api = new CustomFieldApi(apiClient);
20294
+ }
20295
+ /**
20296
+ * List all custom field definitions for the tenant
20297
+ *
20298
+ * Retrieves all custom field definitions, optionally filtered by entity type.
20299
+ * Results are sorted by sortOrder (ascending).
20300
+ *
20301
+ * @param options - Query options including entity type filter
20302
+ * @returns Array of custom field definitions
20303
+ *
20304
+ * @example List All User Fields
20305
+ * ```typescript
20306
+ * const fields = await sdk.customFields.getDefinitions();
20307
+ * console.log(`Found ${fields.length} custom fields`);
20308
+ *
20309
+ * fields.forEach(field => {
20310
+ * console.log(`${field.key}: ${field.label} (${field.fieldType})`);
20311
+ * });
20312
+ * ```
20313
+ *
20314
+ * @example Filter by Entity Type
20315
+ * ```typescript
20316
+ * // Get only business custom fields
20317
+ * const businessFields = await sdk.customFields.getDefinitions({
20318
+ * entityType: 'business'
20319
+ * });
20320
+ * ```
20321
+ */
20322
+ async getDefinitions(options) {
20323
+ return this.api.getDefinitions(options?.entityType);
20324
+ }
20325
+ /**
20326
+ * Get a single custom field definition by ID
20327
+ *
20328
+ * @param id - The UUID of the custom field definition
20329
+ * @returns The custom field definition
20330
+ * @throws {PersApiError} When definition not found (404)
20331
+ *
20332
+ * @example
20333
+ * ```typescript
20334
+ * try {
20335
+ * const field = await sdk.customFields.getDefinition('uuid-here');
20336
+ * console.log('Field:', field.label);
20337
+ * } catch (error) {
20338
+ * if (error.statusCode === 404) {
20339
+ * console.log('Field not found');
20340
+ * }
20341
+ * }
20342
+ * ```
20343
+ */
20344
+ async getDefinition(id) {
20345
+ return this.api.getDefinition(id);
20346
+ }
20347
+ /**
20348
+ * Create a new custom field definition
20349
+ *
20350
+ * Creates a new custom field for the tenant. The field key must be unique
20351
+ * within the tenant and entity type combination.
20352
+ *
20353
+ * @param data - The field definition data
20354
+ * @returns The created custom field definition
20355
+ * @throws {PersApiError} When key already exists (409) or validation fails (400)
20356
+ *
20357
+ * @example Text Field with Pattern
20358
+ * ```typescript
20359
+ * const employeeIdField = await sdk.customFields.createDefinition({
20360
+ * key: 'employee_id',
20361
+ * label: 'Employee ID',
20362
+ * description: 'Your company employee ID (E + 5 digits)',
20363
+ * fieldType: 'text',
20364
+ * validation: {
20365
+ * required: true,
20366
+ * pattern: '^E[0-9]{5}$',
20367
+ * patternMessage: 'Must be E followed by 5 digits'
20368
+ * },
20369
+ * sortOrder: 1
20370
+ * });
20371
+ * ```
20372
+ *
20373
+ * @example Date Field with Comparison
20374
+ * ```typescript
20375
+ * const checkOutField = await sdk.customFields.createDefinition({
20376
+ * key: 'check_out',
20377
+ * label: 'Check-out Date',
20378
+ * fieldType: 'date',
20379
+ * validation: {
20380
+ * required: true,
20381
+ * comparisons: [
20382
+ * { field: 'check_in', operator: '>', message: 'Must be after check-in' }
20383
+ * ]
20384
+ * },
20385
+ * sortOrder: 2
20386
+ * });
20387
+ * ```
20388
+ *
20389
+ * @example Select Field with Static Options
20390
+ * ```typescript
20391
+ * const departmentField = await sdk.customFields.createDefinition({
20392
+ * key: 'department',
20393
+ * label: 'Department',
20394
+ * fieldType: 'select',
20395
+ * selectOptions: [
20396
+ * { value: 'engineering', label: 'Engineering' },
20397
+ * { value: 'sales', label: 'Sales' },
20398
+ * { value: 'hr', label: 'Human Resources' }
20399
+ * ],
20400
+ * validation: { required: true }
20401
+ * });
20402
+ * ```
20403
+ *
20404
+ * @example Select Field with Dynamic Options
20405
+ * ```typescript
20406
+ * const hotelField = await sdk.customFields.createDefinition({
20407
+ * key: 'preferred_hotel',
20408
+ * label: 'Preferred Hotel',
20409
+ * fieldType: 'select',
20410
+ * selectOptionsSource: {
20411
+ * entity: 'business',
20412
+ * valueField: 'id',
20413
+ * labelField: 'name',
20414
+ * filter: { tags: ['hotel'], isActive: true }
20415
+ * },
20416
+ * validation: { required: true }
20417
+ * });
20418
+ * ```
20419
+ */
20420
+ async createDefinition(data) {
20421
+ const result = await this.api.createDefinition(data);
20422
+ // Using 'user' domain until pers-shared adds 'customField' domain
20423
+ this.events?.emitSuccess({
20424
+ domain: 'user',
20425
+ type: 'definition_created',
20426
+ userMessage: `Custom field "${result.label}" created`,
20427
+ details: { id: result.id, key: result.key }
20428
+ });
20429
+ return result;
20430
+ }
20431
+ /**
20432
+ * Update an existing custom field definition
20433
+ *
20434
+ * Updates a custom field definition. Note that key and entityType cannot
20435
+ * be changed after creation.
20436
+ *
20437
+ * @param id - The UUID of the custom field definition to update
20438
+ * @param data - The fields to update (partial update supported)
20439
+ * @returns The updated custom field definition
20440
+ * @throws {PersApiError} When definition not found (404) or validation fails (400)
20441
+ *
20442
+ * @example Update Label and Validation
20443
+ * ```typescript
20444
+ * const updated = await sdk.customFields.updateDefinition('uuid-here', {
20445
+ * label: 'Employee ID (Required)',
20446
+ * validation: {
20447
+ * required: true,
20448
+ * minLength: 6,
20449
+ * maxLength: 6
20450
+ * }
20451
+ * });
20452
+ * ```
20453
+ *
20454
+ * @example Update Sort Order
20455
+ * ```typescript
20456
+ * await sdk.customFields.updateDefinition('uuid-here', {
20457
+ * sortOrder: 5
20458
+ * });
20459
+ * ```
20460
+ */
20461
+ async updateDefinition(id, data) {
20462
+ const result = await this.api.updateDefinition(id, data);
20463
+ // Using 'user' domain until pers-shared adds 'customField' domain
20464
+ this.events?.emitSuccess({
20465
+ domain: 'user',
20466
+ type: 'definition_updated',
20467
+ userMessage: `Custom field "${result.label}" updated`,
20468
+ details: { id: result.id, key: result.key }
20469
+ });
20470
+ return result;
20471
+ }
20472
+ /**
20473
+ * Delete a custom field definition (soft delete)
20474
+ *
20475
+ * Soft deletes a custom field definition. Existing user data in customData
20476
+ * is preserved, but the field will no longer appear in forms or validation.
20477
+ *
20478
+ * ⚠️ Consider the impact on existing user data before deleting.
20479
+ *
20480
+ * @param id - The UUID of the custom field definition to delete
20481
+ * @throws {PersApiError} When definition not found (404)
20482
+ *
20483
+ * @example
20484
+ * ```typescript
20485
+ * try {
20486
+ * await sdk.customFields.deleteDefinition('uuid-here');
20487
+ * console.log('Field deleted');
20488
+ * } catch (error) {
20489
+ * console.log('Failed to delete:', error.message);
20490
+ * }
20491
+ * ```
20492
+ */
20493
+ async deleteDefinition(id) {
20494
+ await this.api.deleteDefinition(id);
20495
+ // Using 'user' domain until pers-shared adds 'customField' domain
20496
+ this.events?.emitSuccess({
20497
+ domain: 'user',
20498
+ type: 'definition_deleted',
20499
+ userMessage: 'Custom field deleted',
20500
+ details: { id }
20501
+ });
20502
+ }
20503
+ /**
20504
+ * Resolve dynamic select options for a field
20505
+ *
20506
+ * For fields with selectOptionsSource (dynamic options from entities),
20507
+ * this resolves the actual options by querying the source entity.
20508
+ *
20509
+ * @param id - The UUID of the custom field definition
20510
+ * @returns Array of select options
20511
+ *
20512
+ * @example
20513
+ * ```typescript
20514
+ * const field = await sdk.customFields.getDefinition('hotel-field-id');
20515
+ *
20516
+ * if (field.selectOptionsSource) {
20517
+ * // Options come from entity - need to resolve
20518
+ * const options = await sdk.customFields.resolveSelectOptions(field.id);
20519
+ * console.log('Available hotels:', options);
20520
+ * } else {
20521
+ * // Static options
20522
+ * console.log('Options:', field.selectOptions);
20523
+ * }
20524
+ * ```
20525
+ */
20526
+ async resolveSelectOptions(id) {
20527
+ const response = await this.api.resolveSelectOptions(id);
20528
+ return response.options;
20529
+ }
20530
+ /**
20531
+ * Validate user custom data against field definitions
20532
+ *
20533
+ * Client-side validation using the same rules as the backend. Use this
20534
+ * to validate form data before submission.
20535
+ *
20536
+ * @param data - User's custom data (key-value pairs)
20537
+ * @param definitions - Custom field definitions to validate against
20538
+ * @returns Array of validation errors (empty if valid)
20539
+ *
20540
+ * @example Form Validation
20541
+ * ```typescript
20542
+ * const definitions = await sdk.customFields.getDefinitions();
20543
+ * const formData = {
20544
+ * employee_id: 'E123', // Missing a digit
20545
+ * department: 'engineering'
20546
+ * };
20547
+ *
20548
+ * const errors = sdk.customFields.validateUserData(formData, definitions);
20549
+ * if (errors.length > 0) {
20550
+ * errors.forEach(err => {
20551
+ * console.log(`${err.field}: ${err.message}`);
20552
+ * });
20553
+ * return; // Don't submit
20554
+ * }
20555
+ *
20556
+ * // No errors, safe to submit
20557
+ * await sdk.users.updateCurrentUser({ customData: formData });
20558
+ * ```
20559
+ */
20560
+ validateUserData(data, definitions) {
20561
+ // Convert CustomFieldDefinitionDTO to FieldDefinition for validation
20562
+ const fieldDefinitions = definitions.map(def => ({
20563
+ key: def.key,
20564
+ fieldType: def.fieldType,
20565
+ label: def.label,
20566
+ validation: def.validation ?? undefined,
20567
+ }));
20568
+ return validateAllFields(data, fieldDefinitions);
20569
+ }
20570
+ /**
20571
+ * Validate a single field value
20572
+ *
20573
+ * Validates a single value against a field's rules. Useful for
20574
+ * real-time validation as the user types.
20575
+ *
20576
+ * @param value - The value to validate
20577
+ * @param definition - The field definition
20578
+ * @returns Validation error or null if valid
20579
+ *
20580
+ * @example Real-time Validation
20581
+ * ```typescript
20582
+ * const employeeIdField = definitions.find(d => d.key === 'employee_id');
20583
+ *
20584
+ * const handleBlur = (value: string) => {
20585
+ * const error = sdk.customFields.validateFieldValue(value, employeeIdField);
20586
+ * if (error) {
20587
+ * setFieldError('employee_id', error.message);
20588
+ * } else {
20589
+ * clearFieldError('employee_id');
20590
+ * }
20591
+ * };
20592
+ * ```
20593
+ */
20594
+ validateFieldValue(value, definition) {
20595
+ const error = validateFieldValue(value, definition.validation ?? null, definition.fieldType);
20596
+ if (error) {
20597
+ // Add field key to error
20598
+ return { ...error, field: definition.key };
20599
+ }
20600
+ return null;
20601
+ }
20602
+ }
20603
+
19308
20604
  /**
19309
20605
  * @fileoverview PERS SDK - Platform-agnostic TypeScript SDK with High-Level Managers
19310
20606
  *
@@ -19748,7 +21044,7 @@ class PersSDK {
19748
21044
  */
19749
21045
  get tokens() {
19750
21046
  if (!this._tokens) {
19751
- this._tokens = new TokenManager(this.apiClient);
21047
+ this._tokens = new TokenManager(this.apiClient, this._events);
19752
21048
  }
19753
21049
  return this._tokens;
19754
21050
  }
@@ -19993,6 +21289,37 @@ class PersSDK {
19993
21289
  }
19994
21290
  return this._webhooks;
19995
21291
  }
21292
+ /**
21293
+ * Custom Field Definition Manager - Manage tenant-specific custom fields
21294
+ *
21295
+ * Provides CRUD operations for custom field definitions that extend the built-in
21296
+ * user profile fields. Custom fields are tenant-specific and support validation.
21297
+ *
21298
+ * @returns CustomFieldDefinitionManager instance
21299
+ *
21300
+ * @example Basic Operations
21301
+ * ```typescript
21302
+ * // List all custom fields
21303
+ * const fields = await sdk.customFields.getDefinitions();
21304
+ *
21305
+ * // Create a new field
21306
+ * const field = await sdk.customFields.createDefinition({
21307
+ * key: 'employee_id',
21308
+ * label: 'Employee ID',
21309
+ * fieldType: 'text',
21310
+ * validation: { required: true }
21311
+ * });
21312
+ *
21313
+ * // Validate user data
21314
+ * const errors = sdk.customFields.validateUserData(formData, fields.data);
21315
+ * ```
21316
+ */
21317
+ get customFields() {
21318
+ if (!this._customFields) {
21319
+ this._customFields = new CustomFieldDefinitionManager(this.apiClient, this._events);
21320
+ }
21321
+ return this._customFields;
21322
+ }
19996
21323
  /**
19997
21324
  * Wallet Events Manager - Real-time blockchain events for user's wallets
19998
21325
  *
@@ -40680,6 +42007,122 @@ function spelunkMessage(value) {
40680
42007
  return result;
40681
42008
  }
40682
42009
 
42010
+ /**
42011
+ * Get multiple ERC-1155 token balances using native balanceOfBatch function.
42012
+ * This is the most efficient method - it's built into the ERC-1155 standard.
42013
+ *
42014
+ * @param contract - ERC-1155 contract instance
42015
+ * @param accountAddress - Account to check balances for
42016
+ * @param tokenIds - Array of tokenIds to check
42017
+ * @returns Array of BatchBalanceResult with balance for each tokenId
42018
+ *
42019
+ * @example
42020
+ * ```typescript
42021
+ * const results = await getERC1155BalanceOfBatch(
42022
+ * contract,
42023
+ * '0xabc...',
42024
+ * ['1', '2', '3', '4', '5']
42025
+ * );
42026
+ * // 1 RPC call returns all balances
42027
+ * ```
42028
+ */
42029
+ async function getERC1155BalanceOfBatch(contract, accountAddress, tokenIds) {
42030
+ if (tokenIds.length === 0) {
42031
+ return [];
42032
+ }
42033
+ try {
42034
+ // ERC-1155 balanceOfBatch requires arrays of same length
42035
+ // accounts[i] corresponds to ids[i]
42036
+ const accounts = tokenIds.map(() => accountAddress);
42037
+ // Call native balanceOfBatch - 1 RPC call for ALL tokenIds
42038
+ const balances = await contract.balanceOfBatch(accounts, tokenIds);
42039
+ return tokenIds.map((tokenId, index) => ({
42040
+ tokenId,
42041
+ balance: balances[index],
42042
+ success: true
42043
+ }));
42044
+ }
42045
+ catch (error) {
42046
+ console.warn('[ERC1155] balanceOfBatch failed:', error);
42047
+ return tokenIds.map(tokenId => ({
42048
+ tokenId,
42049
+ balance: 0n,
42050
+ success: false,
42051
+ error: `balanceOfBatch failed: ${error}`
42052
+ }));
42053
+ }
42054
+ }
42055
+ /**
42056
+ * Get multiple token balances for ERC-1155 contracts.
42057
+ * Uses the native balanceOfBatch function (1 RPC call for ALL tokens).
42058
+ * Falls back to individual calls only if balanceOfBatch fails.
42059
+ *
42060
+ * @param provider - Ethers provider
42061
+ * @param contractAddress - Token contract address
42062
+ * @param contractInterface - Contract interface (must include balanceOfBatch for ERC-1155)
42063
+ * @param accountAddress - Account to check balances for
42064
+ * @param tokenIds - Array of tokenIds to check
42065
+ * @param isERC1155 - Whether the contract is ERC-1155 (default: true)
42066
+ * @returns Array of BatchBalanceResult
42067
+ */
42068
+ async function getBatchBalancesWithFallback(provider, contractAddress, contractInterface, accountAddress, tokenIds, isERC1155 = true) {
42069
+ if (tokenIds.length === 0)
42070
+ return [];
42071
+ const contract = new Contract(contractAddress, contractInterface, provider);
42072
+ // For single token, direct call is more efficient
42073
+ if (tokenIds.length === 1) {
42074
+ try {
42075
+ const balance = isERC1155
42076
+ ? await contract.balanceOf(accountAddress, tokenIds[0])
42077
+ : await contract.balanceOf(accountAddress);
42078
+ return [{
42079
+ tokenId: tokenIds[0],
42080
+ balance: balance,
42081
+ success: true
42082
+ }];
42083
+ }
42084
+ catch (error) {
42085
+ return [{
42086
+ tokenId: tokenIds[0],
42087
+ balance: 0n,
42088
+ success: false,
42089
+ error: `${error}`
42090
+ }];
42091
+ }
42092
+ }
42093
+ // For ERC-1155: Use native balanceOfBatch (1 RPC call for ALL tokens)
42094
+ if (isERC1155) {
42095
+ const results = await getERC1155BalanceOfBatch(contract, accountAddress, tokenIds);
42096
+ // If native call succeeded, return results
42097
+ if (results.some(r => r.success)) {
42098
+ return results;
42099
+ }
42100
+ // Otherwise fall through to individual calls
42101
+ }
42102
+ // Fallback: individual calls in parallel (for non-ERC1155 or if balanceOfBatch failed)
42103
+ const promises = tokenIds.map(async (tokenId) => {
42104
+ try {
42105
+ const balance = isERC1155
42106
+ ? await contract.balanceOf(accountAddress, tokenId)
42107
+ : await contract.balanceOf(accountAddress);
42108
+ return {
42109
+ tokenId,
42110
+ balance: balance,
42111
+ success: true
42112
+ };
42113
+ }
42114
+ catch (error) {
42115
+ return {
42116
+ tokenId,
42117
+ balance: 0n,
42118
+ success: false,
42119
+ error: `${error}`
42120
+ };
42121
+ }
42122
+ });
42123
+ return Promise.all(promises);
42124
+ }
42125
+
40683
42126
  const PERS_STATUS_TOKEN_ETHERS_METHOD_BUILDER = (contract, methodName) => {
40684
42127
  try {
40685
42128
  // Use unknown first, then cast to the expected type
@@ -41291,7 +42734,10 @@ class TokenDomainService {
41291
42734
  try {
41292
42735
  const abi = convertAbiToInterface(params.abi);
41293
42736
  const analysis = this.contractService.analyzeContract(abi);
41294
- const batchSize = Math.min(params.batchSize || 10, 20); // Cap at 20 for safety
42737
+ // ERC-1155 balanceOfBatch can handle large batches efficiently (1 RPC call)
42738
+ // Default to 100 for ERC-1155, 20 for ERC-721 (which has no batch function)
42739
+ const defaultBatchSize = analysis.isERC1155 ? 100 : 20;
42740
+ const batchSize = Math.min(params.batchSize || defaultBatchSize, analysis.isERC1155 ? 200 : 50);
41295
42741
  // ERC-1155: Requires specific token IDs
41296
42742
  if (analysis.isERC1155) {
41297
42743
  if (!params.tokenIds?.length) {
@@ -41345,31 +42791,44 @@ class TokenDomainService {
41345
42791
  async processTokenBatch(params, tokenIds, batchSize, tokenStandard) {
41346
42792
  const tokens = [];
41347
42793
  const errors = [];
42794
+ const abi = convertAbiToInterface(params.abi);
41348
42795
  // Process in batches
41349
42796
  for (let i = 0; i < tokenIds.length; i += batchSize) {
41350
42797
  const batch = tokenIds.slice(i, i + batchSize);
41351
- // Step 1: Check balance for each token (handle ERC-721 vs ERC-1155 differences)
41352
- const balancePromises = batch.map(tokenId => {
41353
- if (tokenStandard === 'ERC-721') {
41354
- // For ERC-721: Skip balance check, we know ownership from enumeration or explicit tokenIds
41355
- return Promise.resolve({
41356
- tokenId,
41357
- balance: 1,
41358
- hasBalance: true,
41359
- metadata: null
41360
- });
41361
- }
41362
- else {
41363
- // For ERC-1155: Check actual balance
41364
- return this.getTokenBalance({ ...params, tokenId })
41365
- .catch(error => {
41366
- errors.push(`${tokenId}: ${error.message}`);
41367
- return null;
41368
- });
41369
- }
41370
- });
41371
- const balanceResults = await Promise.all(balancePromises);
41372
- const validTokens = balanceResults.filter((token) => token !== null && token.hasBalance);
42798
+ let validTokens;
42799
+ if (tokenStandard === 'ERC-721') {
42800
+ // For ERC-721: Skip balance check, we know ownership from enumeration or explicit tokenIds
42801
+ validTokens = batch.map(tokenId => ({
42802
+ tokenId,
42803
+ balance: 1,
42804
+ hasBalance: true,
42805
+ metadata: null
42806
+ }));
42807
+ }
42808
+ else {
42809
+ // For ERC-1155: Use native balanceOfBatch (1 RPC call for entire batch)
42810
+ const batchResults = await this.web3Api.getBatchTokenBalances({
42811
+ accountAddress: params.accountAddress,
42812
+ contractAddress: params.contractAddress,
42813
+ abi,
42814
+ tokenIds: batch,
42815
+ chainId: params.chainId,
42816
+ isERC1155: true
42817
+ });
42818
+ // Convert batch results to TokenBalance format
42819
+ validTokens = batchResults
42820
+ .filter(r => r.success && r.balance > 0n)
42821
+ .map(r => ({
42822
+ tokenId: r.tokenId,
42823
+ balance: Number(r.balance),
42824
+ hasBalance: true,
42825
+ metadata: null
42826
+ }));
42827
+ // Track errors
42828
+ batchResults.filter(r => !r.success).forEach(r => {
42829
+ errors.push(`${r.tokenId}: ${r.error}`);
42830
+ });
42831
+ }
41373
42832
  // Step 2: Get metadata for tokens that have balance
41374
42833
  if (validTokens.length > 0) {
41375
42834
  const metadataPromises = validTokens.map(token => this.getTokenMetadata({
@@ -41493,6 +42952,627 @@ class ContractDomainService {
41493
42952
  }
41494
42953
  }
41495
42954
 
42955
+ /**
42956
+ * TokenCollectionManager - Auto-refresh token collections on wallet events
42957
+ *
42958
+ * This manager provides:
42959
+ * 1. Cached token collection storage with automatic refresh
42960
+ * 2. Auto-subscription to wallet events (transfers, mints, burns)
42961
+ * 3. Targeted invalidation based on contract address
42962
+ * 4. Subscriber pattern for reactive updates
42963
+ *
42964
+ * @example
42965
+ * ```typescript
42966
+ * // Get from SDK
42967
+ * const manager = sdk.tokens.collectionManager;
42968
+ *
42969
+ * // Subscribe to collection updates
42970
+ * const unsubscribe = manager.subscribe((collection, contractAddress) => {
42971
+ * console.log(`Collection updated for ${contractAddress}`);
42972
+ * updateUI(collection);
42973
+ * });
42974
+ *
42975
+ * // Fetch collection (auto-cached, auto-refreshed on wallet events)
42976
+ * const collection = await manager.getCollection({
42977
+ * accountAddress: '0x...',
42978
+ * contractAddress: '0x...',
42979
+ * abi: contractAbi,
42980
+ * chainId: 39123,
42981
+ * tokenIds: ['1', '2', '3']
42982
+ * });
42983
+ *
42984
+ * // Cleanup
42985
+ * unsubscribe();
42986
+ * ```
42987
+ */
42988
+ /**
42989
+ * Manages token collections with automatic refresh on wallet events
42990
+ */
42991
+ class TokenCollectionManager {
42992
+ constructor(tokenService, eventEmitter, config) {
42993
+ this.tokenService = tokenService;
42994
+ this.eventEmitter = eventEmitter;
42995
+ this.collections = new Map();
42996
+ this.handlers = new Set();
42997
+ this.pendingRefresh = new Map();
42998
+ this.config = {
42999
+ autoRefreshOnWalletEvents: config?.autoRefreshOnWalletEvents ?? true,
43000
+ eventDebounceMs: config?.eventDebounceMs ?? 500,
43001
+ debug: config?.debug ?? false
43002
+ };
43003
+ if (this.config.autoRefreshOnWalletEvents && eventEmitter) {
43004
+ this.setupEventSubscription();
43005
+ }
43006
+ }
43007
+ /**
43008
+ * Subscribe to wallet events for auto-refresh
43009
+ */
43010
+ setupEventSubscription() {
43011
+ if (!this.eventEmitter) {
43012
+ return;
43013
+ }
43014
+ this.eventUnsubscribe = this.eventEmitter.subscribe((event) => {
43015
+ const contractAddress = event.details?.contractAddress;
43016
+ this.handleWalletEvent(contractAddress, event.type);
43017
+ }, { domains: ['wallet'] });
43018
+ }
43019
+ /**
43020
+ * Get a token collection (cached, auto-refreshed on wallet events)
43021
+ *
43022
+ * @param params - Collection parameters
43023
+ * @returns Promise resolving to the token collection
43024
+ */
43025
+ async getCollection(params) {
43026
+ const key = this.getCacheKey(params);
43027
+ const cached = this.collections.get(key);
43028
+ // Return cached if available and not stale
43029
+ if (cached?.lastCollection) {
43030
+ this.log(`Cache HIT for ${key}`);
43031
+ return cached.lastCollection;
43032
+ }
43033
+ this.log(`Cache MISS for ${key}, fetching...`);
43034
+ return this.fetchAndCache(params);
43035
+ }
43036
+ /**
43037
+ * Force refresh a specific collection
43038
+ *
43039
+ * @param contractAddress - Contract address to refresh
43040
+ * @param accountAddress - Account address to refresh
43041
+ * @returns Promise that resolves when refresh is complete
43042
+ */
43043
+ async refreshCollection(contractAddress, accountAddress) {
43044
+ const toRefresh = [];
43045
+ for (const [key, entry] of this.collections) {
43046
+ if (entry.params.contractAddress === contractAddress) {
43047
+ if (!accountAddress || entry.params.accountAddress === accountAddress) {
43048
+ toRefresh.push(key);
43049
+ }
43050
+ }
43051
+ }
43052
+ await Promise.all(toRefresh.map(key => {
43053
+ const entry = this.collections.get(key);
43054
+ if (entry) {
43055
+ return this.refreshEntry(key, entry, 'refresh');
43056
+ }
43057
+ }));
43058
+ }
43059
+ /**
43060
+ * Subscribe to collection change events
43061
+ *
43062
+ * @param handler - Callback when any tracked collection changes
43063
+ * @returns Unsubscribe function
43064
+ */
43065
+ subscribe(handler) {
43066
+ this.handlers.add(handler);
43067
+ return () => this.handlers.delete(handler);
43068
+ }
43069
+ /**
43070
+ * Get all currently tracked collections
43071
+ */
43072
+ getTrackedCollections() {
43073
+ return Array.from(this.collections.entries()).map(([_, entry]) => ({
43074
+ contractAddress: entry.params.contractAddress,
43075
+ accountAddress: entry.params.accountAddress,
43076
+ collection: entry.lastCollection
43077
+ }));
43078
+ }
43079
+ /**
43080
+ * Check if a collection is currently being tracked
43081
+ */
43082
+ isTracking(contractAddress, accountAddress) {
43083
+ const key = `${contractAddress}:${accountAddress}`;
43084
+ return this.collections.has(key);
43085
+ }
43086
+ /**
43087
+ * Clear tracking for a specific collection
43088
+ */
43089
+ untrack(contractAddress, accountAddress) {
43090
+ if (accountAddress) {
43091
+ const key = `${contractAddress}:${accountAddress}`;
43092
+ this.collections.delete(key);
43093
+ this.log(`Untracked ${key}`);
43094
+ }
43095
+ else {
43096
+ // Untrack all for this contract
43097
+ for (const key of this.collections.keys()) {
43098
+ if (key.startsWith(`${contractAddress}:`)) {
43099
+ this.collections.delete(key);
43100
+ }
43101
+ }
43102
+ this.log(`Untracked all collections for ${contractAddress}`);
43103
+ }
43104
+ }
43105
+ /**
43106
+ * Clear all tracked collections
43107
+ */
43108
+ clearAll() {
43109
+ this.collections.clear();
43110
+ this.log('Cleared all tracked collections');
43111
+ }
43112
+ /**
43113
+ * Handle wallet events (with debouncing)
43114
+ */
43115
+ handleWalletEvent(contractAddress, eventType) {
43116
+ // Determine which collections to refresh
43117
+ const keysToRefresh = [];
43118
+ for (const [key, entry] of this.collections) {
43119
+ // If no specific contract, refresh all; otherwise match contract
43120
+ if (!contractAddress || entry.params.contractAddress.toLowerCase() === contractAddress.toLowerCase()) {
43121
+ keysToRefresh.push(key);
43122
+ }
43123
+ }
43124
+ if (keysToRefresh.length === 0) {
43125
+ this.log(`No tracked collections affected by event for ${contractAddress || 'all contracts'}`);
43126
+ return;
43127
+ }
43128
+ // Debounce refresh per key
43129
+ for (const key of keysToRefresh) {
43130
+ const existing = this.pendingRefresh.get(key);
43131
+ if (existing) {
43132
+ clearTimeout(existing);
43133
+ }
43134
+ const timeout = setTimeout(async () => {
43135
+ this.pendingRefresh.delete(key);
43136
+ const entry = this.collections.get(key);
43137
+ if (entry) {
43138
+ this.log(`Refreshing ${key} due to wallet event: ${eventType}`);
43139
+ await this.refreshEntry(key, entry, eventType);
43140
+ }
43141
+ }, this.config.eventDebounceMs);
43142
+ this.pendingRefresh.set(key, timeout);
43143
+ }
43144
+ }
43145
+ /**
43146
+ * Fetch and cache a collection
43147
+ */
43148
+ async fetchAndCache(params) {
43149
+ const key = this.getCacheKey(params);
43150
+ // Mark as refreshing to prevent duplicate fetches
43151
+ const existing = this.collections.get(key);
43152
+ if (existing?.refreshing) {
43153
+ // Wait for existing refresh to complete
43154
+ return existing.lastCollection || this.emptyCollection(params);
43155
+ }
43156
+ this.collections.set(key, {
43157
+ params,
43158
+ lastCollection: null,
43159
+ timestamp: Date.now(),
43160
+ refreshing: true
43161
+ });
43162
+ try {
43163
+ const collection = await this.tokenService.getTokenCollection(params);
43164
+ this.collections.set(key, {
43165
+ params,
43166
+ lastCollection: collection,
43167
+ timestamp: Date.now(),
43168
+ refreshing: false
43169
+ });
43170
+ this.log(`Cached collection for ${key}: ${collection.tokensRetrieved} tokens`);
43171
+ return collection;
43172
+ }
43173
+ catch (error) {
43174
+ // Remove failed entry
43175
+ this.collections.delete(key);
43176
+ throw error;
43177
+ }
43178
+ }
43179
+ /**
43180
+ * Refresh a cached entry and notify handlers
43181
+ */
43182
+ async refreshEntry(key, entry, source) {
43183
+ if (entry.refreshing) {
43184
+ this.log(`Skipping refresh for ${key} - already refreshing`);
43185
+ return;
43186
+ }
43187
+ entry.refreshing = true;
43188
+ try {
43189
+ const newCollection = await this.tokenService.getTokenCollection(entry.params);
43190
+ entry.lastCollection = newCollection;
43191
+ entry.timestamp = Date.now();
43192
+ entry.refreshing = false;
43193
+ this.log(`Refreshed ${key}: ${newCollection.tokensRetrieved} tokens`);
43194
+ // Notify subscribers
43195
+ for (const handler of this.handlers) {
43196
+ try {
43197
+ handler(newCollection, entry.params.contractAddress, {
43198
+ type: source === 'refresh' ? 'refresh' : 'wallet_event',
43199
+ source
43200
+ });
43201
+ }
43202
+ catch (handlerError) {
43203
+ console.error('[TokenCollectionManager] Handler error:', handlerError);
43204
+ }
43205
+ }
43206
+ }
43207
+ catch (error) {
43208
+ entry.refreshing = false;
43209
+ console.error(`[TokenCollectionManager] Refresh failed for ${key}:`, error);
43210
+ }
43211
+ }
43212
+ /**
43213
+ * Generate cache key from params
43214
+ */
43215
+ getCacheKey(params) {
43216
+ return `${params.contractAddress}:${params.accountAddress}`;
43217
+ }
43218
+ /**
43219
+ * Create empty collection placeholder
43220
+ */
43221
+ emptyCollection(params) {
43222
+ return {
43223
+ accountAddress: params.accountAddress,
43224
+ contractAddress: params.contractAddress,
43225
+ totalBalance: 0,
43226
+ tokensRetrieved: 0,
43227
+ tokens: [],
43228
+ note: 'Loading...'
43229
+ };
43230
+ }
43231
+ /**
43232
+ * Debug logging
43233
+ */
43234
+ log(message, data) {
43235
+ if (this.config.debug) {
43236
+ console.log(`[TokenCollectionManager] ${message}`, data || '');
43237
+ }
43238
+ }
43239
+ /**
43240
+ * Cleanup resources
43241
+ */
43242
+ destroy() {
43243
+ // Clear pending refreshes
43244
+ for (const timeout of this.pendingRefresh.values()) {
43245
+ clearTimeout(timeout);
43246
+ }
43247
+ this.pendingRefresh.clear();
43248
+ // Unsubscribe from events
43249
+ this.eventUnsubscribe?.();
43250
+ // Clear handlers and collections
43251
+ this.handlers.clear();
43252
+ this.collections.clear();
43253
+ this.log('Destroyed');
43254
+ }
43255
+ }
43256
+
43257
+ /**
43258
+ * BalanceManager - Auto-refresh ERC-20 token balances on wallet events
43259
+ *
43260
+ * This manager provides:
43261
+ * 1. Cached balance storage with automatic refresh
43262
+ * 2. Auto-subscription to wallet events (transfers)
43263
+ * 3. Targeted invalidation based on contract address
43264
+ * 4. Subscriber pattern for reactive updates
43265
+ *
43266
+ * @example
43267
+ * ```typescript
43268
+ * // Get from SDK
43269
+ * const manager = sdk.tokens.balanceManager;
43270
+ *
43271
+ * // Subscribe to balance updates
43272
+ * const unsubscribe = manager.subscribe((balance, contractAddress) => {
43273
+ * console.log(`Balance updated for ${contractAddress}: ${balance}`);
43274
+ * updateUI(balance);
43275
+ * });
43276
+ *
43277
+ * // Fetch balance (auto-cached, auto-refreshed on wallet events)
43278
+ * const balance = await manager.getBalance({
43279
+ * accountAddress: '0x...',
43280
+ * contractAddress: '0x...',
43281
+ * abi: contractAbi,
43282
+ * chainId: 39123
43283
+ * });
43284
+ *
43285
+ * // Manual refresh
43286
+ * await manager.refreshBalance(contractAddress);
43287
+ *
43288
+ * // Cleanup
43289
+ * unsubscribe();
43290
+ * ```
43291
+ */
43292
+ /**
43293
+ * Manages ERC-20 token balances with automatic refresh on wallet events
43294
+ */
43295
+ class BalanceManager {
43296
+ constructor(tokenService, eventEmitter, config) {
43297
+ this.tokenService = tokenService;
43298
+ this.eventEmitter = eventEmitter;
43299
+ this.balances = new Map();
43300
+ this.handlers = new Set();
43301
+ this.pendingRefresh = new Map();
43302
+ this.config = {
43303
+ autoRefreshOnWalletEvents: config?.autoRefreshOnWalletEvents ?? true,
43304
+ eventDebounceMs: config?.eventDebounceMs ?? 500,
43305
+ debug: config?.debug ?? false
43306
+ };
43307
+ if (this.config.autoRefreshOnWalletEvents && eventEmitter) {
43308
+ this.setupEventSubscription();
43309
+ }
43310
+ }
43311
+ /**
43312
+ * Subscribe to wallet events for auto-refresh
43313
+ */
43314
+ setupEventSubscription() {
43315
+ if (!this.eventEmitter) {
43316
+ return;
43317
+ }
43318
+ this.eventUnsubscribe = this.eventEmitter.subscribe((event) => {
43319
+ const contractAddress = event.details?.contractAddress;
43320
+ this.handleWalletEvent(contractAddress, event.type);
43321
+ }, { domains: ['wallet'] });
43322
+ }
43323
+ /**
43324
+ * Get a token balance (cached, auto-refreshed on wallet events)
43325
+ *
43326
+ * @param params - Balance parameters
43327
+ * @returns Promise resolving to the token balance
43328
+ */
43329
+ async getBalance(params) {
43330
+ const key = this.getCacheKey(params);
43331
+ const cached = this.balances.get(key);
43332
+ // Return cached if available and not stale
43333
+ if (cached?.lastBalance) {
43334
+ this.log(`Cache HIT for ${key}`);
43335
+ return cached.lastBalance;
43336
+ }
43337
+ this.log(`Cache MISS for ${key}, fetching...`);
43338
+ return this.fetchAndCache(params);
43339
+ }
43340
+ /**
43341
+ * Force refresh a specific balance
43342
+ *
43343
+ * @param contractAddress - Contract address to refresh
43344
+ * @param accountAddress - Account address to refresh (optional, refreshes all if not provided)
43345
+ * @returns Promise that resolves when refresh is complete
43346
+ */
43347
+ async refreshBalance(contractAddress, accountAddress) {
43348
+ const toRefresh = [];
43349
+ for (const [key, entry] of this.balances) {
43350
+ if (entry.params.contractAddress.toLowerCase() === contractAddress.toLowerCase()) {
43351
+ if (!accountAddress || entry.params.accountAddress.toLowerCase() === accountAddress.toLowerCase()) {
43352
+ toRefresh.push(key);
43353
+ }
43354
+ }
43355
+ }
43356
+ await Promise.all(toRefresh.map(key => {
43357
+ const entry = this.balances.get(key);
43358
+ if (entry) {
43359
+ return this.refreshEntry(key, entry, 'refresh');
43360
+ }
43361
+ }));
43362
+ }
43363
+ /**
43364
+ * Force refresh all tracked balances
43365
+ *
43366
+ * @returns Promise that resolves when all refreshes are complete
43367
+ */
43368
+ async refreshAll() {
43369
+ const refreshPromises = Array.from(this.balances.entries()).map(([key, entry]) => this.refreshEntry(key, entry, 'refresh'));
43370
+ await Promise.all(refreshPromises);
43371
+ }
43372
+ /**
43373
+ * Subscribe to balance change events
43374
+ *
43375
+ * @param handler - Callback when any tracked balance changes
43376
+ * @returns Unsubscribe function
43377
+ */
43378
+ subscribe(handler) {
43379
+ this.handlers.add(handler);
43380
+ return () => this.handlers.delete(handler);
43381
+ }
43382
+ /**
43383
+ * Get all currently tracked balances
43384
+ */
43385
+ getTrackedBalances() {
43386
+ return Array.from(this.balances.entries()).map(([_, entry]) => ({
43387
+ contractAddress: entry.params.contractAddress,
43388
+ accountAddress: entry.params.accountAddress,
43389
+ balance: entry.lastBalance
43390
+ }));
43391
+ }
43392
+ /**
43393
+ * Check if a balance is currently being tracked
43394
+ */
43395
+ isTracking(contractAddress, accountAddress) {
43396
+ const key = `${contractAddress.toLowerCase()}:${accountAddress.toLowerCase()}`;
43397
+ return this.balances.has(key);
43398
+ }
43399
+ /**
43400
+ * Clear tracking for a specific balance
43401
+ */
43402
+ untrack(contractAddress, accountAddress) {
43403
+ if (accountAddress) {
43404
+ const key = `${contractAddress.toLowerCase()}:${accountAddress.toLowerCase()}`;
43405
+ this.balances.delete(key);
43406
+ this.log(`Untracked ${key}`);
43407
+ }
43408
+ else {
43409
+ // Untrack all for this contract
43410
+ const toDelete = [];
43411
+ for (const key of this.balances.keys()) {
43412
+ if (key.startsWith(`${contractAddress.toLowerCase()}:`)) {
43413
+ toDelete.push(key);
43414
+ }
43415
+ }
43416
+ toDelete.forEach(key => this.balances.delete(key));
43417
+ this.log(`Untracked all balances for ${contractAddress}`);
43418
+ }
43419
+ }
43420
+ /**
43421
+ * Clear all tracked balances
43422
+ */
43423
+ clearAll() {
43424
+ this.balances.clear();
43425
+ this.log('Cleared all tracked balances');
43426
+ }
43427
+ /**
43428
+ * Handle wallet events (with debouncing)
43429
+ */
43430
+ handleWalletEvent(contractAddress, eventType) {
43431
+ // Determine which balances to refresh
43432
+ const keysToRefresh = [];
43433
+ for (const [key, entry] of this.balances) {
43434
+ // If no specific contract, refresh all; otherwise match contract
43435
+ if (!contractAddress || entry.params.contractAddress.toLowerCase() === contractAddress.toLowerCase()) {
43436
+ keysToRefresh.push(key);
43437
+ }
43438
+ }
43439
+ if (keysToRefresh.length === 0) {
43440
+ this.log(`No tracked balances affected by event for ${contractAddress || 'all contracts'}`);
43441
+ return;
43442
+ }
43443
+ // Debounce refresh per key
43444
+ for (const key of keysToRefresh) {
43445
+ const existing = this.pendingRefresh.get(key);
43446
+ if (existing) {
43447
+ clearTimeout(existing);
43448
+ }
43449
+ const timeout = setTimeout(async () => {
43450
+ this.pendingRefresh.delete(key);
43451
+ const entry = this.balances.get(key);
43452
+ if (entry) {
43453
+ this.log(`Refreshing ${key} due to wallet event: ${eventType}`);
43454
+ await this.refreshEntry(key, entry, eventType);
43455
+ }
43456
+ }, this.config.eventDebounceMs);
43457
+ this.pendingRefresh.set(key, timeout);
43458
+ }
43459
+ }
43460
+ /**
43461
+ * Fetch and cache a balance
43462
+ */
43463
+ async fetchAndCache(params) {
43464
+ const key = this.getCacheKey(params);
43465
+ // Mark as refreshing to prevent duplicate fetches
43466
+ const existing = this.balances.get(key);
43467
+ if (existing?.refreshing) {
43468
+ // Wait for existing refresh to complete
43469
+ return existing.lastBalance || this.emptyBalance();
43470
+ }
43471
+ this.balances.set(key, {
43472
+ params,
43473
+ lastBalance: null,
43474
+ timestamp: Date.now(),
43475
+ refreshing: true
43476
+ });
43477
+ try {
43478
+ const balance = await this.tokenService.getTokenBalance({
43479
+ ...params,
43480
+ tokenId: null // ERC-20 has no tokenId
43481
+ });
43482
+ this.balances.set(key, {
43483
+ params,
43484
+ lastBalance: balance,
43485
+ timestamp: Date.now(),
43486
+ refreshing: false
43487
+ });
43488
+ this.log(`Cached balance for ${key}: ${balance.balance}`);
43489
+ return balance;
43490
+ }
43491
+ catch (error) {
43492
+ // Remove failed entry
43493
+ this.balances.delete(key);
43494
+ throw error;
43495
+ }
43496
+ }
43497
+ /**
43498
+ * Refresh a cached entry and notify handlers
43499
+ */
43500
+ async refreshEntry(key, entry, source) {
43501
+ if (entry.refreshing) {
43502
+ this.log(`Skipping refresh for ${key} - already refreshing`);
43503
+ return;
43504
+ }
43505
+ entry.refreshing = true;
43506
+ try {
43507
+ const newBalance = await this.tokenService.getTokenBalance({
43508
+ ...entry.params,
43509
+ tokenId: null // ERC-20 has no tokenId
43510
+ });
43511
+ entry.lastBalance = newBalance;
43512
+ entry.timestamp = Date.now();
43513
+ entry.refreshing = false;
43514
+ this.log(`Refreshed ${key}: ${newBalance.balance}`);
43515
+ // Notify subscribers
43516
+ for (const handler of this.handlers) {
43517
+ try {
43518
+ handler(newBalance, entry.params.contractAddress, {
43519
+ type: source === 'refresh' ? 'refresh' : 'wallet_event',
43520
+ source
43521
+ });
43522
+ }
43523
+ catch (handlerError) {
43524
+ console.error('[BalanceManager] Handler error:', handlerError);
43525
+ }
43526
+ }
43527
+ }
43528
+ catch (error) {
43529
+ entry.refreshing = false;
43530
+ console.error(`[BalanceManager] Refresh failed for ${key}:`, error);
43531
+ }
43532
+ }
43533
+ /**
43534
+ * Generate cache key from params
43535
+ */
43536
+ getCacheKey(params) {
43537
+ return `${params.contractAddress.toLowerCase()}:${params.accountAddress.toLowerCase()}`;
43538
+ }
43539
+ /**
43540
+ * Create empty balance placeholder
43541
+ */
43542
+ emptyBalance() {
43543
+ return {
43544
+ tokenId: null,
43545
+ balance: 0,
43546
+ hasBalance: false,
43547
+ metadata: null
43548
+ };
43549
+ }
43550
+ /**
43551
+ * Debug logging
43552
+ */
43553
+ log(message, data) {
43554
+ if (this.config.debug) {
43555
+ console.log(`[BalanceManager] ${message}`, data || '');
43556
+ }
43557
+ }
43558
+ /**
43559
+ * Cleanup resources
43560
+ */
43561
+ destroy() {
43562
+ // Clear pending refreshes
43563
+ for (const timeout of this.pendingRefresh.values()) {
43564
+ clearTimeout(timeout);
43565
+ }
43566
+ this.pendingRefresh.clear();
43567
+ // Unsubscribe from events
43568
+ this.eventUnsubscribe?.();
43569
+ // Clear handlers and balances
43570
+ this.handlers.clear();
43571
+ this.balances.clear();
43572
+ this.log('Destroyed');
43573
+ }
43574
+ }
43575
+
41496
43576
  /**
41497
43577
  * Web3ApplicationService - Application layer entrance point
41498
43578
  * Orchestrates domain services and provides clean public interface
@@ -41601,6 +43681,30 @@ class Web3InfrastructureApi {
41601
43681
  return 0;
41602
43682
  }
41603
43683
  }
43684
+ /**
43685
+ * Get multiple ERC-1155 token balances in a single RPC call using native balanceOfBatch.
43686
+ * Falls back to individual calls if balanceOfBatch fails.
43687
+ *
43688
+ * @param request - Batch balance request
43689
+ * @returns Array of balance results per tokenId
43690
+ */
43691
+ async getBatchTokenBalances(request) {
43692
+ try {
43693
+ const ethersProvider = await this.web3ChainService.getEthersProviderByChainId(request.chainId);
43694
+ const results = await getBatchBalancesWithFallback(ethersProvider, request.contractAddress, request.abi, request.accountAddress, request.tokenIds, request.isERC1155 ?? true);
43695
+ return results;
43696
+ }
43697
+ catch (error) {
43698
+ console.error(`Failed to get batch token balances:`, error);
43699
+ // Return empty results on failure
43700
+ return request.tokenIds.map(tokenId => ({
43701
+ tokenId,
43702
+ balance: 0n,
43703
+ success: false,
43704
+ error: `${error}`
43705
+ }));
43706
+ }
43707
+ }
41604
43708
  async getTokenUri(request) {
41605
43709
  try {
41606
43710
  const ethersProvider = await this.web3ChainService.getEthersProviderByChainId(request.chainId);
@@ -41838,17 +43942,11 @@ async function getExplorerUrlByChainId(getChainData, chainId, address, type) {
41838
43942
  * ```
41839
43943
  */
41840
43944
  class Web3Manager {
41841
- // TODO: Add PersEventEmitter support for blockchain events
41842
- // When ready, add:
41843
- // 1. constructor param: private events?: PersEventEmitter
41844
- // 2. Subscribe to contract events (Transfer, Approval, etc.)
41845
- // 3. Emit via: this.events?.emitSuccess({ domain: 'web3', type: 'NFT_TRANSFERRED', ... })
41846
- // 4. Filter to only user's address (not all transfers)
41847
- // 5. Add cleanup for event listeners on SDK destroy
41848
- constructor(apiClient, tenantManager) {
43945
+ constructor(apiClient, tenantManager, eventEmitter) {
41849
43946
  this.apiClient = apiClient;
41850
43947
  // Use provided TenantManager or create one
41851
43948
  this.tenantManager = tenantManager || new TenantManager(apiClient);
43949
+ this.eventEmitter = eventEmitter;
41852
43950
  // Initialize Web3 Chain service
41853
43951
  const web3ChainApi = new Web3ChainApi(apiClient);
41854
43952
  this.web3ChainService = new Web3ChainService(web3ChainApi);
@@ -41856,6 +43954,75 @@ class Web3Manager {
41856
43954
  const web3InfrastructureApi = new Web3InfrastructureApi(this.web3ChainService);
41857
43955
  const ipfsInfrastructureApi = new IPFSInfrastructureApi(this.tenantManager);
41858
43956
  this.web3ApplicationService = new Web3ApplicationService(web3InfrastructureApi, ipfsInfrastructureApi);
43957
+ // Initialize domain services for managers
43958
+ const contractDomainService = new ContractDomainService();
43959
+ const metadataDomainService = new MetadataDomainService(ipfsInfrastructureApi);
43960
+ this.tokenDomainService = new TokenDomainService(web3InfrastructureApi, metadataDomainService, contractDomainService);
43961
+ }
43962
+ /**
43963
+ * Get the TokenCollectionManager for NFT collection operations.
43964
+ *
43965
+ * Features:
43966
+ * - Auto-refresh on wallet events (transfers, mints, burns)
43967
+ * - Cached collections
43968
+ * - Subscriber pattern for reactive updates
43969
+ *
43970
+ * @example
43971
+ * ```typescript
43972
+ * const manager = sdk.web3.collectionManager;
43973
+ *
43974
+ * // Subscribe to updates
43975
+ * const unsubscribe = manager.subscribe((collection, contractAddress) => {
43976
+ * console.log(`Collection updated for ${contractAddress}`);
43977
+ * });
43978
+ *
43979
+ * // Fetch collection (auto-cached, auto-refreshed)
43980
+ * const collection = await manager.getCollection({...});
43981
+ *
43982
+ * // Manual refresh
43983
+ * await manager.refreshCollection(contractAddress);
43984
+ * ```
43985
+ */
43986
+ get collectionManager() {
43987
+ if (!this._collectionManager) {
43988
+ this._collectionManager = new TokenCollectionManager(this.tokenDomainService, this.eventEmitter);
43989
+ }
43990
+ return this._collectionManager;
43991
+ }
43992
+ /**
43993
+ * Get the BalanceManager for ERC-20 (credit token) balance operations.
43994
+ *
43995
+ * Features:
43996
+ * - Auto-refresh on wallet events (transfers)
43997
+ * - Cached balances
43998
+ * - Subscriber pattern for reactive updates
43999
+ *
44000
+ * @example
44001
+ * ```typescript
44002
+ * const manager = sdk.web3.balanceManager;
44003
+ *
44004
+ * // Subscribe to balance updates
44005
+ * const unsubscribe = manager.subscribe((balance, contractAddress) => {
44006
+ * console.log(`Balance updated: ${balance.balance}`);
44007
+ * });
44008
+ *
44009
+ * // Fetch balance (auto-cached, auto-refreshed)
44010
+ * const balance = await manager.getBalance({
44011
+ * accountAddress: '0x...',
44012
+ * contractAddress: '0x...',
44013
+ * abi: creditTokenAbi,
44014
+ * chainId: 39123
44015
+ * });
44016
+ *
44017
+ * // Manual refresh
44018
+ * await manager.refreshBalance(contractAddress);
44019
+ * ```
44020
+ */
44021
+ get balanceManager() {
44022
+ if (!this._balanceManager) {
44023
+ this._balanceManager = new BalanceManager(this.tokenDomainService, this.eventEmitter);
44024
+ }
44025
+ return this._balanceManager;
41859
44026
  }
41860
44027
  /**
41861
44028
  * Get token balance for a specific token
@@ -42054,6 +44221,18 @@ class Web3Manager {
42054
44221
  maxTokens
42055
44222
  };
42056
44223
  }
44224
+ /**
44225
+ * Cleanup resources.
44226
+ *
44227
+ * Call this when the SDK is being destroyed to clean up
44228
+ * event subscriptions and cached data.
44229
+ */
44230
+ destroy() {
44231
+ this._collectionManager?.destroy();
44232
+ this._balanceManager?.destroy();
44233
+ this._collectionManager = undefined;
44234
+ this._balanceManager = undefined;
44235
+ }
42057
44236
  }
42058
44237
 
42059
44238
  /**
@@ -42945,6 +45124,83 @@ class BrowserFetchClientAdapter {
42945
45124
  }
42946
45125
  }
42947
45126
 
45127
+ /**
45128
+ * Build optimized image URL with automatic IPFS resolution and CDN options
45129
+ *
45130
+ * Single entry point for all image URL building:
45131
+ * - IPFS URIs → resolved via gateway + CDN options applied
45132
+ * - HTTP URLs → CDN options applied directly
45133
+ *
45134
+ * @param url - Image URL (ipfs://, https://, or any URL)
45135
+ * @param options - CDN optimization options (preset, quality, format, fit)
45136
+ * @param gateway - IPFS gateway domain (required for ipfs:// URLs)
45137
+ * @returns Optimized URL with query parameters
45138
+ *
45139
+ * @example
45140
+ * ```typescript
45141
+ * // IPFS thumbnail
45142
+ * buildImageUrl('ipfs://QmHash', { preset: 'thumb' }, 'cdn.example.com')
45143
+ * // => 'https://cdn.example.com/ipfs/QmHash?preset=thumb'
45144
+ *
45145
+ * // IPFS hero with high quality
45146
+ * buildImageUrl('ipfs://QmHash', { preset: 'hero', q: 95, fit: 'cover' }, 'cdn.example.com')
45147
+ * // => 'https://cdn.example.com/ipfs/QmHash?preset=hero&q=95&fit=cover'
45148
+ *
45149
+ * // HTTP URL (gateway ignored)
45150
+ * buildImageUrl('https://example.com/photo.jpg', { preset: 'card' })
45151
+ * // => 'https://example.com/photo.jpg?preset=card'
45152
+ *
45153
+ * // No options - just IPFS resolution
45154
+ * buildImageUrl('ipfs://QmHash', undefined, 'cdn.example.com')
45155
+ * // => 'https://cdn.example.com/ipfs/QmHash'
45156
+ * ```
45157
+ */
45158
+ function buildImageUrl(url, options, gateway) {
45159
+ if (!url)
45160
+ return '';
45161
+ let resolvedUrl = url;
45162
+ // Resolve IPFS to HTTP via gateway
45163
+ if (url.startsWith('ipfs://')) {
45164
+ if (!gateway) {
45165
+ console.warn('[buildImageUrl] IPFS URL requires gateway parameter');
45166
+ return url;
45167
+ }
45168
+ const cid = url.replace('ipfs://', '');
45169
+ resolvedUrl = `https://${gateway}/ipfs/${cid}`;
45170
+ }
45171
+ // Apply CDN options
45172
+ return buildImageCdnUrl(resolvedUrl, options);
45173
+ }
45174
+ /**
45175
+ * Check if a URL is an IPFS URI
45176
+ *
45177
+ * @param url - URL to check
45178
+ * @returns True if URL starts with ipfs://
45179
+ */
45180
+ function isIpfsUrl(url) {
45181
+ return !!url && url.startsWith('ipfs://');
45182
+ }
45183
+ /**
45184
+ * Extract CID from IPFS URI
45185
+ *
45186
+ * @param ipfsUri - IPFS URI (ipfs://Qm... or ipfs://baf...)
45187
+ * @returns CID string, or null if not a valid IPFS URI
45188
+ *
45189
+ * @example
45190
+ * ```typescript
45191
+ * extractIpfsCid('ipfs://QmHash123')
45192
+ * // => 'QmHash123'
45193
+ *
45194
+ * extractIpfsCid('https://example.com/image.jpg')
45195
+ * // => null
45196
+ * ```
45197
+ */
45198
+ function extractIpfsCid(ipfsUri) {
45199
+ if (!isIpfsUrl(ipfsUri))
45200
+ return null;
45201
+ return ipfsUri.replace('ipfs://', '');
45202
+ }
45203
+
42948
45204
  /**
42949
45205
  * React hook for loading and managing token balances
42950
45206
  *
@@ -46619,8 +48875,11 @@ const useTriggerSources = () => {
46619
48875
  };
46620
48876
  };
46621
48877
 
48878
+ exports.ACTIVE_USER_DEFINITION = ACTIVE_USER_DEFINITION;
46622
48879
  exports.ANALYTICS_GROUP_BY_TYPES = ANALYTICS_GROUP_BY_TYPES;
46623
48880
  exports.ANALYTICS_METRIC_TYPES = ANALYTICS_METRIC_TYPES;
48881
+ exports.ANALYTICS_VALID_PROCESS_STATUS = ANALYTICS_VALID_PROCESS_STATUS;
48882
+ exports.ANALYTICS_VALID_TRANSACTION_STATUSES = ANALYTICS_VALID_TRANSACTION_STATUSES;
46624
48883
  exports.AUTH_STORAGE_KEYS = AUTH_STORAGE_KEYS;
46625
48884
  exports.AnalyticsApi = AnalyticsApi;
46626
48885
  exports.AnalyticsManager = AnalyticsManager;
@@ -46651,6 +48910,7 @@ exports.CampaignService = CampaignService;
46651
48910
  exports.ChainTypes = ChainTypes;
46652
48911
  exports.ClientTransactionType = ClientTransactionType;
46653
48912
  exports.CommonErrorCodes = CommonErrorCodes;
48913
+ exports.CustomFieldDefinitionManager = CustomFieldDefinitionManager;
46654
48914
  exports.DEFAULT_PERS_CONFIG = DEFAULT_PERS_CONFIG;
46655
48915
  exports.DPOP_STORAGE_KEYS = DPOP_STORAGE_KEYS;
46656
48916
  exports.DPoPManager = DPoPManager;
@@ -46659,6 +48919,9 @@ exports.Domains = Domains;
46659
48919
  exports.DonationApi = DonationApi;
46660
48920
  exports.DonationManager = DonationManager;
46661
48921
  exports.DonationService = DonationService;
48922
+ exports.EMAIL_MAX_LENGTH = EMAIL_MAX_LENGTH;
48923
+ exports.EMAIL_REGEX = EMAIL_REGEX;
48924
+ exports.ENGAGEMENT_RATE_FORMULA = ENGAGEMENT_RATE_FORMULA;
46662
48925
  exports.EntityTypes = EntityTypes;
46663
48926
  exports.ErrorDomains = ErrorDomains;
46664
48927
  exports.ErrorUtils = ErrorUtils;
@@ -46666,6 +48929,14 @@ exports.FATAL_AUTH_CODES = FATAL_AUTH_CODES;
46666
48929
  exports.FileApi = FileApi;
46667
48930
  exports.FileManager = FileManager;
46668
48931
  exports.FileService = FileService;
48932
+ exports.IMAGE_CDN_CACHE_TTL = IMAGE_CDN_CACHE_TTL;
48933
+ exports.IMAGE_CDN_DEFAULT_FIT = IMAGE_CDN_DEFAULT_FIT;
48934
+ exports.IMAGE_CDN_DEFAULT_FORMAT = IMAGE_CDN_DEFAULT_FORMAT;
48935
+ exports.IMAGE_CDN_DEFAULT_QUALITY = IMAGE_CDN_DEFAULT_QUALITY;
48936
+ exports.IMAGE_CDN_MAX_DIMENSION = IMAGE_CDN_MAX_DIMENSION;
48937
+ exports.IMAGE_CDN_MAX_INPUT_SIZE = IMAGE_CDN_MAX_INPUT_SIZE;
48938
+ exports.IMAGE_CDN_PRESETS = IMAGE_CDN_PRESETS;
48939
+ exports.IMAGE_CDN_SUPPORTED_FORMATS = IMAGE_CDN_SUPPORTED_FORMATS;
46669
48940
  exports.IndexedDBTokenStorage = IndexedDBTokenStorage;
46670
48941
  exports.LocalStorageTokenStorage = LocalStorageTokenStorage;
46671
48942
  exports.LogoutRequired = LogoutRequired;
@@ -46674,6 +48945,7 @@ exports.MemoryTokenStorage = MemoryTokenStorage;
46674
48945
  exports.NativeTokenTypes = NativeTokenTypes;
46675
48946
  exports.NetworkError = NetworkError;
46676
48947
  exports.NodeHttpClientAdapter = NodeHttpClientAdapter;
48948
+ exports.OttAuthMethod = OttAuthMethod;
46677
48949
  exports.PaymentApi = PurchaseApi;
46678
48950
  exports.PaymentService = PaymentService;
46679
48951
  exports.PersApiClient = PersApiClient;
@@ -46718,6 +48990,8 @@ exports.TransactionService = TransactionService;
46718
48990
  exports.TriggerSourceApi = TriggerSourceApi;
46719
48991
  exports.TriggerSourceManager = TriggerSourceManager;
46720
48992
  exports.TriggerSourceService = TriggerSourceService;
48993
+ exports.URL_REGEX = URL_REGEX;
48994
+ exports.UUID_REGEX = UUID_REGEX;
46721
48995
  exports.UserApi = UserApi;
46722
48996
  exports.UserManager = UserManager;
46723
48997
  exports.UserService = UserService;
@@ -46730,8 +49004,10 @@ exports.VALID_CAMPAIGN_RELATIONS = VALID_CAMPAIGN_RELATIONS;
46730
49004
  exports.VALID_REDEMPTION_REDEEM_RELATIONS = VALID_REDEMPTION_REDEEM_RELATIONS;
46731
49005
  exports.VALID_REDEMPTION_RELATIONS = VALID_REDEMPTION_RELATIONS;
46732
49006
  exports.VALID_RELATIONS = VALID_RELATIONS;
49007
+ exports.VALID_TOKEN_METADATA_RELATIONS = VALID_TOKEN_METADATA_RELATIONS;
46733
49008
  exports.VALID_TRANSACTION_RELATIONS = VALID_TRANSACTION_RELATIONS;
46734
49009
  exports.VALID_USER_RELATIONS = VALID_USER_RELATIONS;
49010
+ exports.WALLET_ADDRESS_REGEX = WALLET_ADDRESS_REGEX;
46735
49011
  exports.WalletEventsManager = WalletEventsManager;
46736
49012
  exports.WebDPoPCryptoProvider = WebDPoPCryptoProvider;
46737
49013
  exports.WebhookApi = WebhookApi;
@@ -46740,6 +49016,8 @@ exports.WebhookService = WebhookService;
46740
49016
  exports.apiPublicKeyTestPrefix = apiPublicKeyTestPrefix;
46741
49017
  exports.buildApiRoot = buildApiRoot;
46742
49018
  exports.buildBurnRequest = buildBurnRequest;
49019
+ exports.buildImageCdnUrl = buildImageCdnUrl;
49020
+ exports.buildImageUrl = buildImageUrl;
46743
49021
  exports.buildMintRequest = buildMintRequest;
46744
49022
  exports.buildPOSBurnRequest = buildPOSBurnRequest;
46745
49023
  exports.buildPOSTransferRequest = buildPOSTransferRequest;
@@ -46748,46 +49026,67 @@ exports.buildPendingTransactionData = buildPendingTransactionData;
46748
49026
  exports.buildSubmissionRequest = buildSubmissionRequest;
46749
49027
  exports.buildTransferRequest = buildTransferRequest;
46750
49028
  exports.buildWalletEventsWsUrl = buildWalletEventsWsUrl;
49029
+ exports.builtInUserFieldMetadata = builtInUserFieldMetadata;
49030
+ exports.builtInUserFields = builtInUserFields;
49031
+ exports.compareValues = compareValues;
46751
49032
  exports.createPersEventsClient = createPersEventsClient;
46752
49033
  exports.createPersSDK = createPersSDK;
46753
49034
  exports.createReactNativeAuthProvider = createReactNativeAuthProvider;
46754
49035
  exports.createUserIdentifierObject = createUserIdentifierObject;
46755
49036
  exports.createUserStatusSDK = createUserStatusSDK;
49037
+ exports.customFieldEntityTypes = customFieldEntityTypes;
46756
49038
  exports.detectEnvironment = detectEnvironment;
46757
49039
  exports.detectIdentifierType = detectIdentifierType;
46758
49040
  exports.environment = environment;
46759
49041
  exports.extractData = extractData;
46760
49042
  exports.extractDeadlineFromSigningData = extractDeadlineFromSigningData;
49043
+ exports.extractIpfsCid = extractIpfsCid;
46761
49044
  exports.extractPagination = extractPagination;
46762
49045
  exports.fetchAllPages = fetchAllPages;
49046
+ exports.getAllBuiltInFieldDefinitions = getAllBuiltInFieldDefinitions;
49047
+ exports.getBuiltInFieldDefinition = getBuiltInFieldDefinition;
46763
49048
  exports.getMetadataFromTokenUnitResponse = getMetadataFromTokenUnitResponse;
49049
+ exports.getOperatorText = getOperatorText;
49050
+ exports.getPresetDimensions = getPresetDimensions;
46764
49051
  exports.getSigningUrl = getSigningUrl;
46765
49052
  exports.globalCacheService = globalCacheService;
46766
49053
  exports.hasMinimumRole = hasMinimumRole;
46767
49054
  exports.initializeReactNativePolyfills = initializeReactNativePolyfills;
49055
+ exports.isBuiltInUserField = isBuiltInUserField;
46768
49056
  exports.isConnectedMessage = isConnectedMessage;
46769
49057
  exports.isErrorMessage = isErrorMessage;
46770
49058
  exports.isEventMessage = isEventMessage;
46771
49059
  exports.isFatalAuthErrorInMessage = isFatalAuthErrorInMessage;
49060
+ exports.isIpfsUrl = isIpfsUrl;
46772
49061
  exports.isPaginatedResponse = isPaginatedResponse;
46773
49062
  exports.isPingMessage = isPingMessage;
46774
49063
  exports.isServerMessage = isServerMessage;
46775
49064
  exports.isSubscribedMessage = isSubscribedMessage;
49065
+ exports.isSupportedImageFormat = isSupportedImageFormat;
49066
+ exports.isUUID = isUUID;
46776
49067
  exports.isUnsubscribedMessage = isUnsubscribedMessage;
46777
49068
  exports.isUserIdentifierObject = isUserIdentifierObject;
46778
49069
  exports.isValidBusinessMembershipRelation = isValidBusinessMembershipRelation;
46779
49070
  exports.isValidCampaignClaimRelation = isValidCampaignClaimRelation;
46780
49071
  exports.isValidCampaignRelation = isValidCampaignRelation;
49072
+ exports.isValidEmail = isValidEmail;
49073
+ exports.isValidNonEmptyString = isValidNonEmptyString;
49074
+ exports.isValidPreset = isValidPreset;
46781
49075
  exports.isValidRedemptionRedeemRelation = isValidRedemptionRedeemRelation;
46782
49076
  exports.isValidRedemptionRelation = isValidRedemptionRelation;
46783
49077
  exports.isValidRelation = isValidRelation;
49078
+ exports.isValidTokenMetadataRelation = isValidTokenMetadataRelation;
46784
49079
  exports.isValidTransactionRelation = isValidTransactionRelation;
49080
+ exports.isValidUrl = isValidUrl;
46785
49081
  exports.isValidUserRelation = isValidUserRelation;
49082
+ exports.isValidUuid = isValidUuid;
49083
+ exports.isValidWalletAddress = isValidWalletAddress;
46786
49084
  exports.mergeWithDefaults = mergeWithDefaults;
46787
49085
  exports.needsExternalSigning = needsExternalSigning;
46788
49086
  exports.needsSubmission = needsSubmission;
46789
49087
  exports.normalizeFilters = normalizeFilters;
46790
49088
  exports.normalizeToPaginated = normalizeToPaginated;
49089
+ exports.operatorText = operatorText;
46791
49090
  exports.registerIdentifierType = registerIdentifierType;
46792
49091
  exports.testnetPrefix = testnetPrefix;
46793
49092
  exports.testnetShortPrefix = testnetShortPrefix;
@@ -46815,5 +49114,10 @@ exports.useTriggerSources = useTriggerSources;
46815
49114
  exports.useUserStatus = useUserStatus;
46816
49115
  exports.useUsers = useUsers;
46817
49116
  exports.useWeb3 = useWeb3;
49117
+ exports.userModelFields = userModelFields;
49118
+ exports.validateAllFields = validateAllFields;
49119
+ exports.validateFieldComparisons = validateFieldComparisons;
49120
+ exports.validateFieldValue = validateFieldValue;
49121
+ exports.validateObjectArrayItems = validateObjectArrayItems;
46818
49122
  exports.warnIfProblematicEnvironment = warnIfProblematicEnvironment;
46819
49123
  //# sourceMappingURL=index.js.map