1/** 2 * UserDataService - DynamoDB Data Access Layer 3 * 4 * Handles all CRUD operations for user profile data, bookmarks, pageviews, 5 * and topic following across multiple sites. 6 * 7 * Uses AWS SDK v2 (loaded from CDN) with Cognito Identity Pool credentials. 8 * Each user has isolated data per site domain (pubID). 9 */ 10 11(function(global) { 12 'use strict'; 13 14 class UserDataService { 15 constructor(config = {}) { 16 this.tableName = config.tableName || 'SwiftUserProfiles-prod'; 17 this.region = config.region || 'us-west-2'; 18 this.debug = config.debug || false; 19 20 // DynamoDB DocumentClient will be initialized when credentials are set 21 this.docClient = null; 22 } 23 24 /** 25 * Initialize DynamoDB client with credentials 26 * @param {AWS.Credentials} credentials - Cognito Identity Pool credentials 27 */ 28 initializeClient(credentials) { 29 if (typeof AWS === 'undefined') { 30 throw new Error('AWS SDK not loaded'); 31 } 32 33 // Configure AWS SDK 34 AWS.config.update({ 35 region: this.region, 36 credentials: credentials 37 }); 38 39 // Create DocumentClient 40 this.docClient = new AWS.DynamoDB.DocumentClient(); 41 42 this.log('DynamoDB client initialized'); 43 } 44 45 /** 46 * Helper to construct primary key 47 * @param {string} userId - Cognito Identity Pool identity ID 48 * @param {string} pubID - Site identifier 49 */ 50 buildPK(userId, pubID) { 51 return `USER#${userId}#SITE#${pubID}`; 52 } 53 54 /** 55 * Helper to construct GSI1 keys 56 */ 57 buildGSI1Keys(pubID, entityType, identifier) { 58 return { 59 GSI1PK: `SITE#${pubID}`, 60 GSI1SK: `${entityType}#${identifier}` 61 }; 62 } 63 64 /** 65 * Helper to construct GSI2 keys 66 */ 67 buildGSI2Keys(userId, pubID, entityType, timestamp = null) { 68 return { 69 GSI2PK: `USER#${userId}`, 70 GSI2SK: timestamp ? `SITE#${pubID}#${entityType}#${timestamp}` : `SITE#${pubID}#${entityType}` 71 }; 72 } 73 74 /** 75 * Helper for logging 76 */ 77 log(...args) { 78 if (this.debug) { 79 console.log('[UserDataService]', ...args); 80 } 81 } 82 83 /** 84 * Helper for error logging 85 */ 86 logError(...args) { 87 console.error('[UserDataService ERROR]', ...args); 88 } 89 90 // ========================================== 91 // PROFILE OPERATIONS 92 // ========================================== 93 94 /** 95 * Get user profile for a specific site 96 */ 97 async getProfile(userId, pubID) { 98 if (!this.docClient) { 99 throw new Error('DynamoDB client not initialized'); 100 } 101 102 try { 103 const params = { 104 TableName: this.tableName, 105 Key: { 106 PK: this.buildPK(userId, pubID), 107 SK: 'PROFILE' 108 } 109 }; 110 111 this.log('Getting profile:', params); 112 const result = await this.docClient.get(params).promise(); 113 return result.Item || null; 114 } catch (error) { 115 this.logError('getProfile failed:', error); 116 throw error; 117 } 118 } 119 120 /** 121 * Create or update user profile 122 */ 123 async saveProfile(userId, pubID, profileData) { 124 if (!this.docClient) { 125 throw new Error('DynamoDB client not initialized'); 126 } 127 128 try { 129 const timestamp = new Date().toISOString(); 130 const item = { 131 PK: this.buildPK(userId, pubID), 132 SK: 'PROFILE', 133 ...this.buildGSI1Keys(pubID, 'PROFILE', userId), 134 ...this.buildGSI2Keys(userId, pubID, 'PROFILE'), 135 136 // User identifiers 137 identityId: userId, 138 sub: profileData.sub, 139 email: profileData.email, 140 141 // Profile attributes 142 name: profileData.name, 143 givenName: profileData.givenName, 144 familyName: profileData.familyName, 145 postalCode: profileData.postalCode, 146 phoneNumber: profileData.phoneNumber, 147 148 // Location data 149 city: profileData.city, 150 county: profileData.county, 151 state: profileData.state, 152 country: profileData.country, 153 latitude: profileData.latitude, 154 longitude: profileData.longitude, 155 formattedAddress: profileData.formattedAddress, 156 address: profileData.address, 157 locationTags: profileData.locationTags, 158 locationUpdatedAt: profileData.locationUpdatedAt, 159 160 // User preferences 161 trackReadingHistory: profileData.trackReadingHistory !== undefined ? profileData.trackReadingHistory : true, 162 163 // Metadata 164 entityType: 'PROFILE', 165 updatedAt: timestamp, 166 createdAt: profileData.createdAt || timestamp, 167 lastVisited: profileData.lastVisited || timestamp 168 }; 169 170 // Remove undefined values 171 Object.keys(item).forEach(key => { 172 if (item[key] === undefined || item[key] === null) { 173 delete item[key]; 174 } 175 }); 176 177 const params = { 178 TableName: this.tableName, 179 Item: item 180 }; 181 182 this.log('Saving profile:', params); 183 await this.docClient.put(params).promise(); 184 this.log('Profile saved successfully'); 185 186 return item; 187 } catch (error) { 188 this.logError('saveProfile failed:', error); 189 throw error; 190 } 191 } 192 193 /** 194 * Update specific profile fields 195 */ 196 async updateProfile(userId, pubID, updates) { 197 if (!this.docClient) { 198 throw new Error('DynamoDB client not initialized'); 199 } 200 201 try { 202 const timestamp = new Date().toISOString(); 203 204 // Build update expression 205 const expressionParts = ['updatedAt = :timestamp', 'GSI1PK = :gsi1pk', 'GSI1SK = :gsi1sk']; 206 const expressionValues = { 207 ':timestamp': timestamp, 208 ':gsi1pk': `SITE#${pubID}`, 209 ':gsi1sk': `PROFILE#${userId}` 210 }; 211 const expressionNames = {}; 212
213 Object.entries(updates).forEach(([key, value]) => { 214 if (value !== undefined && value !== null) { 215 const attrName = `#${key}`; 216 const attrValue = `:${key}`; 217 expressionParts.push(`${attrName} = ${attrValue}`); 218 expressionValues[attrValue] = value; 219 expressionNames[attrName] = key; 220 } 221 }); 222 223 const params = { 224 TableName: this.tableName, 225 Key: { 226 PK: this.buildPK(userId, pubID), 227 SK: 'PROFILE' 228 }, 229 UpdateExpression: `SET ${expressionParts.join(', ')}`, 230 ExpressionAttributeValues: expressionValues 231 }; 232 233 if (Object.keys(expressionNames).length > 0) { 234 params.ExpressionAttributeNames = expressionNames; 235 } 236 237 this.log('Updating profile:', params); 238 await this.docClient.update(params).promise(); 239 this.log('Profile updated successfully'); 240 } catch (error) { 241 this.logError('updateProfile failed:', error); 242 throw error; 243 } 244 } 245 246 /** 247 * Record a wall engagement event (impression or CTA click) 248 * Appends to wallImpressions list on the PROFILE entity 249 */ 250 async recordWallEvent(userId, pubID, data) { 251 if (!this.docClient) { 252 throw new Error('DynamoDB client not initialized'); 253 } 254 255 try { 256 const params = { 257 TableName: this.tableName, 258 Key: { 259 PK: this.buildPK(userId, pubID), 260 SK: 'PROFILE' 261 }, 262 UpdateExpression: 'SET #wi = list_append(if_not_exists(#wi, :empty), :event), #updatedAt = :now', 263 ExpressionAttributeNames: { 264 '#wi': 'wallImpressions', 265 '#updatedAt': 'updatedAt' 266 }, 267 ExpressionAttributeValues: { 268 ':event': [Object.assign({ 269 ts: new Date().toISOString(), 270 type: data.type, 271 source: data.source, 272 action: data.action 273 }, data.level != null ? { level: data.level } : {})], 274 ':empty': [], 275 ':now': new Date().toISOString() 276 } 277 }; 278 279 await this.docClient.update(params).promise(); 280 this.log('Wall event recorded:', data); 281 } catch (error) { 282 this.logError('recordWallEvent failed:', error); 283 // Don't throw - not critical 284 } 285 } 286 287 /** 288 * Update last visited timestamp 289 */ 290 async updateLastVisited(userId, pubID) { 291 try { 292 const params = { 293 TableName: this.tableName, 294 Key: { 295 PK: this.buildPK(userId, pubID), 296 SK: 'PROFILE' 297 }, 298 UpdateExpression: 'SET lastVisited = :timestamp, updatedAt = :timestamp, GSI1PK = :gsi1pk, GSI1SK = :gsi1sk', 299 ExpressionAttributeValues: { 300 ':timestamp': new Date().toISOString(), 301 ':gsi1pk': `SITE#${pubID}`, 302 ':gsi1sk': `PROFILE#${userId}` 303 } 304 }; 305 306 await this.docClient.update(params).promise(); 307 this.log('lastVisited updated'); 308 } catch (error) { 309 this.logError('updateLastVisited failed:', error); 310 // Don't throw - not critical 311 } 312 } 313 314 // ========================================== 315 // BOOKMARK OPERATIONS 316 // ========================================== 317 318 /** 319 * Get all bookmarks for user on a specific site 320 */ 321 async getBookmarks(userId, pubID) { 322 if (!this.docClient) { 323 throw new Error('DynamoDB client not initialized'); 324 } 325 326 try { 327 const params = { 328 TableName: this.tableName, 329 KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)', 330 ExpressionAttributeValues: { 331 ':pk': this.buildPK(userId, pubID), 332 ':sk': 'BOOKMARK#' 333 } 334 }; 335 336 this.log('Getting bookmarks:', params); 337 const result = await this.docClient.query(params).promise(); 338 return result.Items || []; 339 } catch (error) { 340 this.logError('getBookmarks failed:', error); 341 return []; 342 } 343 } 344 345 /** 346 * Add a bookmark 347 */ 348 async addBookmark(userId, pubID, bookmark) { 349 if (!this.docClient) { 350 throw new Error('DynamoDB client not initialized'); 351 } 352 353 try { 354 const timestamp = new Date().toISOString(); 355 const postId = bookmark.postId || bookmark.articleId || this.generateId(); 356 357 const item = { 358 PK: this.buildPK(userId, pubID), 359 SK: `BOOKMARK#${postId}`, 360 ...this.buildGSI1Keys(pubID, 'BOOKMARK', timestamp), 361 ...this.buildGSI2Keys(userId, pubID, 'BOOKMARK', timestamp), 362 postId: postId, 363 articleTitle: bookmark.articleTitle || bookmark.bookmarkTitle || bookmark.title, 364 articleUrl: bookmark.articleUrl || bookmark.bookmarkShortlink || bookmark.url, 365 articleImage: bookmark.articleImage || bookmark.bookmarkImage || bookmark.image, 366 bookmarkedAt: timestamp, 367 entityType: 'BOOKMARK' 368 }; 369 370 const params = { 371 TableName: this.tableName, 372 Item: item 373 }; 374 375 this.log('Adding bookmark:', params); 376 await this.docClient.put(params).promise(); 377 this.log('Bookmark added successfully'); 378 379 return item; 380 } catch (error) { 381 this.logError('addBookmark failed:', error); 382 throw error; 383 } 384 } 385 386 /** 387 * Remove a bookmark (and any duplicates with old SK format) 388 */ 389 async removeBookmark(userId, pubID, postId) { 390 if (!this.docClient) { 391 throw new Error('DynamoDB client not initialized'); 392 } 393 394 try { 395 // First, delete the current format BOOKMARK#${postId} 396 const currentFormatParams = { 397 TableName: this.tableName, 398 Key: { 399 PK: this.buildPK(userId, pubID), 400 SK: `BOOKMARK#${postId}` 401 } 402 }; 403 404 this.log('Removing bookmark (current format):', currentFormatParams); 405 await this.docClient.delete(currentFormatParams).promise(); 406 407 // Also clean up any old-format duplicates (BOOKMARK#timestamp#postId) 408 // Query all bookmarks and find any with matching articleId/postId 409 const queryParams = { 410 TableName: this.tableName, 411 KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)', 412 ExpressionAttributeValues: { 413 ':pk': this.buildPK(userId, pubID), 414 ':sk': 'BOOKMARK#' 415 } 416 }; 417 418 const result = await this.docClient.query(queryParams).promise(); 419 const duplicates = (result.Items || []).filter(item => 420 (item.postId === postId || item.articleId === postId) && item.SK !== `BOOKMARK#${postId}` 421 ); 422 423 // Delete any duplicates found 424 if (duplicates.length > 0) { 425 this.log(`Found ${duplicates.length} duplicate bookmark(s) to clean up`); 426 for (const dup of duplicates) { 427 const deleteParams = { 428 TableName: this.tableName, 429 Key: { 430 PK: dup.PK, 431 SK: dup.SK 432 } 433 }; 434 this.log('Removing duplicate bookmark:', dup.SK); 435 await this.docClient.delete(deleteParams).promise(); 436 } 437 } 438 439 this.log('Bookmark removed successfully'); 440 return true; 441 } catch (error) { 442 this.logError('removeBookmark failed:', error); 443 throw error; 444 } 445 } 446 447 /** 448 * Check if article is bookmarked (single-item lookup only) 449 * OPTIMIZED: No longer falls back to fetching ALL bookmarks 450 */ 451 async isBookmarked(userId, pubID, postId) { 452 if (!this.docClient) { 453 return false;
454 } 455 456 try { 457 // Direct single-item lookup using BOOKMARK#postId format 458 const params = { 459 TableName: this.tableName, 460 Key: { 461 PK: this.buildPK(userId, pubID), 462 SK: `BOOKMARK#${postId}` 463 } 464 }; 465 466 const result = await this.docClient.get(params).promise(); 467 return !!result.Item; 468 } catch (error) { 469 this.logError('isBookmarked failed:', error); 470 return false; 471 } 472 } 473 474 // ========================================== 475 // TOPIC FOLLOWING OPERATIONS 476 // ========================================== 477 478 /** 479 * Get all followed topics 480 */ 481 async getFollowedTopics(userId, pubID) { 482 if (!this.docClient) { 483 throw new Error('DynamoDB client not initialized'); 484 } 485 486 try { 487 const params = { 488 TableName: this.tableName, 489 KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)', 490 ExpressionAttributeValues: { 491 ':pk': this.buildPK(userId, pubID), 492 ':sk': 'TOPIC#' 493 } 494 }; 495 496 this.log('Getting followed topics:', params); 497 const result = await this.docClient.query(params).promise(); 498 return result.Items || []; 499 } catch (error) { 500 this.logError('getFollowedTopics failed:', error); 501 return []; 502 } 503 } 504 505 /** 506 * Follow a topic 507 */ 508 async followTopic(userId, pubID, topic) { 509 if (!this.docClient) { 510 throw new Error('DynamoDB client not initialized'); 511 } 512 513 try { 514 const topicId = topic.topicId || topic.id; 515 const timestamp = new Date().toISOString(); 516 517 const item = { 518 PK: this.buildPK(userId, pubID), 519 SK: `TOPIC#${topicId}`, 520 ...this.buildGSI1Keys(pubID, 'TOPIC', `${topicId}#${userId}`), 521 ...this.buildGSI2Keys(userId, pubID, 'TOPIC', timestamp), 522 topicId: topicId, 523 topicType: this.parseTopicType(topicId), 524 topicName: topic.topicName || topic.name || topicId, 525 followedAt: timestamp, 526 notificationsEnabled: topic.notificationsEnabled !== undefined ? topic.notificationsEnabled : true, 527 emailDigest: topic.emailDigest !== undefined ? topic.emailDigest : true, 528 entityType: 'TOPIC' 529 }; 530 531 const params = { 532 TableName: this.tableName, 533 Item: item 534 }; 535 536 this.log('Following topic:', params); 537 await this.docClient.put(params).promise(); 538 this.log('Topic followed successfully'); 539 540 return item; 541 } catch (error) { 542 this.logError('followTopic failed:', error); 543 throw error; 544 } 545 } 546 547 /** 548 * Unfollow a topic 549 */ 550 async unfollowTopic(userId, pubID, topicId) { 551 if (!this.docClient) { 552 throw new Error('DynamoDB client not initialized'); 553 } 554 555 try { 556 const params = { 557 TableName: this.tableName, 558 Key: { 559 PK: this.buildPK(userId, pubID), 560 SK: `TOPIC#${topicId}` 561 } 562 }; 563 564 this.log('Unfollowing topic:', params); 565 await this.docClient.delete(params).promise(); 566 this.log('Topic unfollowed successfully'); 567 return true; 568 } catch (error) { 569 this.logError('unfollowTopic failed:', error); 570 throw error; 571 } 572 } 573 574 /** 575 * Check if following a topic 576 */ 577 async isFollowingTopic(userId, pubID, topicId) { 578 if (!this.docClient) { 579 return false;
580 } 581 582 try { 583 const params = { 584 TableName: this.tableName, 585 Key: { 586 PK: this.buildPK(userId, pubID), 587 SK: `TOPIC#${topicId}` 588 } 589 }; 590 591 const result = await this.docClient.get(params).promise(); 592 return !!result.Item; 593 } catch (error) { 594 this.logError('isFollowingTopic failed:', error); 595 return false; 596 } 597 } 598 599 /** 600 * Parse topic type from topic ID 601 */ 602 parseTopicType(topicId) { 603 if (topicId.startsWith('series-')) return 'series'; 604 if (topicId.startsWith('author-')) return 'author'; 605 if (topicId.startsWith('tag-')) return 'tag'; 606 return 'category'; 607 } 608 609 /** 610 * Update notification preference for a followed topic 611 */ 612 async updateTopicNotification(userId, pubID, topicId, notificationsEnabled) { 613 if (!this.docClient) { 614 throw new Error('DynamoDB client not initialized'); 615 } 616 617 try { 618 const params = { 619 TableName: this.tableName, 620 Key: { 621 PK: this.buildPK(userId, pubID), 622 SK: `TOPIC#${topicId}` 623 }, 624 UpdateExpression: 'SET notificationsEnabled = :val, updatedAt = :ts', 625 ExpressionAttributeValues: { 626 ':val': notificationsEnabled, 627 ':ts': new Date().toISOString() 628 } 629 }; 630 631 this.log('Updating topic notification:', params); 632 await this.docClient.update(params).promise(); 633 this.log('Topic notification updated successfully'); 634 return true; 635 } catch (error) { 636 this.logError('updateTopicNotification failed:', error); 637 throw error; 638 } 639 } 640 641 // ========================================== 642 // PUSH CLICK OPERATIONS 643 // ========================================== 644 645 /** 646 * Record a push notification click 647 */ 648 async recordPushClick(userId, pubID, clickData) { 649 if (!this.docClient) { 650 throw new Error('DynamoDB client not initialized'); 651 } 652 653 try { 654 const timestamp = new Date().toISOString(); 655 const ttl = Math.floor(Date.now() / 1000) + (90 * 24 * 60 * 60); // 90 days TTL 656 657 const item = { 658 PK: this.buildPK(userId, pubID), 659 SK: `PUSHCLICK#${timestamp}`, 660 ...this.buildGSI1Keys(pubID, 'PUSHCLICK', timestamp), 661 ...this.buildGSI2Keys(userId, pubID, 'PUSHCLICK', timestamp), 662 articleUrl: clickData.articleUrl, 663 articleTitle: clickData.articleTitle, 664 source: clickData.source || 'progressier', 665 campaign: clickData.campaign || null, 666 clickedAt: timestamp, 667 ttl: ttl, 668 entityType: 'PUSHCLICK' 669 }; 670 671 // Remove null/undefined values 672 Object.keys(item).forEach(function(key) { 673 if (item[key] === undefined || item[key] === null) { 674 delete item[key]; 675 } 676 }); 677 678 const params = { 679 TableName: this.tableName, 680 Item: item 681 }; 682 683 await this.docClient.put(params).promise(); 684 this.log('Push click recorded:', { url: item.articleUrl, campaign: item.campaign }); 685 } catch (error) { 686 this.logError('recordPushClick failed:', error); 687 // Don't throw - not critical 688 } 689 } 690 691 // ========================================== 692 // PAGEVIEW / READING HISTORY OPERATIONS 693 // ========================================== 694 695 /** 696 * Get pageview history 697 */ 698 async getPageviews(userId, pubID, limit = 50) { 699 if (!this.docClient) { 700 throw new Error('DynamoDB client not initialized'); 701 } 702 703 try { 704 const params = { 705 TableName: this.tableName, 706 KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)', 707 ExpressionAttributeValues: { 708 ':pk': this.buildPK(userId, pubID), 709 ':sk': 'PAGEVIEW#' 710 }, 711 ScanIndexForward: false, // Most recent first 712 Limit: limit 713 }; 714 715 this.log('Getting pageviews:', params); 716 const result = await this.docClient.query(params).promise(); 717 return result.Items || []; 718 } catch (error) { 719 this.logError('getPageviews failed:', error); 720 return []; 721 } 722 } 723 724 /** 725 * Record a pageview 726 */ 727 async recordPageview(userId, pubID, pageviewData) { 728 if (!this.docClient) { 729 console.error('[PV-DIAG] UserDataService: docClient is null!'); 730 throw new Error('DynamoDB client not initialized'); 731 } 732 733 try { 734 const timestamp = pageviewData.viewedAt || pageviewData.dateTime || new Date().toISOString(); 735 const ttl = Math.floor(Date.now() / 1000) + (90 * 24 * 60 * 60); // 90 days TTL 736 737 // Use articleId (post ID) for deduplication - same article = same record 738 // Falls back to URL-based ID if no articleId provided 739 const articleId = pageviewData.articleId || pageviewData.postId || 740 (pageviewData.articleUrl || pageviewData.url || '').replace(/[^a-zA-Z0-9]/g, '-'); 741 742 const item = { 743 PK: this.buildPK(userId, pubID), 744 SK: `PAGEVIEW#${articleId}`, 745 ...this.buildGSI1Keys(pubID, 'PAGEVIEW', articleId), 746 ...this.buildGSI2Keys(userId, pubID, 'PAGEVIEW', articleId), 747 articleUrl: pageviewData.articleUrl || pageviewData.url, 748 articleTitle: pageviewData.articleTitle || pageviewData.title, 749 viewedAt: timestamp, 750 readDuration: pageviewData.readDuration, 751 scrollDepth: pageviewData.scrollDepth, 752 referrer: pageviewData.referrer, 753 ttl: ttl, 754 entityType: 'PAGEVIEW' 755 }; 756 757 // Remove undefined values 758 Object.keys(item).forEach(key => { 759 if (item[key] === undefined || item[key] === null) { 760 delete item[key]; 761 } 762 }); 763 764 console.log('[PV-DIAG] UserDataService.recordPageview DynamoDB PutItem:', { 765 table: this.tableName, 766 PK: item.PK, 767 SK: item.SK, 768 GSI1PK: item.GSI1PK, 769 GSI1SK: item.GSI1SK, 770 viewedAt: item.viewedAt, 771 articleUrl: item.articleUrl, 772 }); 773 774 const params = { 775 TableName: this.tableName, 776 Item: item 777 }; 778 779 await this.docClient.put(params).promise(); 780 console.log('[PV-DIAG] DynamoDB PutItem SUCCESS'); 781 this.log('Pageview recorded:', { url: item.articleUrl, timestamp }); 782 } catch (error) { 783 console.error('[PV-DIAG] DynamoDB PutItem FAILED:', error.code, error.message); 784 this.logError('recordPageview failed:', error); 785 } 786 } 787 788 /** 789 * Record a login event (for dashboard analytics) 790 * Creates a separate item with 90-day TTL â mirrors recordPageview pattern. 791 */ 792 async recordLogin(userId, pubID) { 793 if (!this.docClient) { 794 throw new Error('DynamoDB client not initialized'); 795 } 796 797 try { 798 const timestamp = new Date().toISOString(); 799 const ttl = Math.floor(Date.now() / 1000) + (90 * 24 * 60 * 60); 800 801 const item = { 802 PK: this.buildPK(userId, pubID), 803 SK: `LOGIN#${timestamp}`, 804 ...this.buildGSI1Keys(pubID, 'LOGIN', timestamp), 805 ...this.buildGSI2Keys(userId, pubID, 'LOGIN', timestamp), 806 entityType: 'LOGIN', 807 loggedInAt: timestamp, 808 ttl, 809 }; 810 811 await this.docClient.put({ TableName: this.tableName, Item: item }).promise(); 812 this.log('Login recorded:', timestamp); 813 } catch (error) { 814 this.logError('recordLogin failed:', error); 815 } 816 } 817 818 /** 819 * Clear reading history for a user 820 */ 821 async clearReadingHistory(userId, pubID) { 822 if (!this.docClient) { 823 throw new Error('DynamoDB client not initialized'); 824 } 825 826 try { 827 // Get all pageviews 828 const pageviews = await this.getPageviews(userId, pubID, 1000); 829 830 if (pageviews.length === 0) { 831 return true; 832 } 833 834 // Delete in batches of 25 835 const chunks = this.chunkArray(pageviews, 25); 836 837 for (const chunk of chunks) { 838 const deleteRequests = chunk.map(item => ({ 839 DeleteRequest: { 840 Key: { 841 PK: item.PK, 842 SK: item.SK 843 } 844 } 845 })); 846 847 const params = { 848 RequestItems: { 849 [this.tableName]: deleteRequests 850 } 851 }; 852 853 await this.docClient.batchWrite(params).promise(); 854 } 855 856 this.log('Reading history cleared'); 857 return true; 858 } catch (error) { 859 this.logError('clearReadingHistory failed:', error); 860 throw error; 861 } 862 } 863 864 /** 865 * Remove a single pageview/reading history item 866 * @param {string} articleId - The article ID (postId or URL-derived ID) 867 */ 868 async removePageview(userId, pubID, articleId) { 869 if (!this.docClient) { 870 throw new Error('DynamoDB client not initialized'); 871 } 872 873 try { 874 const params = { 875 TableName: this.tableName, 876 Key: { 877 PK: this.buildPK(userId, pubID), 878 SK: `PAGEVIEW#${articleId}` 879 } 880 }; 881 882 this.log('Removing pageview:', params); 883 await this.docClient.delete(params).promise(); 884 this.log('Pageview removed successfully'); 885 return true; 886 } catch (error) { 887 this.logError('removePageview failed:', error); 888 throw error; 889 } 890 } 891 892 // ========================================== 893 // SUBSCRIPTION / ENTITLEMENT OPERATIONS 894 // ========================================== 895 896 /** 897 * Get subscription status 898 */ 899 async getSubscription(userId, pubID) { 900 if (!this.docClient) { 901 throw new Error('DynamoDB client not initialized'); 902 } 903 904 try { 905 const params = { 906 TableName: this.tableName, 907 Key: { 908 PK: this.buildPK(userId, pubID), 909 SK: 'SUBSCRIPTION' 910 } 911 }; 912 913 this.log('Getting subscription:', params); 914 const result = await this.docClient.get(params).promise(); 915 return result.Item || null; 916 } catch (error) { 917 this.logError('getSubscription failed:', error); 918 return null; 919 } 920 } 921 922 /** 923 * Check if user has active subscription 924 */ 925 async hasActiveSubscription(userId, pubID) { 926 try {
927 const subscription = await this.getSubscription(userId, pubID); 928 929 if (!subscription) return false; 930 931 const now = new Date(); 932 const endDate = new Date(subscription.endDate); 933 934 return subscription.status === 'active' && endDate > now; 935 } catch (error) { 936 this.logError('hasActiveSubscription failed:', error); 937 return false; 938 } 939 } 940 941 // ========================================== 942 // BULK OPERATIONS 943 // ========================================== 944 945 /** 946 * Get all user data for a specific site 947 */ 948 async getAllUserData(userId, pubID) { 949 if (!this.docClient) { 950 throw new Error('DynamoDB client not initialized'); 951 } 952 953 try { 954 const params = { 955 TableName: this.tableName, 956 KeyConditionExpression: 'PK = :pk', 957 ExpressionAttributeValues: { 958 ':pk': this.buildPK(userId, pubID) 959 } 960 }; 961 962 this.log('Getting all user data:', params); 963 const result = await this.docClient.query(params).promise(); 964 965 // Organize data by entity type 966 const data = { 967 profile: null, 968 bookmarks: [], 969 pageviews: [], 970 topics: [], 971 subscription: null 972 }; 973 974 (result.Items || []).forEach(item => { 975 if (item.SK === 'PROFILE') { 976 data.profile = item; 977 } else if (item.SK.startsWith('BOOKMARK#')) { 978 data.bookmarks.push(item); 979 } else if (item.SK.startsWith('PAGEVIEW#')) { 980 data.pageviews.push(item); 981 } else if (item.SK.startsWith('TOPIC#')) { 982 data.topics.push(item); 983 } else if (item.SK === 'SUBSCRIPTION') { 984 data.subscription = item; 985 } 986 }); 987 988 return data; 989 } catch (error) { 990 this.logError('getAllUserData failed:', error); 991 throw error; 992 } 993 } 994 995 /** 996 * Delete all user data for a specific site 997 * Queries all items with user's PK and batch deletes them 998 */ 999 async deleteAllUserData(userId, pubID) { 1000 if (!this.docClient) { 1001 throw new Error('DynamoDB client not initialized'); 1002 } 1003 1004 try { 1005 // First, query all items for this user 1006 const params = { 1007 TableName: this.tableName, 1008 KeyConditionExpression: 'PK = :pk', 1009 ExpressionAttributeValues: { 1010 ':pk': this.buildPK(userId, pubID) 1011 }, 1012 ProjectionExpression: 'PK, SK' // Only need keys for deletion 1013 }; 1014 1015 this.log('Querying all user data for deletion:', params); 1016 const result = await this.docClient.query(params).promise(); 1017 const items = result.Items || []; 1018 1019 if (items.length === 0) { 1020 this.log('No user data found to delete'); 1021 return true; 1022 } 1023 1024 this.log(`Found ${items.length} items to delete`); 1025 1026 // Batch delete in chunks of 25 (DynamoDB limit) 1027 const chunks = this.chunkArray(items, 25); 1028 1029 for (const chunk of chunks) { 1030 const deleteRequests = chunk.map(item => ({ 1031 DeleteRequest: { 1032 Key: { 1033 PK: item.PK, 1034 SK: item.SK 1035 } 1036 } 1037 })); 1038 1039 const batchParams = { 1040 RequestItems: { 1041 [this.tableName]: deleteRequests 1042 } 1043 }; 1044 1045 await this.docClient.batchWrite(batchParams).promise(); 1046 this.log(`Deleted batch of ${chunk.length} items`); 1047 } 1048 1049 this.log('All user data deleted successfully'); 1050 return true; 1051 } catch (error) { 1052 this.logError('deleteAllUserData failed:', error); 1053 throw error; 1054 } 1055 } 1056 1057 // ========================================== 1058 // UTILITY METHODS 1059 // ========================================== 1060 1061 /** 1062 * Generate a unique ID 1063 */ 1064 generateId() { 1065 return Date.now().toString(36) + Math.random().toString(36).substr(2); 1066 } 1067 1068 /** 1069 * Chunk array into smaller arrays 1070 */ 1071 chunkArray(array, size) { 1072 const chunks = []; 1073 for (let i = 0; i < array.length; i += size) { 1074 chunks.push(array.slice(i, i + size)); 1075 } 1076 return chunks; 1077 } 1078 } 1079 1080 // Export 1081 global.SwiftUserDataService = UserDataService; 1082 1083})(typeof window !== 'undefined' ? window : this);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.