Skip to content

Commit e7cee0f

Browse files
committed
Replace webhooks with polling sources for Trustpilot integration
- Replaced webhook approach with 15-minute polling (webhooks not supported by Trustpilot) - Added conversation endpoints and public review endpoints to app - Created comprehensive polling base class with deduplication by reviewId + timestamp - Implemented 8 polling sources as requested: - new-service-reviews (public + private) - updated-service-reviews - new-product-reviews - updated-product-reviews - new-service-review-replies - new-product-review-replies - new-conversations - updated-conversations - Added proper business unit filtering and 24-hour lookback on first run - Skipped deleted reviews (not detectable via polling) - Maintained all existing actions (no changes needed)
1 parent 2b663f1 commit e7cee0f

File tree

18 files changed

+666
-307
lines changed

18 files changed

+666
-307
lines changed

components/trustpilot/app/trustpilot.app.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,63 @@ export default defineApp({
180180
return response.businessUnits?.map(parseBusinessUnit) || [];
181181
},
182182

183-
// Service Review methods
183+
// Public Review methods (no auth required for basic info)
184+
async getPublicServiceReviews({
185+
businessUnitId,
186+
stars = null,
187+
sortBy = SORT_OPTIONS.CREATED_AT_DESC,
188+
limit = DEFAULT_LIMIT,
189+
offset = 0,
190+
tags = [],
191+
language = null,
192+
} = {}) {
193+
if (!validateBusinessUnitId(businessUnitId)) {
194+
throw new Error("Invalid business unit ID");
195+
}
196+
197+
const endpoint = buildUrl(ENDPOINTS.PUBLIC_REVIEWS, { businessUnitId });
198+
const params = {
199+
stars,
200+
orderBy: sortBy,
201+
perPage: limit,
202+
page: Math.floor(offset / limit) + 1,
203+
language,
204+
};
205+
206+
if (tags.length > 0) {
207+
params.tags = tags.join(",");
208+
}
209+
210+
const response = await this._makeRequestWithRetry({
211+
endpoint,
212+
params,
213+
});
214+
215+
return {
216+
reviews: response.reviews?.map(parseReview) || [],
217+
pagination: {
218+
total: response.pagination?.total || 0,
219+
page: response.pagination?.page || 1,
220+
perPage: response.pagination?.perPage || limit,
221+
hasMore: response.pagination?.hasMore || false,
222+
},
223+
};
224+
},
225+
226+
async getPublicServiceReviewById({ businessUnitId, reviewId }) {
227+
if (!validateBusinessUnitId(businessUnitId)) {
228+
throw new Error("Invalid business unit ID");
229+
}
230+
if (!validateReviewId(reviewId)) {
231+
throw new Error("Invalid review ID");
232+
}
233+
234+
const endpoint = buildUrl(ENDPOINTS.PUBLIC_REVIEW_BY_ID, { businessUnitId, reviewId });
235+
const response = await this._makeRequest({ endpoint });
236+
return parseReview(response);
237+
},
238+
239+
// Private Service Review methods
184240
async getServiceReviews({
185241
businessUnitId,
186242
stars = null,
@@ -330,6 +386,66 @@ export default defineApp({
330386
return response;
331387
},
332388

389+
// Conversation methods
390+
async getConversations({
391+
limit = DEFAULT_LIMIT,
392+
offset = 0,
393+
sortBy = SORT_OPTIONS.CREATED_AT_DESC,
394+
businessUnitId = null,
395+
} = {}) {
396+
const params = {
397+
perPage: limit,
398+
page: Math.floor(offset / limit) + 1,
399+
orderBy: sortBy,
400+
};
401+
402+
if (businessUnitId) {
403+
params.businessUnitId = businessUnitId;
404+
}
405+
406+
const response = await this._makeRequestWithRetry({
407+
endpoint: ENDPOINTS.CONVERSATIONS,
408+
params,
409+
});
410+
411+
return {
412+
conversations: response.conversations || [],
413+
pagination: {
414+
total: response.pagination?.total || 0,
415+
page: response.pagination?.page || 1,
416+
perPage: response.pagination?.perPage || limit,
417+
hasMore: response.pagination?.hasMore || false,
418+
},
419+
};
420+
},
421+
422+
async getConversationById({ conversationId }) {
423+
if (!conversationId) {
424+
throw new Error("Invalid conversation ID");
425+
}
426+
427+
const endpoint = buildUrl(ENDPOINTS.CONVERSATION_BY_ID, { conversationId });
428+
const response = await this._makeRequest({ endpoint });
429+
return response;
430+
},
431+
432+
async replyToConversation({ conversationId, message }) {
433+
if (!conversationId) {
434+
throw new Error("Invalid conversation ID");
435+
}
436+
if (!message || typeof message !== 'string') {
437+
throw new Error("Reply message is required");
438+
}
439+
440+
const endpoint = buildUrl(ENDPOINTS.REPLY_TO_CONVERSATION, { conversationId });
441+
const response = await this._makeRequest({
442+
endpoint,
443+
method: "POST",
444+
data: { message },
445+
});
446+
return response;
447+
},
448+
333449
// Webhook methods
334450
async createWebhook({ url, events = [], businessUnitId = null }) {
335451
if (!url) {

components/trustpilot/common/constants.mjs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const ENDPOINTS = {
1616

1717
// Public Reviews
1818
PUBLIC_REVIEWS: "/business-units/{businessUnitId}/reviews",
19+
PUBLIC_REVIEW_BY_ID: "/business-units/{businessUnitId}/reviews/{reviewId}",
1920

2021
// Private Reviews (Service)
2122
PRIVATE_SERVICE_REVIEWS: "/private/business-units/{businessUnitId}/reviews",
@@ -27,10 +28,15 @@ export const ENDPOINTS = {
2728
PRIVATE_PRODUCT_REVIEW_BY_ID: "/private/product-reviews/{reviewId}",
2829
REPLY_TO_PRODUCT_REVIEW: "/private/product-reviews/{reviewId}/reply",
2930

31+
// Conversations
32+
CONVERSATIONS: "/private/conversations",
33+
CONVERSATION_BY_ID: "/private/conversations/{conversationId}",
34+
REPLY_TO_CONVERSATION: "/private/conversations/{conversationId}/reply",
35+
3036
// Invitations
3137
EMAIL_INVITATIONS: "/private/business-units/{businessUnitId}/email-invitations",
3238

33-
// Webhooks
39+
// Webhooks (deprecated for polling)
3440
WEBHOOKS: "/private/webhooks",
3541
WEBHOOK_BY_ID: "/private/webhooks/{webhookId}",
3642
};
@@ -75,4 +81,18 @@ export const RETRY_CONFIG = {
7581
MAX_RETRIES: 3,
7682
INITIAL_DELAY: 1000,
7783
MAX_DELAY: 10000,
84+
};
85+
86+
export const POLLING_CONFIG = {
87+
DEFAULT_TIMER_INTERVAL_SECONDS: 15 * 60, // 15 minutes
88+
MAX_ITEMS_PER_POLL: 100,
89+
LOOKBACK_HOURS: 24, // How far back to look on first run
90+
};
91+
92+
export const SOURCE_TYPES = {
93+
NEW_REVIEWS: "new_reviews",
94+
UPDATED_REVIEWS: "updated_reviews",
95+
NEW_REPLIES: "new_replies",
96+
NEW_CONVERSATIONS: "new_conversations",
97+
UPDATED_CONVERSATIONS: "updated_conversations",
7898
};
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import trustpilot from "../../app/trustpilot.app.ts";
2+
import { POLLING_CONFIG, SOURCE_TYPES } from "../../common/constants.mjs";
3+
4+
export default {
5+
props: {
6+
trustpilot,
7+
db: "$.service.db",
8+
timer: {
9+
type: "$.interface.timer",
10+
default: {
11+
intervalSeconds: POLLING_CONFIG.DEFAULT_TIMER_INTERVAL_SECONDS,
12+
},
13+
},
14+
businessUnitId: {
15+
propDefinition: [
16+
trustpilot,
17+
"businessUnitId",
18+
],
19+
optional: true,
20+
description: "Business Unit ID to filter events for. If not provided, will receive events for all business units.",
21+
},
22+
},
23+
methods: {
24+
_getLastPolled() {
25+
return this.db.get("lastPolled");
26+
},
27+
_setLastPolled(timestamp) {
28+
this.db.set("lastPolled", timestamp);
29+
},
30+
_getSeenItems() {
31+
return this.db.get("seenItems") || {};
32+
},
33+
_setSeenItems(seenItems) {
34+
this.db.set("seenItems", seenItems);
35+
},
36+
_cleanupSeenItems(seenItems, hoursToKeep = 72) {
37+
const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000);
38+
const cleaned = {};
39+
40+
Object.entries(seenItems).forEach(([key, timestamp]) => {
41+
if (timestamp > cutoff) {
42+
cleaned[key] = timestamp;
43+
}
44+
});
45+
46+
return cleaned;
47+
},
48+
getSourceType() {
49+
// Override in child classes
50+
return SOURCE_TYPES.NEW_REVIEWS;
51+
},
52+
getPollingMethod() {
53+
// Override in child classes to return the app method to call
54+
throw new Error("getPollingMethod must be implemented in child class");
55+
},
56+
getPollingParams(since) {
57+
// Override in child classes to return method-specific parameters
58+
return {
59+
businessUnitId: this.businessUnitId,
60+
limit: POLLING_CONFIG.MAX_ITEMS_PER_POLL,
61+
sortBy: "createdat.desc", // Most recent first
62+
};
63+
},
64+
isNewItem(item, sourceType) {
65+
// For "new" sources, check creation date
66+
// For "updated" sources, check update date
67+
const itemDate = sourceType.includes("updated")
68+
? new Date(item.updatedAt)
69+
: new Date(item.createdAt || item.updatedAt);
70+
71+
const lastPolled = this._getLastPolled();
72+
return !lastPolled || itemDate > new Date(lastPolled);
73+
},
74+
generateDedupeKey(item, sourceType) {
75+
// Create unique key: itemId + relevant timestamp
76+
const timestamp = sourceType.includes("updated")
77+
? item.updatedAt
78+
: (item.createdAt || item.updatedAt);
79+
80+
return `${item.id}_${timestamp}`;
81+
},
82+
generateMeta(item, sourceType) {
83+
const dedupeKey = this.generateDedupeKey(item, sourceType);
84+
const summary = this.generateSummary(item, sourceType);
85+
const timestamp = sourceType.includes("updated")
86+
? item.updatedAt
87+
: (item.createdAt || item.updatedAt);
88+
89+
return {
90+
id: dedupeKey,
91+
summary,
92+
ts: new Date(timestamp).getTime(),
93+
};
94+
},
95+
generateSummary(item, sourceType) {
96+
// Override in child classes for specific summaries
97+
return `${sourceType} - ${item.id}`;
98+
},
99+
async fetchItems(since) {
100+
const method = this.getPollingMethod();
101+
const params = this.getPollingParams(since);
102+
103+
try {
104+
const result = await this.trustpilot[method](params);
105+
106+
// Handle different response formats
107+
if (result.reviews) {
108+
return result.reviews;
109+
} else if (result.conversations) {
110+
return result.conversations;
111+
} else if (Array.isArray(result)) {
112+
return result;
113+
} else {
114+
return [];
115+
}
116+
} catch (error) {
117+
console.error(`Error fetching items with ${method}:`, error);
118+
throw error;
119+
}
120+
},
121+
async pollForItems() {
122+
const sourceType = this.getSourceType();
123+
const lastPolled = this._getLastPolled();
124+
const seenItems = this._getSeenItems();
125+
126+
// If first run, look back 24 hours
127+
const since = lastPolled || new Date(Date.now() - (POLLING_CONFIG.LOOKBACK_HOURS * 60 * 60 * 1000)).toISOString();
128+
129+
console.log(`Polling for ${sourceType} since ${since}`);
130+
131+
try {
132+
const items = await this.fetchItems(since);
133+
const newItems = [];
134+
const currentTime = Date.now();
135+
136+
for (const item of items) {
137+
// Check if item is new based on source type
138+
if (this.isNewItem(item, sourceType)) {
139+
const dedupeKey = this.generateDedupeKey(item, sourceType);
140+
141+
// Check if we've already seen this exact item+timestamp
142+
if (!seenItems[dedupeKey]) {
143+
seenItems[dedupeKey] = currentTime;
144+
newItems.push(item);
145+
}
146+
}
147+
}
148+
149+
// Emit new items
150+
for (const item of newItems.reverse()) { // Oldest first
151+
const meta = this.generateMeta(item, sourceType);
152+
this.$emit(item, meta);
153+
}
154+
155+
// Update state
156+
this._setLastPolled(new Date().toISOString());
157+
this._setSeenItems(this._cleanupSeenItems(seenItems));
158+
159+
console.log(`Found ${newItems.length} new items of type ${sourceType}`);
160+
161+
} catch (error) {
162+
console.error(`Polling failed for ${sourceType}:`, error);
163+
throw error;
164+
}
165+
},
166+
},
167+
async run() {
168+
await this.pollForItems();
169+
},
170+
};

0 commit comments

Comments
 (0)