PageSourceSearch

https://www.tsln.com/wp-content/plugins/swift-auth/public/js/services/UserDataService.js?ver=1780617830

js tsln.com collected 2026-10-02 03:55:13 UTC 39,082 bytes, 1,083 lines download raw bytes

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.