From 47cee75d38d1b613108f66e23d7cd0f0feaeada5 Mon Sep 17 00:00:00 2001 From: seynadio <79858321+seynadio@users.noreply.github.com> Date: Mon, 14 Jul 2025 17:04:50 +0200 Subject: [PATCH 1/2] Implement comprehensive Trustpilot integration with polling sources ## Overview Complete Trustpilot integration using polling approach (webhooks not supported by Trustpilot API). ## Features Implemented ### Authentication & Core App - Enhanced trustpilot.app.ts with OAuth and API key authentication - Added comprehensive API methods for reviews, products, and conversations - Support for both public and private endpoints - Proper error handling, validation, and retry logic with rate limiting ### Actions (6 total) - **fetch-service-reviews** - Get service reviews with filtering and pagination - **fetch-service-review-by-id** - Get specific service review - **fetch-product-reviews** - Get product reviews with filtering and pagination - **fetch-product-review-by-id** - Get specific product review - **reply-to-service-review** - Reply to service reviews - **reply-to-product-review** - Reply to product reviews ### Polling Sources (8 total) - **new-service-reviews** - New service reviews (public + private endpoints) - **updated-service-reviews** - Updated/revised service reviews - **new-product-reviews** - New product reviews - **updated-product-reviews** - Updated/revised product reviews - **new-service-review-replies** - New replies to service reviews - **new-product-review-replies** - New replies to product reviews - **new-conversations** - New conversations started - **updated-conversations** - Updated conversations (new messages) ### Technical Implementation - 15-minute polling intervals following Google Drive pattern - Smart deduplication by reviewId + timestamp - Business unit filtering (optional) - 24-hour lookback on first run - Comprehensive constants and utilities - Proper pagination and error handling ## API Endpoints Used - `/business-units/{businessUnitId}/reviews` (public) - `/private/business-units/{businessUnitId}/reviews` (private service) - `/private/product-reviews/business-units/{businessUnitId}/reviews` (products) - `/private/conversations` (conversations) - All reply endpoints for posting responses Addresses all requirements from https://developers.trustpilot.com/introduction/ --- components/trustpilot/.gitignore | 1 - .../fetch-product-review-by-id.mjs | 39 ++ .../fetch-product-reviews.mjs | 109 ++++ .../fetch-service-review-by-id.mjs | 50 ++ .../fetch-service-reviews.mjs | 109 ++++ .../reply-to-product-review.mjs | 54 ++ .../reply-to-service-review.mjs | 63 +++ components/trustpilot/app/trustpilot.app.ts | 503 +++++++++++++++++- components/trustpilot/common/constants.mjs | 98 ++++ components/trustpilot/common/utils.mjs | 161 ++++++ components/trustpilot/package.json | 3 + .../trustpilot/sources/common/polling.mjs | 170 ++++++ .../new-conversations/new-conversations.mjs | 38 ++ .../new-product-review-replies.mjs | 68 +++ .../new-product-reviews.mjs | 37 ++ .../new-service-review-replies.mjs | 66 +++ .../new-service-reviews.mjs | 37 ++ .../updated-conversations.mjs | 39 ++ .../updated-product-reviews.mjs | 37 ++ .../updated-service-reviews.mjs | 36 ++ 20 files changed, 1714 insertions(+), 4 deletions(-) create mode 100644 components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs create mode 100644 components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs create mode 100644 components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs create mode 100644 components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs create mode 100644 components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs create mode 100644 components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs create mode 100644 components/trustpilot/common/constants.mjs create mode 100644 components/trustpilot/common/utils.mjs create mode 100644 components/trustpilot/sources/common/polling.mjs create mode 100644 components/trustpilot/sources/new-conversations/new-conversations.mjs create mode 100644 components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs create mode 100644 components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs create mode 100644 components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs create mode 100644 components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs create mode 100644 components/trustpilot/sources/updated-conversations/updated-conversations.mjs create mode 100644 components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs create mode 100644 components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs diff --git a/components/trustpilot/.gitignore b/components/trustpilot/.gitignore index ec761ccab7595..650d0178990b0 100644 --- a/components/trustpilot/.gitignore +++ b/components/trustpilot/.gitignore @@ -1,3 +1,2 @@ *.js -*.mjs dist \ No newline at end of file diff --git a/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs b/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs new file mode 100644 index 0000000000000..dc8c28adfdf6d --- /dev/null +++ b/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs @@ -0,0 +1,39 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +export default { + key: "trustpilot-fetch-product-review-by-id", + name: "Fetch Product Review by ID", + description: "Fetch a specific product review by its ID. [See the documentation](https://developers.trustpilot.com/product-reviews-api#get-private-product-review)", + version: "0.0.1", + type: "action", + props: { + trustpilot, + reviewId: { + propDefinition: [ + trustpilot, + "reviewId", + ], + }, + }, + async run({ $ }) { + const { reviewId } = this; + + try { + const review = await this.trustpilot.getProductReviewById({ + reviewId, + }); + + $.export("$summary", `Successfully fetched product review ${reviewId}`); + + return { + review, + metadata: { + reviewId, + requestTime: new Date().toISOString(), + }, + }; + } catch (error) { + throw new Error(`Failed to fetch product review: ${error.message}`); + } + }, +}; \ No newline at end of file diff --git a/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs b/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs new file mode 100644 index 0000000000000..6dfc73b29c38b --- /dev/null +++ b/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs @@ -0,0 +1,109 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +export default { + key: "trustpilot-fetch-product-reviews", + name: "Fetch Product Reviews", + description: "Fetch product reviews for a business unit. [See the documentation](https://developers.trustpilot.com/product-reviews-api#get-private-product-reviews)", + version: "0.0.1", + type: "action", + props: { + trustpilot, + businessUnitId: { + propDefinition: [ + trustpilot, + "businessUnitId", + ], + }, + stars: { + propDefinition: [ + trustpilot, + "stars", + ], + }, + sortBy: { + propDefinition: [ + trustpilot, + "sortBy", + ], + }, + limit: { + propDefinition: [ + trustpilot, + "limit", + ], + }, + includeReportedReviews: { + propDefinition: [ + trustpilot, + "includeReportedReviews", + ], + }, + tags: { + propDefinition: [ + trustpilot, + "tags", + ], + }, + language: { + propDefinition: [ + trustpilot, + "language", + ], + }, + offset: { + type: "integer", + label: "Offset", + description: "Number of results to skip (for pagination)", + min: 0, + default: 0, + optional: true, + }, + }, + async run({ $ }) { + const { + businessUnitId, + stars, + sortBy, + limit, + includeReportedReviews, + tags, + language, + offset, + } = this; + + try { + const result = await this.trustpilot.getProductReviews({ + businessUnitId, + stars, + sortBy, + limit, + includeReportedReviews, + tags, + language, + offset, + }); + + const { reviews, pagination } = result; + + $.export("$summary", `Successfully fetched ${reviews.length} product review(s) for business unit ${businessUnitId}`); + + return { + reviews, + pagination, + metadata: { + businessUnitId, + filters: { + stars, + sortBy, + includeReportedReviews, + tags, + language, + }, + requestTime: new Date().toISOString(), + }, + }; + } catch (error) { + throw new Error(`Failed to fetch product reviews: ${error.message}`); + } + }, +}; \ No newline at end of file diff --git a/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs b/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs new file mode 100644 index 0000000000000..608bba91acca8 --- /dev/null +++ b/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs @@ -0,0 +1,50 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +export default { + key: "trustpilot-fetch-service-review-by-id", + name: "Fetch Service Review by ID", + description: "Fetch a specific service review by its ID. [See the documentation](https://developers.trustpilot.com/business-units-api#get-business-unit-review)", + version: "0.0.1", + type: "action", + props: { + trustpilot, + businessUnitId: { + propDefinition: [ + trustpilot, + "businessUnitId", + ], + }, + reviewId: { + propDefinition: [ + trustpilot, + "reviewId", + ], + }, + }, + async run({ $ }) { + const { + businessUnitId, + reviewId, + } = this; + + try { + const review = await this.trustpilot.getServiceReviewById({ + businessUnitId, + reviewId, + }); + + $.export("$summary", `Successfully fetched service review ${reviewId} for business unit ${businessUnitId}`); + + return { + review, + metadata: { + businessUnitId, + reviewId, + requestTime: new Date().toISOString(), + }, + }; + } catch (error) { + throw new Error(`Failed to fetch service review: ${error.message}`); + } + }, +}; \ No newline at end of file diff --git a/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs b/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs new file mode 100644 index 0000000000000..a5cb476bf5d32 --- /dev/null +++ b/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs @@ -0,0 +1,109 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +export default { + key: "trustpilot-fetch-service-reviews", + name: "Fetch Service Reviews", + description: "Fetch service reviews for a business unit. [See the documentation](https://developers.trustpilot.com/business-units-api#get-business-unit-reviews)", + version: "0.0.1", + type: "action", + props: { + trustpilot, + businessUnitId: { + propDefinition: [ + trustpilot, + "businessUnitId", + ], + }, + stars: { + propDefinition: [ + trustpilot, + "stars", + ], + }, + sortBy: { + propDefinition: [ + trustpilot, + "sortBy", + ], + }, + limit: { + propDefinition: [ + trustpilot, + "limit", + ], + }, + includeReportedReviews: { + propDefinition: [ + trustpilot, + "includeReportedReviews", + ], + }, + tags: { + propDefinition: [ + trustpilot, + "tags", + ], + }, + language: { + propDefinition: [ + trustpilot, + "language", + ], + }, + offset: { + type: "integer", + label: "Offset", + description: "Number of results to skip (for pagination)", + min: 0, + default: 0, + optional: true, + }, + }, + async run({ $ }) { + const { + businessUnitId, + stars, + sortBy, + limit, + includeReportedReviews, + tags, + language, + offset, + } = this; + + try { + const result = await this.trustpilot.getServiceReviews({ + businessUnitId, + stars, + sortBy, + limit, + includeReportedReviews, + tags, + language, + offset, + }); + + const { reviews, pagination } = result; + + $.export("$summary", `Successfully fetched ${reviews.length} service review(s) for business unit ${businessUnitId}`); + + return { + reviews, + pagination, + metadata: { + businessUnitId, + filters: { + stars, + sortBy, + includeReportedReviews, + tags, + language, + }, + requestTime: new Date().toISOString(), + }, + }; + } catch (error) { + throw new Error(`Failed to fetch service reviews: ${error.message}`); + } + }, +}; \ No newline at end of file diff --git a/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs b/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs new file mode 100644 index 0000000000000..64c7543626e28 --- /dev/null +++ b/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs @@ -0,0 +1,54 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +export default { + key: "trustpilot-reply-to-product-review", + name: "Reply to Product Review", + description: "Reply to a product review on behalf of your business. [See the documentation](https://developers.trustpilot.com/product-reviews-api#reply-to-product-review)", + version: "0.0.1", + type: "action", + props: { + trustpilot, + reviewId: { + propDefinition: [ + trustpilot, + "reviewId", + ], + }, + message: { + type: "string", + label: "Reply Message", + description: "The message to reply to the review with", + }, + }, + async run({ $ }) { + const { + reviewId, + message, + } = this; + + if (!message || message.trim().length === 0) { + throw new Error("Reply message cannot be empty"); + } + + try { + const result = await this.trustpilot.replyToProductReview({ + reviewId, + message: message.trim(), + }); + + $.export("$summary", `Successfully replied to product review ${reviewId}`); + + return { + success: true, + reply: result, + metadata: { + reviewId, + messageLength: message.trim().length, + requestTime: new Date().toISOString(), + }, + }; + } catch (error) { + throw new Error(`Failed to reply to product review: ${error.message}`); + } + }, +}; \ No newline at end of file diff --git a/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs b/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs new file mode 100644 index 0000000000000..62a1e3205c0f0 --- /dev/null +++ b/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs @@ -0,0 +1,63 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +export default { + key: "trustpilot-reply-to-service-review", + name: "Reply to Service Review", + description: "Reply to a service review on behalf of your business. [See the documentation](https://developers.trustpilot.com/business-units-api#reply-to-review)", + version: "0.0.1", + type: "action", + props: { + trustpilot, + businessUnitId: { + propDefinition: [ + trustpilot, + "businessUnitId", + ], + }, + reviewId: { + propDefinition: [ + trustpilot, + "reviewId", + ], + }, + message: { + type: "string", + label: "Reply Message", + description: "The message to reply to the review with", + }, + }, + async run({ $ }) { + const { + businessUnitId, + reviewId, + message, + } = this; + + if (!message || message.trim().length === 0) { + throw new Error("Reply message cannot be empty"); + } + + try { + const result = await this.trustpilot.replyToServiceReview({ + businessUnitId, + reviewId, + message: message.trim(), + }); + + $.export("$summary", `Successfully replied to service review ${reviewId}`); + + return { + success: true, + reply: result, + metadata: { + businessUnitId, + reviewId, + messageLength: message.trim().length, + requestTime: new Date().toISOString(), + }, + }; + } catch (error) { + throw new Error(`Failed to reply to service review: ${error.message}`); + } + }, +}; \ No newline at end of file diff --git a/components/trustpilot/app/trustpilot.app.ts b/components/trustpilot/app/trustpilot.app.ts index 3f00f2e9d4a85..51cd370068687 100644 --- a/components/trustpilot/app/trustpilot.app.ts +++ b/components/trustpilot/app/trustpilot.app.ts @@ -1,13 +1,510 @@ import { defineApp } from "@pipedream/types"; +import { axios } from "@pipedream/platform"; +import { + BASE_URL, + ENDPOINTS, + DEFAULT_LIMIT, + MAX_LIMIT, + SORT_OPTIONS, + RATING_SCALE, + RETRY_CONFIG, + HTTP_STATUS, +} from "../common/constants.mjs"; +import { + buildUrl, + parseReview, + parseBusinessUnit, + parseWebhookPayload, + validateBusinessUnitId, + validateReviewId, + formatQueryParams, + parseApiError, + sleep, +} from "../common/utils.mjs"; export default defineApp({ type: "app", app: "trustpilot", - propDefinitions: {}, + propDefinitions: { + businessUnitId: { + type: "string", + label: "Business Unit ID", + description: "The unique identifier for your business unit on Trustpilot", + async options() { + try { + const businessUnits = await this.searchBusinessUnits({ + query: "", + limit: 20, + }); + return businessUnits.map(unit => ({ + label: unit.displayName, + value: unit.id, + })); + } catch (error) { + console.error("Error fetching business units:", error); + return []; + } + }, + }, + reviewId: { + type: "string", + label: "Review ID", + description: "The unique identifier for a review", + }, + stars: { + type: "integer", + label: "Star Rating", + description: "Filter by star rating (1-5)", + options: RATING_SCALE, + optional: true, + }, + sortBy: { + type: "string", + label: "Sort By", + description: "How to sort the results", + options: Object.entries(SORT_OPTIONS).map(([key, value]) => ({ + label: key.replace(/_/g, " ").toLowerCase(), + value, + })), + optional: true, + default: SORT_OPTIONS.CREATED_AT_DESC, + }, + limit: { + type: "integer", + label: "Limit", + description: "Maximum number of results to return", + min: 1, + max: MAX_LIMIT, + default: DEFAULT_LIMIT, + optional: true, + }, + includeReportedReviews: { + type: "boolean", + label: "Include Reported Reviews", + description: "Whether to include reviews that have been reported", + default: false, + optional: true, + }, + tags: { + type: "string[]", + label: "Tags", + description: "Filter reviews by tags", + optional: true, + }, + language: { + type: "string", + label: "Language", + description: "Filter reviews by language (ISO 639-1 code)", + optional: true, + }, + }, methods: { - // this.$auth contains connected account data + // Authentication and base request methods + _getAuthHeaders() { + const headers = { + "Content-Type": "application/json", + "User-Agent": "Pipedream/1.0", + }; + + if (this.$auth?.api_key) { + headers["apikey"] = this.$auth.api_key; + } + + if (this.$auth?.oauth_access_token) { + headers["Authorization"] = `Bearer ${this.$auth.oauth_access_token}`; + } + + return headers; + }, + + async _makeRequest({ endpoint, method = "GET", params = {}, data = null, ...args }) { + const url = `${BASE_URL}${endpoint}`; + const headers = this._getAuthHeaders(); + + const config = { + method, + url, + headers, + params: formatQueryParams(params), + timeout: 30000, + ...args, + }; + + if (data) { + config.data = data; + } + + try { + const response = await axios(this, config); + return response.data || response; + } catch (error) { + const parsedError = parseApiError(error); + throw new Error(`Trustpilot API Error: ${parsedError.message} (${parsedError.code})`); + } + }, + + async _makeRequestWithRetry(config, retries = RETRY_CONFIG.MAX_RETRIES) { + try { + return await this._makeRequest(config); + } catch (error) { + if (retries > 0 && error.response?.status === HTTP_STATUS.TOO_MANY_REQUESTS) { + const delay = Math.min(RETRY_CONFIG.INITIAL_DELAY * (RETRY_CONFIG.MAX_RETRIES - retries + 1), RETRY_CONFIG.MAX_DELAY); + await sleep(delay); + return this._makeRequestWithRetry(config, retries - 1); + } + throw error; + } + }, + + // Business Unit methods + async getBusinessUnit(businessUnitId) { + if (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID"); + } + + const endpoint = buildUrl(ENDPOINTS.BUSINESS_UNIT_BY_ID, { businessUnitId }); + const response = await this._makeRequest({ endpoint }); + return parseBusinessUnit(response); + }, + + async searchBusinessUnits({ query = "", limit = DEFAULT_LIMIT, offset = 0 } = {}) { + const response = await this._makeRequest({ + endpoint: ENDPOINTS.BUSINESS_UNITS, + params: { + query, + limit, + offset, + }, + }); + + return response.businessUnits?.map(parseBusinessUnit) || []; + }, + + // Public Review methods (no auth required for basic info) + async getPublicServiceReviews({ + businessUnitId, + stars = null, + sortBy = SORT_OPTIONS.CREATED_AT_DESC, + limit = DEFAULT_LIMIT, + offset = 0, + tags = [], + language = null, + } = {}) { + if (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID"); + } + + const endpoint = buildUrl(ENDPOINTS.PUBLIC_REVIEWS, { businessUnitId }); + const params = { + stars, + orderBy: sortBy, + perPage: limit, + page: Math.floor(offset / limit) + 1, + language, + }; + + if (tags.length > 0) { + params.tags = tags.join(","); + } + + const response = await this._makeRequestWithRetry({ + endpoint, + params, + }); + + return { + reviews: response.reviews?.map(parseReview) || [], + pagination: { + total: response.pagination?.total || 0, + page: response.pagination?.page || 1, + perPage: response.pagination?.perPage || limit, + hasMore: response.pagination?.hasMore || false, + }, + }; + }, + + async getPublicServiceReviewById({ businessUnitId, reviewId }) { + if (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID"); + } + if (!validateReviewId(reviewId)) { + throw new Error("Invalid review ID"); + } + + const endpoint = buildUrl(ENDPOINTS.PUBLIC_REVIEW_BY_ID, { businessUnitId, reviewId }); + const response = await this._makeRequest({ endpoint }); + return parseReview(response); + }, + + // Private Service Review methods + async getServiceReviews({ + businessUnitId, + stars = null, + sortBy = SORT_OPTIONS.CREATED_AT_DESC, + limit = DEFAULT_LIMIT, + offset = 0, + includeReportedReviews = false, + tags = [], + language = null, + } = {}) { + if (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID"); + } + + const endpoint = buildUrl(ENDPOINTS.PRIVATE_SERVICE_REVIEWS, { businessUnitId }); + const params = { + stars, + orderBy: sortBy, + perPage: limit, + page: Math.floor(offset / limit) + 1, + includeReportedReviews, + language, + }; + + if (tags.length > 0) { + params.tags = tags.join(","); + } + + const response = await this._makeRequestWithRetry({ + endpoint, + params, + }); + + return { + reviews: response.reviews?.map(parseReview) || [], + pagination: { + total: response.pagination?.total || 0, + page: response.pagination?.page || 1, + perPage: response.pagination?.perPage || limit, + hasMore: response.pagination?.hasMore || false, + }, + }; + }, + + async getServiceReviewById({ businessUnitId, reviewId }) { + if (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID"); + } + if (!validateReviewId(reviewId)) { + throw new Error("Invalid review ID"); + } + + const endpoint = buildUrl(ENDPOINTS.PRIVATE_SERVICE_REVIEW_BY_ID, { businessUnitId, reviewId }); + const response = await this._makeRequest({ endpoint }); + return parseReview(response); + }, + + async replyToServiceReview({ businessUnitId, reviewId, message }) { + if (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID"); + } + if (!validateReviewId(reviewId)) { + throw new Error("Invalid review ID"); + } + if (!message || typeof message !== 'string') { + throw new Error("Reply message is required"); + } + + const endpoint = buildUrl(ENDPOINTS.REPLY_TO_SERVICE_REVIEW, { businessUnitId, reviewId }); + const response = await this._makeRequest({ + endpoint, + method: "POST", + data: { message }, + }); + return response; + }, + + // Product Review methods + async getProductReviews({ + businessUnitId, + stars = null, + sortBy = SORT_OPTIONS.CREATED_AT_DESC, + limit = DEFAULT_LIMIT, + offset = 0, + includeReportedReviews = false, + tags = [], + language = null, + } = {}) { + if (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID"); + } + + const endpoint = buildUrl(ENDPOINTS.PRIVATE_PRODUCT_REVIEWS, { businessUnitId }); + const params = { + stars, + orderBy: sortBy, + perPage: limit, + page: Math.floor(offset / limit) + 1, + includeReportedReviews, + language, + }; + + if (tags.length > 0) { + params.tags = tags.join(","); + } + + const response = await this._makeRequestWithRetry({ + endpoint, + params, + }); + + return { + reviews: response.reviews?.map(parseReview) || [], + pagination: { + total: response.pagination?.total || 0, + page: response.pagination?.page || 1, + perPage: response.pagination?.perPage || limit, + hasMore: response.pagination?.hasMore || false, + }, + }; + }, + + async getProductReviewById({ reviewId }) { + if (!validateReviewId(reviewId)) { + throw new Error("Invalid review ID"); + } + + const endpoint = buildUrl(ENDPOINTS.PRIVATE_PRODUCT_REVIEW_BY_ID, { reviewId }); + const response = await this._makeRequest({ endpoint }); + return parseReview(response); + }, + + async replyToProductReview({ reviewId, message }) { + if (!validateReviewId(reviewId)) { + throw new Error("Invalid review ID"); + } + if (!message || typeof message !== 'string') { + throw new Error("Reply message is required"); + } + + const endpoint = buildUrl(ENDPOINTS.REPLY_TO_PRODUCT_REVIEW, { reviewId }); + const response = await this._makeRequest({ + endpoint, + method: "POST", + data: { message }, + }); + return response; + }, + + // Conversation methods + async getConversations({ + limit = DEFAULT_LIMIT, + offset = 0, + sortBy = SORT_OPTIONS.CREATED_AT_DESC, + businessUnitId = null, + } = {}) { + const params = { + perPage: limit, + page: Math.floor(offset / limit) + 1, + orderBy: sortBy, + }; + + if (businessUnitId) { + params.businessUnitId = businessUnitId; + } + + const response = await this._makeRequestWithRetry({ + endpoint: ENDPOINTS.CONVERSATIONS, + params, + }); + + return { + conversations: response.conversations || [], + pagination: { + total: response.pagination?.total || 0, + page: response.pagination?.page || 1, + perPage: response.pagination?.perPage || limit, + hasMore: response.pagination?.hasMore || false, + }, + }; + }, + + async getConversationById({ conversationId }) { + if (!conversationId) { + throw new Error("Invalid conversation ID"); + } + + const endpoint = buildUrl(ENDPOINTS.CONVERSATION_BY_ID, { conversationId }); + const response = await this._makeRequest({ endpoint }); + return response; + }, + + async replyToConversation({ conversationId, message }) { + if (!conversationId) { + throw new Error("Invalid conversation ID"); + } + if (!message || typeof message !== 'string') { + throw new Error("Reply message is required"); + } + + const endpoint = buildUrl(ENDPOINTS.REPLY_TO_CONVERSATION, { conversationId }); + const response = await this._makeRequest({ + endpoint, + method: "POST", + data: { message }, + }); + return response; + }, + + // Webhook methods + async createWebhook({ url, events = [], businessUnitId = null }) { + if (!url) { + throw new Error("Webhook URL is required"); + } + if (!Array.isArray(events) || events.length === 0) { + throw new Error("At least one event must be specified"); + } + + const data = { + url, + events, + }; + + if (businessUnitId) { + data.businessUnitId = businessUnitId; + } + + const response = await this._makeRequest({ + endpoint: ENDPOINTS.WEBHOOKS, + method: "POST", + data, + }); + return response; + }, + + async deleteWebhook(webhookId) { + if (!webhookId) { + throw new Error("Webhook ID is required"); + } + + const endpoint = buildUrl(ENDPOINTS.WEBHOOK_BY_ID, { webhookId }); + await this._makeRequest({ + endpoint, + method: "DELETE", + }); + }, + + async listWebhooks() { + const response = await this._makeRequest({ + endpoint: ENDPOINTS.WEBHOOKS, + }); + return response.webhooks || []; + }, + + // Utility methods + parseWebhookPayload(payload) { + return parseWebhookPayload(payload); + }, + + validateWebhookSignature(payload, signature, secret) { + // TODO: Implement webhook signature validation when Trustpilot provides it + return true; + }, + + // Legacy method for debugging authKeys() { - console.log(Object.keys(this.$auth)); + console.log("Auth keys:", Object.keys(this.$auth || {})); + return Object.keys(this.$auth || {}); }, }, }); \ No newline at end of file diff --git a/components/trustpilot/common/constants.mjs b/components/trustpilot/common/constants.mjs new file mode 100644 index 0000000000000..054390855fd8d --- /dev/null +++ b/components/trustpilot/common/constants.mjs @@ -0,0 +1,98 @@ +export const BASE_URL = "https://api.trustpilot.com/v1"; + +export const WEBHOOK_EVENTS = { + REVIEW_CREATED: "review.created", + REVIEW_REVISED: "review.revised", + REVIEW_DELETED: "review.deleted", + REPLY_CREATED: "reply.created", + INVITATION_SENT: "invitation.sent", + INVITATION_FAILED: "invitation.failed", +}; + +export const ENDPOINTS = { + // Business Units + BUSINESS_UNITS: "/business-units", + BUSINESS_UNIT_BY_ID: "/business-units/{businessUnitId}", + + // Public Reviews + PUBLIC_REVIEWS: "/business-units/{businessUnitId}/reviews", + PUBLIC_REVIEW_BY_ID: "/business-units/{businessUnitId}/reviews/{reviewId}", + + // Private Reviews (Service) + PRIVATE_SERVICE_REVIEWS: "/private/business-units/{businessUnitId}/reviews", + PRIVATE_SERVICE_REVIEW_BY_ID: "/private/business-units/{businessUnitId}/reviews/{reviewId}", + REPLY_TO_SERVICE_REVIEW: "/private/business-units/{businessUnitId}/reviews/{reviewId}/reply", + + // Private Reviews (Product) + PRIVATE_PRODUCT_REVIEWS: "/private/product-reviews/business-units/{businessUnitId}/reviews", + PRIVATE_PRODUCT_REVIEW_BY_ID: "/private/product-reviews/{reviewId}", + REPLY_TO_PRODUCT_REVIEW: "/private/product-reviews/{reviewId}/reply", + + // Conversations + CONVERSATIONS: "/private/conversations", + CONVERSATION_BY_ID: "/private/conversations/{conversationId}", + REPLY_TO_CONVERSATION: "/private/conversations/{conversationId}/reply", + + // Invitations + EMAIL_INVITATIONS: "/private/business-units/{businessUnitId}/email-invitations", + + // Webhooks (deprecated for polling) + WEBHOOKS: "/private/webhooks", + WEBHOOK_BY_ID: "/private/webhooks/{webhookId}", +}; + +export const REVIEW_TYPES = { + SERVICE: "service", + PRODUCT: "product", +}; + +export const INVITATION_TYPES = { + REVIEW: "review", + PRODUCT_REVIEW: "product-review", +}; + +export const SORT_OPTIONS = { + CREATED_AT_ASC: "createdat.asc", + CREATED_AT_DESC: "createdat.desc", + STARS_ASC: "stars.asc", + STARS_DESC: "stars.desc", + UPDATED_AT_ASC: "updatedat.asc", + UPDATED_AT_DESC: "updatedat.desc", +}; + +export const RATING_SCALE = [1, 2, 3, 4, 5]; + +export const DEFAULT_LIMIT = 20; +export const MAX_LIMIT = 100; + +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, +}; + +export const RETRY_CONFIG = { + MAX_RETRIES: 3, + INITIAL_DELAY: 1000, + MAX_DELAY: 10000, +}; + +export const POLLING_CONFIG = { + DEFAULT_TIMER_INTERVAL_SECONDS: 15 * 60, // 15 minutes + MAX_ITEMS_PER_POLL: 100, + LOOKBACK_HOURS: 24, // How far back to look on first run +}; + +export const SOURCE_TYPES = { + NEW_REVIEWS: "new_reviews", + UPDATED_REVIEWS: "updated_reviews", + NEW_REPLIES: "new_replies", + NEW_CONVERSATIONS: "new_conversations", + UPDATED_CONVERSATIONS: "updated_conversations", +}; \ No newline at end of file diff --git a/components/trustpilot/common/utils.mjs b/components/trustpilot/common/utils.mjs new file mode 100644 index 0000000000000..71c85a9a5ba24 --- /dev/null +++ b/components/trustpilot/common/utils.mjs @@ -0,0 +1,161 @@ +import { ENDPOINTS } from "./constants.mjs"; + +/** + * Build URL from endpoint template and parameters + * @param {string} endpoint - Endpoint template with placeholders + * @param {object} params - Parameters to replace in the endpoint + * @returns {string} - Complete URL with parameters replaced + */ +export function buildUrl(endpoint, params = {}) { + let url = endpoint; + + // Replace path parameters + Object.entries(params).forEach(([key, value]) => { + url = url.replace(`{${key}}`, value); + }); + + return url; +} + +/** + * Parse Trustpilot review data + * @param {object} review - Raw review data from API + * @returns {object} - Parsed review data + */ +export function parseReview(review) { + return { + id: review.id, + stars: review.stars, + title: review.title, + text: review.text, + language: review.language, + location: review.location, + tags: review.tags || [], + createdAt: review.createdAt, + updatedAt: review.updatedAt, + consumer: { + id: review.consumer?.id, + displayName: review.consumer?.displayName, + numberOfReviews: review.consumer?.numberOfReviews, + }, + company: { + reply: review.companyReply ? { + text: review.companyReply.text, + createdAt: review.companyReply.createdAt, + } : null, + }, + imported: review.imported || false, + verified: review.verified || false, + url: review.url, + }; +} + +/** + * Parse Trustpilot business unit data + * @param {object} businessUnit - Raw business unit data from API + * @returns {object} - Parsed business unit data + */ +export function parseBusinessUnit(businessUnit) { + return { + id: businessUnit.id, + displayName: businessUnit.displayName, + identifyingName: businessUnit.identifyingName, + trustScore: businessUnit.trustScore, + stars: businessUnit.stars, + numberOfReviews: businessUnit.numberOfReviews, + profileUrl: businessUnit.profileUrl, + websiteUrl: businessUnit.websiteUrl, + country: businessUnit.country, + status: businessUnit.status, + createdAt: businessUnit.createdAt, + categories: businessUnit.categories || [], + images: businessUnit.images || [], + }; +} + +/** + * Parse webhook payload + * @param {object} payload - Raw webhook payload + * @returns {object} - Parsed webhook data + */ +export function parseWebhookPayload(payload) { + const { event, data } = payload; + + return { + event: event?.type || payload.eventType, + timestamp: event?.timestamp || payload.timestamp, + businessUnitId: data?.businessUnit?.id || payload.businessUnitId, + reviewId: data?.review?.id || payload.reviewId, + consumerId: data?.consumer?.id || payload.consumerId, + data: data || payload.data, + raw: payload, + }; +} + +/** + * Validate business unit ID format + * @param {string} businessUnitId - Business unit ID to validate + * @returns {boolean} - Whether the ID is valid + */ +export function validateBusinessUnitId(businessUnitId) { + return businessUnitId && typeof businessUnitId === 'string' && businessUnitId.length > 0; +} + +/** + * Validate review ID format + * @param {string} reviewId - Review ID to validate + * @returns {boolean} - Whether the ID is valid + */ +export function validateReviewId(reviewId) { + return reviewId && typeof reviewId === 'string' && reviewId.length > 0; +} + +/** + * Format query parameters for API requests + * @param {object} params - Query parameters + * @returns {object} - Formatted parameters + */ +export function formatQueryParams(params) { + const formatted = {}; + + Object.entries(params).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + formatted[key] = value; + } + }); + + return formatted; +} + +/** + * Parse error response from Trustpilot API + * @param {object} error - Error object from API + * @returns {object} - Parsed error + */ +export function parseApiError(error) { + if (error.response) { + const { status, data } = error.response; + return { + status, + message: data?.message || data?.error || 'API Error', + details: data?.details || data?.errors || [], + code: data?.code || `HTTP_${status}`, + }; + } + + return { + status: 0, + message: error.message || 'Unknown error', + details: [], + code: 'UNKNOWN_ERROR', + }; +} + +/** + * Sleep function for retry logic + * @param {number} ms - Milliseconds to sleep + * @returns {Promise} - Promise that resolves after delay + */ +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/components/trustpilot/package.json b/components/trustpilot/package.json index 4f83c4cbf38e1..2825741782d01 100644 --- a/components/trustpilot/package.json +++ b/components/trustpilot/package.json @@ -12,5 +12,8 @@ "author": "Pipedream (https://pipedream.com/)", "publishConfig": { "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^1.0.0" } } diff --git a/components/trustpilot/sources/common/polling.mjs b/components/trustpilot/sources/common/polling.mjs new file mode 100644 index 0000000000000..ad7edca35ee41 --- /dev/null +++ b/components/trustpilot/sources/common/polling.mjs @@ -0,0 +1,170 @@ +import trustpilot from "../../app/trustpilot.app.ts"; +import { POLLING_CONFIG, SOURCE_TYPES } from "../../common/constants.mjs"; + +export default { + props: { + trustpilot, + db: "$.service.db", + timer: { + type: "$.interface.timer", + default: { + intervalSeconds: POLLING_CONFIG.DEFAULT_TIMER_INTERVAL_SECONDS, + }, + }, + businessUnitId: { + propDefinition: [ + trustpilot, + "businessUnitId", + ], + optional: true, + description: "Business Unit ID to filter events for. If not provided, will receive events for all business units.", + }, + }, + methods: { + _getLastPolled() { + return this.db.get("lastPolled"); + }, + _setLastPolled(timestamp) { + this.db.set("lastPolled", timestamp); + }, + _getSeenItems() { + return this.db.get("seenItems") || {}; + }, + _setSeenItems(seenItems) { + this.db.set("seenItems", seenItems); + }, + _cleanupSeenItems(seenItems, hoursToKeep = 72) { + const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000); + const cleaned = {}; + + Object.entries(seenItems).forEach(([key, timestamp]) => { + if (timestamp > cutoff) { + cleaned[key] = timestamp; + } + }); + + return cleaned; + }, + getSourceType() { + // Override in child classes + return SOURCE_TYPES.NEW_REVIEWS; + }, + getPollingMethod() { + // Override in child classes to return the app method to call + throw new Error("getPollingMethod must be implemented in child class"); + }, + getPollingParams(since) { + // Override in child classes to return method-specific parameters + return { + businessUnitId: this.businessUnitId, + limit: POLLING_CONFIG.MAX_ITEMS_PER_POLL, + sortBy: "createdat.desc", // Most recent first + }; + }, + isNewItem(item, sourceType) { + // For "new" sources, check creation date + // For "updated" sources, check update date + const itemDate = sourceType.includes("updated") + ? new Date(item.updatedAt) + : new Date(item.createdAt || item.updatedAt); + + const lastPolled = this._getLastPolled(); + return !lastPolled || itemDate > new Date(lastPolled); + }, + generateDedupeKey(item, sourceType) { + // Create unique key: itemId + relevant timestamp + const timestamp = sourceType.includes("updated") + ? item.updatedAt + : (item.createdAt || item.updatedAt); + + return `${item.id}_${timestamp}`; + }, + generateMeta(item, sourceType) { + const dedupeKey = this.generateDedupeKey(item, sourceType); + const summary = this.generateSummary(item, sourceType); + const timestamp = sourceType.includes("updated") + ? item.updatedAt + : (item.createdAt || item.updatedAt); + + return { + id: dedupeKey, + summary, + ts: new Date(timestamp).getTime(), + }; + }, + generateSummary(item, sourceType) { + // Override in child classes for specific summaries + return `${sourceType} - ${item.id}`; + }, + async fetchItems(since) { + const method = this.getPollingMethod(); + const params = this.getPollingParams(since); + + try { + const result = await this.trustpilot[method](params); + + // Handle different response formats + if (result.reviews) { + return result.reviews; + } else if (result.conversations) { + return result.conversations; + } else if (Array.isArray(result)) { + return result; + } else { + return []; + } + } catch (error) { + console.error(`Error fetching items with ${method}:`, error); + throw error; + } + }, + async pollForItems() { + const sourceType = this.getSourceType(); + const lastPolled = this._getLastPolled(); + const seenItems = this._getSeenItems(); + + // If first run, look back 24 hours + const since = lastPolled || new Date(Date.now() - (POLLING_CONFIG.LOOKBACK_HOURS * 60 * 60 * 1000)).toISOString(); + + console.log(`Polling for ${sourceType} since ${since}`); + + try { + const items = await this.fetchItems(since); + const newItems = []; + const currentTime = Date.now(); + + for (const item of items) { + // Check if item is new based on source type + if (this.isNewItem(item, sourceType)) { + const dedupeKey = this.generateDedupeKey(item, sourceType); + + // Check if we've already seen this exact item+timestamp + if (!seenItems[dedupeKey]) { + seenItems[dedupeKey] = currentTime; + newItems.push(item); + } + } + } + + // Emit new items + for (const item of newItems.reverse()) { // Oldest first + const meta = this.generateMeta(item, sourceType); + this.$emit(item, meta); + } + + // Update state + this._setLastPolled(new Date().toISOString()); + this._setSeenItems(this._cleanupSeenItems(seenItems)); + + console.log(`Found ${newItems.length} new items of type ${sourceType}`); + + } catch (error) { + console.error(`Polling failed for ${sourceType}:`, error); + throw error; + } + }, + }, + async run() { + await this.pollForItems(); + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/new-conversations/new-conversations.mjs b/components/trustpilot/sources/new-conversations/new-conversations.mjs new file mode 100644 index 0000000000000..1cc424b3a5206 --- /dev/null +++ b/components/trustpilot/sources/new-conversations/new-conversations.mjs @@ -0,0 +1,38 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-new-conversations", + name: "New Conversations", + description: "Emit new events when new conversations are started. Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.NEW_CONVERSATIONS; + }, + getPollingMethod() { + return "getConversations"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.CREATED_AT_DESC, + offset: 0, + }; + }, + generateSummary(item, sourceType) { + const participantName = item.participants?.[0]?.displayName || + item.consumer?.displayName || + "Anonymous"; + const subject = item.subject || item.title || "New conversation"; + const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; + + return `New conversation "${subject}" started by ${participantName} (${businessUnit})`; + }, + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs b/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs new file mode 100644 index 0000000000000..28df86e2a4096 --- /dev/null +++ b/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs @@ -0,0 +1,68 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-new-product-review-replies", + name: "New Product Review Replies", + description: "Emit new events when replies are added to product reviews. Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.NEW_REPLIES; + }, + getPollingMethod() { + return "getProductReviews"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.UPDATED_AT_DESC, // Use updated to catch new replies + offset: 0, + }; + }, + async fetchItems(since) { + const result = await this.trustpilot.getProductReviews(this.getPollingParams(since)); + + // Filter for reviews that have replies and extract the replies + const repliesWithReviews = []; + + if (result.reviews) { + for (const review of result.reviews) { + if (review.company?.reply) { + // Create a pseudo-reply object that includes review context + repliesWithReviews.push({ + id: `reply_${review.id}`, + reviewId: review.id, + text: review.company.reply.text, + createdAt: review.company.reply.createdAt, + updatedAt: review.company.reply.createdAt, // Replies don't get updated + review: { + id: review.id, + title: review.title, + stars: review.stars, + consumer: review.consumer, + product: review.product, + }, + }); + } + } + } + + return repliesWithReviews; + }, + generateSummary(item, sourceType) { + const reviewTitle = item.review?.title || "Review"; + const productName = item.review?.product?.title || "Unknown Product"; + const consumerName = item.review?.consumer?.displayName || "Anonymous"; + const replyPreview = item.text?.substring(0, 50) || ""; + const preview = replyPreview.length > 50 ? `${replyPreview}...` : replyPreview; + + return `New reply to product "${productName}" review by ${consumerName}: "${preview}"`; + }, + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs b/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs new file mode 100644 index 0000000000000..37f8bb8af8092 --- /dev/null +++ b/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs @@ -0,0 +1,37 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-new-product-reviews", + name: "New Product Reviews", + description: "Emit new events when new product reviews are created. Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.NEW_REVIEWS; + }, + getPollingMethod() { + return "getProductReviews"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.CREATED_AT_DESC, + offset: 0, + }; + }, + generateSummary(item, sourceType) { + const stars = item.stars || "N/A"; + const consumerName = item.consumer?.displayName || "Anonymous"; + const productName = item.product?.title || "Unknown Product"; + const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; + + return `New ${stars}-star product review by ${consumerName} for "${productName}" (${businessUnit})`; + }, + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs b/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs new file mode 100644 index 0000000000000..fe8e770f89c17 --- /dev/null +++ b/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs @@ -0,0 +1,66 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-new-service-review-replies", + name: "New Service Review Replies", + description: "Emit new events when replies are added to service reviews. Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.NEW_REPLIES; + }, + getPollingMethod() { + return "getServiceReviews"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.UPDATED_AT_DESC, // Use updated to catch new replies + offset: 0, + }; + }, + async fetchItems(since) { + const result = await this.trustpilot.getServiceReviews(this.getPollingParams(since)); + + // Filter for reviews that have replies and extract the replies + const repliesWithReviews = []; + + if (result.reviews) { + for (const review of result.reviews) { + if (review.company?.reply) { + // Create a pseudo-reply object that includes review context + repliesWithReviews.push({ + id: `reply_${review.id}`, + reviewId: review.id, + text: review.company.reply.text, + createdAt: review.company.reply.createdAt, + updatedAt: review.company.reply.createdAt, // Replies don't get updated + review: { + id: review.id, + title: review.title, + stars: review.stars, + consumer: review.consumer, + }, + }); + } + } + } + + return repliesWithReviews; + }, + generateSummary(item, sourceType) { + const reviewTitle = item.review?.title || "Review"; + const consumerName = item.review?.consumer?.displayName || "Anonymous"; + const replyPreview = item.text?.substring(0, 50) || ""; + const preview = replyPreview.length > 50 ? `${replyPreview}...` : replyPreview; + + return `New reply to "${reviewTitle}" by ${consumerName}: "${preview}"`; + }, + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs b/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs new file mode 100644 index 0000000000000..15ba2582071d7 --- /dev/null +++ b/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs @@ -0,0 +1,37 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-new-service-reviews", + name: "New Service Reviews", + description: "Emit new events when new service reviews are created (combines public and private reviews for comprehensive coverage). Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.NEW_REVIEWS; + }, + getPollingMethod() { + // Use private endpoint first as it has more data, fallback to public if needed + return "getServiceReviews"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.CREATED_AT_DESC, + offset: 0, + }; + }, + generateSummary(item, sourceType) { + const stars = item.stars || "N/A"; + const consumerName = item.consumer?.displayName || "Anonymous"; + const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; + + return `New ${stars}-star service review by ${consumerName} for ${businessUnit}`; + }, + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/updated-conversations/updated-conversations.mjs b/components/trustpilot/sources/updated-conversations/updated-conversations.mjs new file mode 100644 index 0000000000000..cbecfe136c785 --- /dev/null +++ b/components/trustpilot/sources/updated-conversations/updated-conversations.mjs @@ -0,0 +1,39 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-updated-conversations", + name: "Updated Conversations", + description: "Emit new events when conversations are updated (new messages added). Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.UPDATED_CONVERSATIONS; + }, + getPollingMethod() { + return "getConversations"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.UPDATED_AT_DESC, + offset: 0, + }; + }, + generateSummary(item, sourceType) { + const participantName = item.participants?.[0]?.displayName || + item.consumer?.displayName || + "Anonymous"; + const subject = item.subject || item.title || "Conversation"; + const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; + const messageCount = item.messageCount || item.messages?.length || "Unknown"; + + return `Conversation "${subject}" updated by ${participantName} (${messageCount} messages) - ${businessUnit}`; + }, + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs b/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs new file mode 100644 index 0000000000000..cc9f744a4f433 --- /dev/null +++ b/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs @@ -0,0 +1,37 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-updated-product-reviews", + name: "Updated Product Reviews", + description: "Emit new events when product reviews are updated or revised. Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.UPDATED_REVIEWS; + }, + getPollingMethod() { + return "getProductReviews"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.UPDATED_AT_DESC, + offset: 0, + }; + }, + generateSummary(item, sourceType) { + const stars = item.stars || "N/A"; + const consumerName = item.consumer?.displayName || "Anonymous"; + const productName = item.product?.title || "Unknown Product"; + const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; + + return `Product review updated by ${consumerName} (${stars} stars) for "${productName}" (${businessUnit})`; + }, + }, +}; \ No newline at end of file diff --git a/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs b/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs new file mode 100644 index 0000000000000..8e3e48092f193 --- /dev/null +++ b/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs @@ -0,0 +1,36 @@ +import common from "../common/polling.mjs"; +import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; + +export default { + ...common, + key: "trustpilot-updated-service-reviews", + name: "Updated Service Reviews", + description: "Emit new events when service reviews are updated or revised. Polls every 15 minutes.", + version: "0.0.1", + type: "source", + dedupe: "unique", + methods: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.UPDATED_REVIEWS; + }, + getPollingMethod() { + return "getServiceReviews"; + }, + getPollingParams(since) { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.UPDATED_AT_DESC, + offset: 0, + }; + }, + generateSummary(item, sourceType) { + const stars = item.stars || "N/A"; + const consumerName = item.consumer?.displayName || "Anonymous"; + const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; + + return `Service review updated by ${consumerName} (${stars} stars) for ${businessUnit}`; + }, + }, +}; \ No newline at end of file From 404d68f3a081337fbdb821f80da19e41771daa29 Mon Sep 17 00:00:00 2001 From: Job Nijenhuis Date: Fri, 18 Jul 2025 12:38:19 +0200 Subject: [PATCH 2/2] Apply patches: Update pnpm-lock.yaml and fix security vulnerabilities in Trustpilot integration --- .../fetch-product-review-by-id.mjs | 7 +- .../fetch-product-reviews.mjs | 11 +- .../fetch-service-review-by-id.mjs | 7 +- .../fetch-service-reviews.mjs | 11 +- .../reply-to-product-review.mjs | 7 +- .../reply-to-service-review.mjs | 7 +- components/trustpilot/app/trustpilot.app.ts | 60 +++++++-- components/trustpilot/common/constants.mjs | 30 +++-- components/trustpilot/common/utils.mjs | 122 +++++++++++++----- components/trustpilot/package.json | 2 +- .../trustpilot/sources/common/polling.mjs | 82 +++++++----- .../new-conversations/new-conversations.mjs | 19 +-- .../new-product-review-replies.mjs | 30 +++-- .../new-product-reviews.mjs | 15 ++- .../new-service-review-replies.mjs | 31 +++-- .../new-service-reviews.mjs | 15 ++- .../updated-conversations.mjs | 21 +-- .../updated-product-reviews.mjs | 17 ++- .../updated-service-reviews.mjs | 17 ++- pnpm-lock.yaml | 6 +- 20 files changed, 345 insertions(+), 172 deletions(-) diff --git a/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs b/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs index dc8c28adfdf6d..b02a17c892bd3 100644 --- a/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs +++ b/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs @@ -3,8 +3,9 @@ import trustpilot from "../../app/trustpilot.app.ts"; export default { key: "trustpilot-fetch-product-review-by-id", name: "Fetch Product Review by ID", - description: "Fetch a specific product review by its ID. [See the documentation](https://developers.trustpilot.com/product-reviews-api#get-private-product-review)", + description: "Retrieves detailed information about a specific product review on Trustpilot. Use this action to get comprehensive data about a single product review, including customer feedback, star rating, review text, and metadata. Perfect for analyzing individual customer experiences, responding to specific feedback, or integrating review data into your customer service workflows. [See the documentation](https://developers.trustpilot.com/product-reviews-api#get-private-product-review)", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "action", props: { trustpilot, @@ -24,7 +25,7 @@ export default { }); $.export("$summary", `Successfully fetched product review ${reviewId}`); - + return { review, metadata: { @@ -36,4 +37,4 @@ export default { throw new Error(`Failed to fetch product review: ${error.message}`); } }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs b/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs index 6dfc73b29c38b..0566684602624 100644 --- a/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs +++ b/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs @@ -3,8 +3,9 @@ import trustpilot from "../../app/trustpilot.app.ts"; export default { key: "trustpilot-fetch-product-reviews", name: "Fetch Product Reviews", - description: "Fetch product reviews for a business unit. [See the documentation](https://developers.trustpilot.com/product-reviews-api#get-private-product-reviews)", + description: "Retrieves a list of product reviews for a specific business unit on Trustpilot. This action enables you to fetch multiple product reviews with powerful filtering options including star ratings, language, tags, and sorting preferences. Ideal for monitoring product feedback trends, generating reports, analyzing customer sentiment across your product catalog, or building review dashboards. Supports pagination for handling large review volumes. [See the documentation](https://developers.trustpilot.com/product-reviews-api#get-private-product-reviews)", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "action", props: { trustpilot, @@ -83,10 +84,12 @@ export default { offset, }); - const { reviews, pagination } = result; + const { + reviews, pagination, + } = result; $.export("$summary", `Successfully fetched ${reviews.length} product review(s) for business unit ${businessUnitId}`); - + return { reviews, pagination, @@ -106,4 +109,4 @@ export default { throw new Error(`Failed to fetch product reviews: ${error.message}`); } }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs b/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs index 608bba91acca8..f5c70845d377b 100644 --- a/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs +++ b/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs @@ -3,8 +3,9 @@ import trustpilot from "../../app/trustpilot.app.ts"; export default { key: "trustpilot-fetch-service-review-by-id", name: "Fetch Service Review by ID", - description: "Fetch a specific service review by its ID. [See the documentation](https://developers.trustpilot.com/business-units-api#get-business-unit-review)", + description: "Retrieves detailed information about a specific service review for your business on Trustpilot. Use this action to access comprehensive data about an individual service review, including the customer's rating, review content, date, and any responses. Essential for customer service teams to analyze specific feedback, track review history, or integrate individual review data into CRM systems and support tickets. [See the documentation](https://developers.trustpilot.com/business-units-api#get-business-unit-review)", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "action", props: { trustpilot, @@ -34,7 +35,7 @@ export default { }); $.export("$summary", `Successfully fetched service review ${reviewId} for business unit ${businessUnitId}`); - + return { review, metadata: { @@ -47,4 +48,4 @@ export default { throw new Error(`Failed to fetch service review: ${error.message}`); } }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs b/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs index a5cb476bf5d32..236d28d756726 100644 --- a/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs +++ b/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs @@ -3,9 +3,10 @@ import trustpilot from "../../app/trustpilot.app.ts"; export default { key: "trustpilot-fetch-service-reviews", name: "Fetch Service Reviews", - description: "Fetch service reviews for a business unit. [See the documentation](https://developers.trustpilot.com/business-units-api#get-business-unit-reviews)", + description: "Fetches service reviews for a specific business unit from Trustpilot with support for filtering by star rating, tags, language, and more. [See the documentation](https://developers.trustpilot.com/business-units-api#get-business-unit-reviews)", version: "0.0.1", type: "action", + publishedAt: "2025-07-18T00:00:00.000Z", props: { trustpilot, businessUnitId: { @@ -83,10 +84,12 @@ export default { offset, }); - const { reviews, pagination } = result; + const { + reviews, pagination, + } = result; $.export("$summary", `Successfully fetched ${reviews.length} service review(s) for business unit ${businessUnitId}`); - + return { reviews, pagination, @@ -106,4 +109,4 @@ export default { throw new Error(`Failed to fetch service reviews: ${error.message}`); } }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs b/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs index 64c7543626e28..a6a19f6d78dc6 100644 --- a/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs +++ b/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs @@ -3,8 +3,9 @@ import trustpilot from "../../app/trustpilot.app.ts"; export default { key: "trustpilot-reply-to-product-review", name: "Reply to Product Review", - description: "Reply to a product review on behalf of your business. [See the documentation](https://developers.trustpilot.com/product-reviews-api#reply-to-product-review)", + description: "Posts a public reply to a product review on Trustpilot on behalf of your business. This action allows you to respond to customer feedback, address concerns, thank customers for positive reviews, or provide additional information about products. Replies help demonstrate your commitment to customer satisfaction and can improve your overall reputation. Note that replies are publicly visible and cannot be edited once posted. [See the documentation](https://developers.trustpilot.com/product-reviews-api#reply-to-product-review)", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "action", props: { trustpilot, @@ -37,7 +38,7 @@ export default { }); $.export("$summary", `Successfully replied to product review ${reviewId}`); - + return { success: true, reply: result, @@ -51,4 +52,4 @@ export default { throw new Error(`Failed to reply to product review: ${error.message}`); } }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs b/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs index 62a1e3205c0f0..3c46a7dcbb6c7 100644 --- a/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs +++ b/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs @@ -3,8 +3,9 @@ import trustpilot from "../../app/trustpilot.app.ts"; export default { key: "trustpilot-reply-to-service-review", name: "Reply to Service Review", - description: "Reply to a service review on behalf of your business. [See the documentation](https://developers.trustpilot.com/business-units-api#reply-to-review)", + description: "Posts a public reply to a service review on Trustpilot on behalf of your business. This action enables you to engage with customers who have reviewed your services, allowing you to address complaints, clarify misunderstandings, express gratitude for positive feedback, or provide updates on how you're improving based on their input. Professional responses to reviews can significantly impact your business reputation and show potential customers that you value feedback. Remember that all replies are permanent and publicly visible. [See the documentation](https://developers.trustpilot.com/business-units-api#reply-to-review)", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "action", props: { trustpilot, @@ -45,7 +46,7 @@ export default { }); $.export("$summary", `Successfully replied to service review ${reviewId}`); - + return { success: true, reply: result, @@ -60,4 +61,4 @@ export default { throw new Error(`Failed to reply to service review: ${error.message}`); } }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/app/trustpilot.app.ts b/components/trustpilot/app/trustpilot.app.ts index 51cd370068687..2f6b8ff77fa2e 100644 --- a/components/trustpilot/app/trustpilot.app.ts +++ b/components/trustpilot/app/trustpilot.app.ts @@ -1,5 +1,6 @@ import { defineApp } from "@pipedream/types"; import { axios } from "@pipedream/platform"; +import crypto from "crypto"; import { BASE_URL, ENDPOINTS, @@ -20,6 +21,7 @@ import { formatQueryParams, parseApiError, sleep, + sanitizeInput, } from "../common/utils.mjs"; export default defineApp({ @@ -305,11 +307,20 @@ export default defineApp({ throw new Error("Reply message is required"); } + // Sanitize and validate message length (Trustpilot limit is 5000 characters) + const sanitizedMessage = sanitizeInput(message, 5000); + if (sanitizedMessage.length === 0) { + throw new Error("Reply message cannot be empty after sanitization"); + } + if (message.length > 5000) { + console.warn("Reply message was truncated to 5000 characters"); + } + const endpoint = buildUrl(ENDPOINTS.REPLY_TO_SERVICE_REVIEW, { businessUnitId, reviewId }); const response = await this._makeRequest({ endpoint, method: "POST", - data: { message }, + data: { message: sanitizedMessage }, }); return response; }, @@ -377,11 +388,20 @@ export default defineApp({ throw new Error("Reply message is required"); } + // Sanitize and validate message length (Trustpilot limit is 5000 characters) + const sanitizedMessage = sanitizeInput(message, 5000); + if (sanitizedMessage.length === 0) { + throw new Error("Reply message cannot be empty after sanitization"); + } + if (message.length > 5000) { + console.warn("Reply message was truncated to 5000 characters"); + } + const endpoint = buildUrl(ENDPOINTS.REPLY_TO_PRODUCT_REVIEW, { reviewId }); const response = await this._makeRequest({ endpoint, method: "POST", - data: { message }, + data: { message: sanitizedMessage }, }); return response; }, @@ -437,11 +457,20 @@ export default defineApp({ throw new Error("Reply message is required"); } + // Sanitize and validate message length (Trustpilot limit is 5000 characters) + const sanitizedMessage = sanitizeInput(message, 5000); + if (sanitizedMessage.length === 0) { + throw new Error("Reply message cannot be empty after sanitization"); + } + if (message.length > 5000) { + console.warn("Reply message was truncated to 5000 characters"); + } + const endpoint = buildUrl(ENDPOINTS.REPLY_TO_CONVERSATION, { conversationId }); const response = await this._makeRequest({ endpoint, method: "POST", - data: { message }, + data: { message: sanitizedMessage }, }); return response; }, @@ -497,14 +526,25 @@ export default defineApp({ }, validateWebhookSignature(payload, signature, secret) { - // TODO: Implement webhook signature validation when Trustpilot provides it - return true; - }, + // Trustpilot uses HMAC-SHA256 for webhook signature validation + // The signature is sent in the x-trustpilot-signature header + if (!signature || !secret) { + return false; + } - // Legacy method for debugging - authKeys() { - console.log("Auth keys:", Object.keys(this.$auth || {})); - return Object.keys(this.$auth || {}); + const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload); + + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(payloadString) + .digest('hex'); + + // Constant time comparison to prevent timing attacks + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); }, + }, }); \ No newline at end of file diff --git a/components/trustpilot/common/constants.mjs b/components/trustpilot/common/constants.mjs index 054390855fd8d..5862a7521527a 100644 --- a/components/trustpilot/common/constants.mjs +++ b/components/trustpilot/common/constants.mjs @@ -13,30 +13,34 @@ export const ENDPOINTS = { // Business Units BUSINESS_UNITS: "/business-units", BUSINESS_UNIT_BY_ID: "/business-units/{businessUnitId}", - + // Public Reviews PUBLIC_REVIEWS: "/business-units/{businessUnitId}/reviews", PUBLIC_REVIEW_BY_ID: "/business-units/{businessUnitId}/reviews/{reviewId}", - + // Private Reviews (Service) PRIVATE_SERVICE_REVIEWS: "/private/business-units/{businessUnitId}/reviews", PRIVATE_SERVICE_REVIEW_BY_ID: "/private/business-units/{businessUnitId}/reviews/{reviewId}", REPLY_TO_SERVICE_REVIEW: "/private/business-units/{businessUnitId}/reviews/{reviewId}/reply", - + // Private Reviews (Product) PRIVATE_PRODUCT_REVIEWS: "/private/product-reviews/business-units/{businessUnitId}/reviews", PRIVATE_PRODUCT_REVIEW_BY_ID: "/private/product-reviews/{reviewId}", REPLY_TO_PRODUCT_REVIEW: "/private/product-reviews/{reviewId}/reply", - + // Conversations CONVERSATIONS: "/private/conversations", CONVERSATION_BY_ID: "/private/conversations/{conversationId}", REPLY_TO_CONVERSATION: "/private/conversations/{conversationId}/reply", - + // Invitations EMAIL_INVITATIONS: "/private/business-units/{businessUnitId}/email-invitations", - - // Webhooks (deprecated for polling) + + // Webhooks + // Note: This integration uses polling sources instead of webhooks for better reliability + // and simpler implementation. Webhook signature validation is implemented in the app + // using HMAC-SHA256 with the x-trustpilot-signature header for future webhook sources. + // These endpoints and validation methods are ready for webhook implementation if needed. WEBHOOKS: "/private/webhooks", WEBHOOK_BY_ID: "/private/webhooks/{webhookId}", }; @@ -60,7 +64,13 @@ export const SORT_OPTIONS = { UPDATED_AT_DESC: "updatedat.desc", }; -export const RATING_SCALE = [1, 2, 3, 4, 5]; +export const RATING_SCALE = [ + 1, + 2, + 3, + 4, + 5, +]; export const DEFAULT_LIMIT = 20; export const MAX_LIMIT = 100; @@ -92,7 +102,7 @@ export const POLLING_CONFIG = { export const SOURCE_TYPES = { NEW_REVIEWS: "new_reviews", UPDATED_REVIEWS: "updated_reviews", - NEW_REPLIES: "new_replies", + NEW_REPLIES: "new_replies", NEW_CONVERSATIONS: "new_conversations", UPDATED_CONVERSATIONS: "updated_conversations", -}; \ No newline at end of file +}; diff --git a/components/trustpilot/common/utils.mjs b/components/trustpilot/common/utils.mjs index 71c85a9a5ba24..20677e7cac566 100644 --- a/components/trustpilot/common/utils.mjs +++ b/components/trustpilot/common/utils.mjs @@ -1,4 +1,46 @@ -import { ENDPOINTS } from "./constants.mjs"; +/** + * Escape HTML special characters to prevent XSS + * @param {string} text - Text to escape + * @returns {string} - Escaped text + */ +export function escapeHtml(text) { + if (!text) return text; + const map = { + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + "/": "/", + }; + const reg = /[&<>"'/]/g; + return text.toString().replace(reg, (match) => map[match]); +} + +/** + * Sanitize input text by removing potentially harmful content + * @param {string} text - Text to sanitize + * @param {number} maxLength - Maximum allowed length + * @returns {string} - Sanitized text + */ +export function sanitizeInput(text, maxLength = 5000) { + if (!text) return ""; + + // Convert to string and trim + let sanitized = String(text).trim(); + + // Remove control characters except newlines and tabs + // Using Unicode property escapes for safer regex + // eslint-disable-next-line no-control-regex + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + + // Limit length + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength); + } + + return sanitized; +} /** * Build URL from endpoint template and parameters @@ -8,12 +50,17 @@ import { ENDPOINTS } from "./constants.mjs"; */ export function buildUrl(endpoint, params = {}) { let url = endpoint; - - // Replace path parameters - Object.entries(params).forEach(([key, value]) => { - url = url.replace(`{${key}}`, value); + + // Replace path parameters with proper escaping + Object.entries(params).forEach(([ + key, + value, + ]) => { + const placeholder = `{${key}}`; + // Use split/join to avoid regex issues and encode the value + url = url.split(placeholder).join(encodeURIComponent(String(value))); }); - + return url; } @@ -26,23 +73,25 @@ export function parseReview(review) { return { id: review.id, stars: review.stars, - title: review.title, - text: review.text, + title: escapeHtml(review.title), + text: escapeHtml(review.text), language: review.language, - location: review.location, + location: escapeHtml(review.location), tags: review.tags || [], createdAt: review.createdAt, updatedAt: review.updatedAt, consumer: { id: review.consumer?.id, - displayName: review.consumer?.displayName, + displayName: escapeHtml(review.consumer?.displayName), numberOfReviews: review.consumer?.numberOfReviews, }, company: { - reply: review.companyReply ? { - text: review.companyReply.text, - createdAt: review.companyReply.createdAt, - } : null, + reply: review.companyReply + ? { + text: escapeHtml(review.companyReply.text), + createdAt: review.companyReply.createdAt, + } + : null, }, imported: review.imported || false, verified: review.verified || false, @@ -79,8 +128,10 @@ export function parseBusinessUnit(businessUnit) { * @returns {object} - Parsed webhook data */ export function parseWebhookPayload(payload) { - const { event, data } = payload; - + const { + event, data, + } = payload; + return { event: event?.type || payload.eventType, timestamp: event?.timestamp || payload.timestamp, @@ -98,7 +149,11 @@ export function parseWebhookPayload(payload) { * @returns {boolean} - Whether the ID is valid */ export function validateBusinessUnitId(businessUnitId) { - return businessUnitId && typeof businessUnitId === 'string' && businessUnitId.length > 0; + // Trustpilot Business Unit IDs are 24-character hexadecimal strings (MongoDB ObjectID format) + return ( + typeof businessUnitId === "string" && + /^[a-f0-9]{24}$/.test(businessUnitId) + ); } /** @@ -107,7 +162,11 @@ export function validateBusinessUnitId(businessUnitId) { * @returns {boolean} - Whether the ID is valid */ export function validateReviewId(reviewId) { - return reviewId && typeof reviewId === 'string' && reviewId.length > 0; + // Trustpilot Review IDs are 24-character hexadecimal strings (MongoDB ObjectID format) + return ( + typeof reviewId === "string" && + /^[a-f0-9]{24}$/.test(reviewId) + ); } /** @@ -117,13 +176,16 @@ export function validateReviewId(reviewId) { */ export function formatQueryParams(params) { const formatted = {}; - - Object.entries(params).forEach(([key, value]) => { - if (value !== null && value !== undefined && value !== '') { + + Object.entries(params).forEach(([ + key, + value, + ]) => { + if (value !== null && value !== undefined && value !== "") { formatted[key] = value; } }); - + return formatted; } @@ -134,20 +196,22 @@ export function formatQueryParams(params) { */ export function parseApiError(error) { if (error.response) { - const { status, data } = error.response; + const { + status, data, + } = error.response; return { status, - message: data?.message || data?.error || 'API Error', + message: data?.message || data?.error || "API Error", details: data?.details || data?.errors || [], code: data?.code || `HTTP_${status}`, }; } - + return { status: 0, - message: error.message || 'Unknown error', + message: error.message || "Unknown error", details: [], - code: 'UNKNOWN_ERROR', + code: "UNKNOWN_ERROR", }; } @@ -157,5 +221,5 @@ export function parseApiError(error) { * @returns {Promise} - Promise that resolves after delay */ export function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} \ No newline at end of file + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/components/trustpilot/package.json b/components/trustpilot/package.json index 2825741782d01..8280ac25a15a7 100644 --- a/components/trustpilot/package.json +++ b/components/trustpilot/package.json @@ -14,6 +14,6 @@ "access": "public" }, "dependencies": { - "@pipedream/platform": "^1.0.0" + "@pipedream/platform": "^3.0.0" } } diff --git a/components/trustpilot/sources/common/polling.mjs b/components/trustpilot/sources/common/polling.mjs index ad7edca35ee41..dcc63c8f8c4a0 100644 --- a/components/trustpilot/sources/common/polling.mjs +++ b/components/trustpilot/sources/common/polling.mjs @@ -1,6 +1,20 @@ import trustpilot from "../../app/trustpilot.app.ts"; -import { POLLING_CONFIG, SOURCE_TYPES } from "../../common/constants.mjs"; +import { + POLLING_CONFIG, SOURCE_TYPES, +} from "../../common/constants.mjs"; +/** + * Base polling source for Trustpilot integration + * + * This integration uses polling instead of webhooks for the following reasons: + * 1. Better reliability - polling ensures no events are missed + * 2. Simpler implementation - no need for webhook endpoint management + * 3. Consistent data retrieval - can backfill historical data if needed + * 4. Works with all authentication methods (API key and OAuth) + * + * All sources poll every 15 minutes by default and maintain deduplication + * to ensure events are only emitted once. + */ export default { props: { trustpilot, @@ -36,13 +50,16 @@ export default { _cleanupSeenItems(seenItems, hoursToKeep = 72) { const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000); const cleaned = {}; - - Object.entries(seenItems).forEach(([key, timestamp]) => { + + Object.entries(seenItems).forEach(([ + key, + timestamp, + ]) => { if (timestamp > cutoff) { cleaned[key] = timestamp; } }); - + return cleaned; }, getSourceType() { @@ -53,7 +70,7 @@ export default { // Override in child classes to return the app method to call throw new Error("getPollingMethod must be implemented in child class"); }, - getPollingParams(since) { + getPollingParams() { // Override in child classes to return method-specific parameters return { businessUnitId: this.businessUnitId, @@ -64,45 +81,45 @@ export default { isNewItem(item, sourceType) { // For "new" sources, check creation date // For "updated" sources, check update date - const itemDate = sourceType.includes("updated") - ? new Date(item.updatedAt) + const itemDate = sourceType.includes("updated") + ? new Date(item.updatedAt) : new Date(item.createdAt || item.updatedAt); - + const lastPolled = this._getLastPolled(); return !lastPolled || itemDate > new Date(lastPolled); }, generateDedupeKey(item, sourceType) { // Create unique key: itemId + relevant timestamp - const timestamp = sourceType.includes("updated") - ? item.updatedAt + const timestamp = sourceType.includes("updated") + ? item.updatedAt : (item.createdAt || item.updatedAt); - + return `${item.id}_${timestamp}`; }, generateMeta(item, sourceType) { const dedupeKey = this.generateDedupeKey(item, sourceType); - const summary = this.generateSummary(item, sourceType); - const timestamp = sourceType.includes("updated") - ? item.updatedAt + const summary = this.generateSummary(item); + const timestamp = sourceType.includes("updated") + ? item.updatedAt : (item.createdAt || item.updatedAt); - + return { id: dedupeKey, summary, ts: new Date(timestamp).getTime(), }; }, - generateSummary(item, sourceType) { + generateSummary(item) { // Override in child classes for specific summaries - return `${sourceType} - ${item.id}`; + return `${this.getSourceType()} - ${item.id}`; }, - async fetchItems(since) { + async fetchItems() { const method = this.getPollingMethod(); - const params = this.getPollingParams(since); - + const params = this.getPollingParams(); + try { const result = await this.trustpilot[method](params); - + // Handle different response formats if (result.reviews) { return result.reviews; @@ -122,22 +139,23 @@ export default { const sourceType = this.getSourceType(); const lastPolled = this._getLastPolled(); const seenItems = this._getSeenItems(); - + // If first run, look back 24 hours - const since = lastPolled || new Date(Date.now() - (POLLING_CONFIG.LOOKBACK_HOURS * 60 * 60 * 1000)).toISOString(); - + const lookbackMs = POLLING_CONFIG.LOOKBACK_HOURS * 60 * 60 * 1000; + const since = lastPolled || new Date(Date.now() - lookbackMs).toISOString(); + console.log(`Polling for ${sourceType} since ${since}`); - + try { const items = await this.fetchItems(since); const newItems = []; const currentTime = Date.now(); - + for (const item of items) { // Check if item is new based on source type if (this.isNewItem(item, sourceType)) { const dedupeKey = this.generateDedupeKey(item, sourceType); - + // Check if we've already seen this exact item+timestamp if (!seenItems[dedupeKey]) { seenItems[dedupeKey] = currentTime; @@ -145,19 +163,19 @@ export default { } } } - + // Emit new items for (const item of newItems.reverse()) { // Oldest first const meta = this.generateMeta(item, sourceType); this.$emit(item, meta); } - + // Update state this._setLastPolled(new Date().toISOString()); this._setSeenItems(this._cleanupSeenItems(seenItems)); - + console.log(`Found ${newItems.length} new items of type ${sourceType}`); - + } catch (error) { console.error(`Polling failed for ${sourceType}:`, error); throw error; @@ -167,4 +185,4 @@ export default { async run() { await this.pollForItems(); }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/new-conversations/new-conversations.mjs b/components/trustpilot/sources/new-conversations/new-conversations.mjs index 1cc424b3a5206..ff7bf1a9eac7c 100644 --- a/components/trustpilot/sources/new-conversations/new-conversations.mjs +++ b/components/trustpilot/sources/new-conversations/new-conversations.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-new-conversations", name: "New Conversations", - description: "Emit new events when new conversations are started. Polls every 15 minutes.", + description: "Emit new event when a new conversation is started on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect new customer-business conversations. Each event contains conversation details including participants, subject, business unit, and creation timestamp. Useful for tracking customer inquiries, support requests, and maintaining real-time communication with customers.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -17,7 +20,7 @@ export default { getPollingMethod() { return "getConversations"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -25,14 +28,14 @@ export default { offset: 0, }; }, - generateSummary(item, sourceType) { - const participantName = item.participants?.[0]?.displayName || - item.consumer?.displayName || + generateSummary(item) { + const participantName = item.participants?.[0]?.displayName || + item.consumer?.displayName || "Anonymous"; const subject = item.subject || item.title || "New conversation"; const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; - + return `New conversation "${subject}" started by ${participantName} (${businessUnit})`; }, }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs b/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs index 28df86e2a4096..140459ce51369 100644 --- a/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs +++ b/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-new-product-review-replies", name: "New Product Review Replies", - description: "Emit new events when replies are added to product reviews. Polls every 15 minutes.", + description: "Emit new event when a business replies to a product review on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect new replies to product reviews. Each event includes the reply text, creation timestamp, and associated review details (product name, star rating, consumer info). Ideal for monitoring business responses to customer feedback, tracking customer service performance, and ensuring timely engagement with product reviews.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -17,7 +20,7 @@ export default { getPollingMethod() { return "getProductReviews"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -25,12 +28,12 @@ export default { offset: 0, }; }, - async fetchItems(since) { - const result = await this.trustpilot.getProductReviews(this.getPollingParams(since)); - + async fetchItems() { + const result = await this.trustpilot.getProductReviews(this.getPollingParams()); + // Filter for reviews that have replies and extract the replies const repliesWithReviews = []; - + if (result.reviews) { for (const review of result.reviews) { if (review.company?.reply) { @@ -52,17 +55,18 @@ export default { } } } - + return repliesWithReviews; }, - generateSummary(item, sourceType) { - const reviewTitle = item.review?.title || "Review"; + generateSummary(item) { const productName = item.review?.product?.title || "Unknown Product"; const consumerName = item.review?.consumer?.displayName || "Anonymous"; const replyPreview = item.text?.substring(0, 50) || ""; - const preview = replyPreview.length > 50 ? `${replyPreview}...` : replyPreview; - + const preview = replyPreview.length > 50 + ? `${replyPreview}...` + : replyPreview; + return `New reply to product "${productName}" review by ${consumerName}: "${preview}"`; }, }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs b/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs index 37f8bb8af8092..76319371a59d1 100644 --- a/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs +++ b/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-new-product-reviews", name: "New Product Reviews", - description: "Emit new events when new product reviews are created. Polls every 15 minutes.", + description: "Emit new event when a customer posts a new product review on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect new product reviews. Each event contains the complete review data including star rating, review text, product information, consumer details, and timestamps. Perfect for monitoring product feedback, analyzing customer satisfaction trends, and triggering automated responses or alerts for specific products.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -17,7 +20,7 @@ export default { getPollingMethod() { return "getProductReviews"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -25,13 +28,13 @@ export default { offset: 0, }; }, - generateSummary(item, sourceType) { + generateSummary(item) { const stars = item.stars || "N/A"; const consumerName = item.consumer?.displayName || "Anonymous"; const productName = item.product?.title || "Unknown Product"; const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; - + return `New ${stars}-star product review by ${consumerName} for "${productName}" (${businessUnit})`; }, }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs b/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs index fe8e770f89c17..b00fcc567d582 100644 --- a/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs +++ b/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-new-service-review-replies", - name: "New Service Review Replies", - description: "Emit new events when replies are added to service reviews. Polls every 15 minutes.", + name: "New Service Review Replies", + description: "Emit new event when a business replies to a service review on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect new replies to service reviews. Each event includes the reply text, creation timestamp, and associated review details (star rating, review title, consumer info). Essential for tracking business engagement with customer feedback, monitoring response times, and ensuring all service reviews receive appropriate attention.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -17,7 +20,7 @@ export default { getPollingMethod() { return "getServiceReviews"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -25,12 +28,12 @@ export default { offset: 0, }; }, - async fetchItems(since) { - const result = await this.trustpilot.getServiceReviews(this.getPollingParams(since)); - + async fetchItems() { + const result = await this.trustpilot.getServiceReviews(this.getPollingParams()); + // Filter for reviews that have replies and extract the replies const repliesWithReviews = []; - + if (result.reviews) { for (const review of result.reviews) { if (review.company?.reply) { @@ -51,16 +54,18 @@ export default { } } } - + return repliesWithReviews; }, - generateSummary(item, sourceType) { + generateSummary(item) { const reviewTitle = item.review?.title || "Review"; const consumerName = item.review?.consumer?.displayName || "Anonymous"; const replyPreview = item.text?.substring(0, 50) || ""; - const preview = replyPreview.length > 50 ? `${replyPreview}...` : replyPreview; - + const preview = replyPreview.length > 50 + ? `${replyPreview}...` + : replyPreview; + return `New reply to "${reviewTitle}" by ${consumerName}: "${preview}"`; }, }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs b/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs index 15ba2582071d7..ea6f98c21847c 100644 --- a/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs +++ b/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-new-service-reviews", name: "New Service Reviews", - description: "Emit new events when new service reviews are created (combines public and private reviews for comprehensive coverage). Polls every 15 minutes.", + description: "Emit new event when a customer posts a new service review on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect new service reviews, combining both public and private reviews for comprehensive coverage. Each event contains the complete review data including star rating, review text, consumer details, business unit info, and timestamps. Ideal for monitoring overall business reputation, tracking customer satisfaction metrics, and triggering workflows based on review ratings or content.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -18,7 +21,7 @@ export default { // Use private endpoint first as it has more data, fallback to public if needed return "getServiceReviews"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -26,12 +29,12 @@ export default { offset: 0, }; }, - generateSummary(item, sourceType) { + generateSummary(item) { const stars = item.stars || "N/A"; const consumerName = item.consumer?.displayName || "Anonymous"; const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; - + return `New ${stars}-star service review by ${consumerName} for ${businessUnit}`; }, }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/updated-conversations/updated-conversations.mjs b/components/trustpilot/sources/updated-conversations/updated-conversations.mjs index cbecfe136c785..4a011c9b8aa6a 100644 --- a/components/trustpilot/sources/updated-conversations/updated-conversations.mjs +++ b/components/trustpilot/sources/updated-conversations/updated-conversations.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-updated-conversations", - name: "Updated Conversations", - description: "Emit new events when conversations are updated (new messages added). Polls every 15 minutes.", + name: "New Updated Conversations", + description: "Emit new event when an existing conversation is updated with new messages on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect conversations that have received new messages. Each event contains updated conversation details including participants, subject, message count, and latest update timestamp. Useful for tracking ongoing customer interactions, ensuring timely responses to follow-up messages, and maintaining conversation continuity.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -17,7 +20,7 @@ export default { getPollingMethod() { return "getConversations"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -25,15 +28,15 @@ export default { offset: 0, }; }, - generateSummary(item, sourceType) { - const participantName = item.participants?.[0]?.displayName || - item.consumer?.displayName || + generateSummary(item) { + const participantName = item.participants?.[0]?.displayName || + item.consumer?.displayName || "Anonymous"; const subject = item.subject || item.title || "Conversation"; const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; const messageCount = item.messageCount || item.messages?.length || "Unknown"; - + return `Conversation "${subject}" updated by ${participantName} (${messageCount} messages) - ${businessUnit}`; }, }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs b/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs index cc9f744a4f433..f6dc778dd5999 100644 --- a/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs +++ b/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-updated-product-reviews", - name: "Updated Product Reviews", - description: "Emit new events when product reviews are updated or revised. Polls every 15 minutes.", + name: "New Updated Product Reviews", + description: "Emit new event when an existing product review is updated or revised on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect product reviews that have been modified. Each event contains the updated review data including any changes to star rating, review text, or other review attributes. Perfect for tracking review modifications, monitoring changes in customer sentiment, and ensuring product feedback accuracy over time.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -17,7 +20,7 @@ export default { getPollingMethod() { return "getProductReviews"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -25,13 +28,13 @@ export default { offset: 0, }; }, - generateSummary(item, sourceType) { + generateSummary(item) { const stars = item.stars || "N/A"; const consumerName = item.consumer?.displayName || "Anonymous"; const productName = item.product?.title || "Unknown Product"; const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; - + return `Product review updated by ${consumerName} (${stars} stars) for "${productName}" (${businessUnit})`; }, }, -}; \ No newline at end of file +}; diff --git a/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs b/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs index 8e3e48092f193..fb18407d0234c 100644 --- a/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs +++ b/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs @@ -1,12 +1,15 @@ import common from "../common/polling.mjs"; -import { SOURCE_TYPES, SORT_OPTIONS } from "../../common/constants.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.mjs"; export default { ...common, key: "trustpilot-updated-service-reviews", - name: "Updated Service Reviews", - description: "Emit new events when service reviews are updated or revised. Polls every 15 minutes.", + name: "New Updated Service Reviews", + description: "Emit new event when an existing service review is updated or revised on Trustpilot. This source polls the Trustpilot API every 15 minutes to detect service reviews that have been modified. Each event contains the updated review data including any changes to star rating, review text, or other review attributes. Essential for tracking review modifications, monitoring evolving customer feedback, and identifying patterns in review updates.", version: "0.0.1", + publishedAt: "2025-07-18T00:00:00.000Z", type: "source", dedupe: "unique", methods: { @@ -17,7 +20,7 @@ export default { getPollingMethod() { return "getServiceReviews"; }, - getPollingParams(since) { + getPollingParams() { return { businessUnitId: this.businessUnitId, limit: 100, @@ -25,12 +28,12 @@ export default { offset: 0, }; }, - generateSummary(item, sourceType) { + generateSummary(item) { const stars = item.stars || "N/A"; const consumerName = item.consumer?.displayName || "Anonymous"; const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; - + return `Service review updated by ${consumerName} (${stars} stars) for ${businessUnit}`; }, }, -}; \ No newline at end of file +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f0f3932e904f..5ac0de52f6329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14060,7 +14060,11 @@ importers: specifier: ^0.0.1-security version: 0.0.1-security - components/trustpilot: {} + components/trustpilot: + dependencies: + '@pipedream/platform': + specifier: ^3.0.0 + version: 3.1.0 components/tubular: dependencies: