diff --git a/components/etrusted/etrusted.app.mjs b/components/etrusted/etrusted.app.mjs index c7b97945a8aa4..5e41c9504b032 100644 --- a/components/etrusted/etrusted.app.mjs +++ b/components/etrusted/etrusted.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/financial_data/financial_data.app.mjs b/components/financial_data/financial_data.app.mjs index 2ebd5e4c145d8..d21de5a68c40a 100644 --- a/components/financial_data/financial_data.app.mjs +++ b/components/financial_data/financial_data.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/intelliflo_office/intelliflo_office.app.mjs b/components/intelliflo_office/intelliflo_office.app.mjs index 0987df89e37e6..c5bcc4d835d73 100644 --- a/components/intelliflo_office/intelliflo_office.app.mjs +++ b/components/intelliflo_office/intelliflo_office.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/kordiam/kordiam.app.mjs b/components/kordiam/kordiam.app.mjs index 49bd329ae66ec..9cb0e8979b7bc 100644 --- a/components/kordiam/kordiam.app.mjs +++ b/components/kordiam/kordiam.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/lightspeed_ecom_c_series/lightspeed_ecom_c_series.app.mjs b/components/lightspeed_ecom_c_series/lightspeed_ecom_c_series.app.mjs index 741ab63c52762..47001046778b7 100644 --- a/components/lightspeed_ecom_c_series/lightspeed_ecom_c_series.app.mjs +++ b/components/lightspeed_ecom_c_series/lightspeed_ecom_c_series.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/microsoft_authenticator/microsoft_authenticator.app.mjs b/components/microsoft_authenticator/microsoft_authenticator.app.mjs index 3911b9e7409f3..dd83b59dd35a9 100644 --- a/components/microsoft_authenticator/microsoft_authenticator.app.mjs +++ b/components/microsoft_authenticator/microsoft_authenticator.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/rundeck/rundeck.app.mjs b/components/rundeck/rundeck.app.mjs index eb18a9222075a..ea54315a2b959 100644 --- a/components/rundeck/rundeck.app.mjs +++ b/components/rundeck/rundeck.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/thoughtspot/thoughtspot.app.mjs b/components/thoughtspot/thoughtspot.app.mjs index 297653d604fed..b0214d5211435 100644 --- a/components/thoughtspot/thoughtspot.app.mjs +++ b/components/thoughtspot/thoughtspot.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ No newline at end of file +}; diff --git a/components/ticketsauce/ticketsauce.app.mjs b/components/ticketsauce/ticketsauce.app.mjs index 21ab9d092e971..f665cd726ff5b 100644 --- a/components/ticketsauce/ticketsauce.app.mjs +++ b/components/ticketsauce/ticketsauce.app.mjs @@ -8,4 +8,4 @@ export default { console.log(Object.keys(this.$auth)); }, }, -}; \ 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 index 8360d522e88b1..9b5f1994b7cd1 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 @@ -1,10 +1,17 @@ import trustpilot from "../../trustpilot.app.mjs"; +import { makeRequest } from "../../common/api-client.mjs"; +import { ENDPOINTS } from "../../common/constants.mjs"; +import { + buildUrl, + parseProductReview, + validateReviewId, +} from "../../common/utils.mjs"; export default { key: "trustpilot-fetch-product-review-by-id", name: "Fetch Product Review by ID", 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.3", + version: "0.1.0", type: "action", props: { trustpilot, @@ -18,11 +25,28 @@ export default { async run({ $ }) { const { reviewId } = this; + // Validate required parameters + if (!reviewId) { + throw new Error("Review ID is required"); + } + if (!validateReviewId(reviewId)) { + throw new Error("Invalid review ID format"); + } + try { - const review = await this.trustpilot.getProductReviewById({ + // Build the endpoint URL + const endpoint = buildUrl(ENDPOINTS.PRIVATE_PRODUCT_REVIEW_BY_ID, { reviewId, }); + // Make the API request + const response = await makeRequest($, this.trustpilot, { + endpoint, + }); + + // Parse the product review with the correct parser + const review = parseProductReview(response); + $.export("$summary", `Successfully fetched product review ${reviewId}`); return { 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 5a19583331584..2acc51c2905e1 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,8 @@ import trustpilot from "../../trustpilot.app.mjs"; export default { key: "trustpilot-fetch-product-reviews", name: "Fetch 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.3", + description: "Retrieves a list of product reviews for a specific business unit. See documentation [here](https://developers.trustpilot.com/product-reviews-api/#get-private-product-reviews)", + version: "1.0.0", type: "action", props: { trustpilot, @@ -14,96 +14,69 @@ export default { "businessUnitId", ], }, - stars: { + page: { propDefinition: [ trustpilot, - "stars", + "page", ], }, - sortBy: { + perPage: { propDefinition: [ trustpilot, - "sortBy", + "perPage", ], }, - limit: { + sku: { propDefinition: [ trustpilot, - "limit", + "sku", ], }, - includeReportedReviews: { + language: { propDefinition: [ trustpilot, - "includeReportedReviews", + "language", ], }, - tags: { + state: { propDefinition: [ trustpilot, - "tags", + "state", ], }, - language: { + locale: { propDefinition: [ trustpilot, - "language", + "locale", ], }, - 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, + page, + perPage, + sku, language, - offset, + state, + locale, } = this; try { - const result = await this.trustpilot.getProductReviews({ + // Use the shared method from the app + const result = await this.trustpilot.fetchProductReviews($, { businessUnitId, - stars, - sortBy, - limit, - includeReportedReviews, - tags, + page, + perPage, + sku, language, - offset, + state, + locale, }); - const { - reviews, pagination, - } = result; - - $.export("$summary", `Successfully fetched ${reviews.length} product review(s) for business unit ${businessUnitId}`); + $.export("$summary", `Successfully fetched ${result.reviews.length} product review(s) for business unit ${businessUnitId}`); - return { - reviews, - pagination, - metadata: { - businessUnitId, - filters: { - stars, - sortBy, - includeReportedReviews, - tags, - language, - }, - requestTime: new Date().toISOString(), - }, - }; + return result; } catch (error) { throw new Error(`Failed to fetch product reviews: ${error.message}`); } 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 539ec50372681..0289bab9e6deb 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 @@ -1,19 +1,20 @@ import trustpilot from "../../trustpilot.app.mjs"; +import { makeRequest } from "../../common/api-client.mjs"; +import { ENDPOINTS } from "../../common/constants.mjs"; +import { + buildUrl, + parseServiceReview, + validateReviewId, +} from "../../common/utils.mjs"; export default { key: "trustpilot-fetch-service-review-by-id", name: "Fetch Service Review by ID", - 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.3", + description: "Get a private service review by ID, including customer email and order ID. Access comprehensive data about an individual service review for your business. [See the documentation](https://developers.trustpilot.com/business-units-api#get-private-review-by-id)", + version: "0.1.0", type: "action", props: { trustpilot, - businessUnitId: { - propDefinition: [ - trustpilot, - "businessUnitId", - ], - }, reviewId: { propDefinition: [ trustpilot, @@ -22,23 +23,35 @@ export default { }, }, async run({ $ }) { - const { - businessUnitId, - reviewId, - } = this; + const { reviewId } = this; + + // Validate required parameters + if (!reviewId) { + throw new Error("Review ID is required"); + } + if (!validateReviewId(reviewId)) { + throw new Error("Invalid review ID format"); + } try { - const review = await this.trustpilot.getServiceReviewById({ - businessUnitId, + // Build the endpoint URL for private service review + const endpoint = buildUrl(ENDPOINTS.SERVICE_REVIEW_BY_ID, { reviewId, }); - $.export("$summary", `Successfully fetched service review ${reviewId} for business unit ${businessUnitId}`); + // Make the API request + const response = await makeRequest($, this.trustpilot, { + endpoint, + }); + + // Parse the service review with the correct parser + const review = parseServiceReview(response); + + $.export("$summary", `Successfully fetched service review ${reviewId}`); return { review, metadata: { - businessUnitId, reviewId, requestTime: new Date().toISOString(), }, 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 8408483dc736a..7cfa4e5ce3920 100644 --- a/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs +++ b/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs @@ -3,8 +3,8 @@ import trustpilot from "../../trustpilot.app.mjs"; export default { key: "trustpilot-fetch-service-reviews", name: "Fetch Service 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.3", + description: "Get private reviews for a business unit. Response includes customer email and order ID. [See the documentation](https://developers.trustpilot.com/business-units-api#get-private-reviews-for-business-unit)", + version: "0.1.0", type: "action", props: { trustpilot, @@ -14,48 +14,141 @@ export default { "businessUnitId", ], }, - stars: { + language: { propDefinition: [ trustpilot, - "stars", + "language", ], }, - sortBy: { - propDefinition: [ - trustpilot, - "sortBy", - ], + page: { + type: "integer", + label: "Page", + description: "The page to retrieve. If the page number requested is higher than the available number of pages, an empty array will be returned.", + min: 1, + default: 1, + optional: true, }, - limit: { - propDefinition: [ - trustpilot, - "limit", - ], + stars: { + type: "string", + label: "Star Rating", + description: "Filter by reviews with a specific star rating. 1-5, separated by commas.", + optional: true, }, - includeReportedReviews: { - propDefinition: [ - trustpilot, - "includeReportedReviews", - ], + internalLocationId: { + type: "string", + label: "Internal Location ID", + description: "Filter by reviews with a specific location", + optional: true, }, - tags: { - propDefinition: [ - trustpilot, - "tags", - ], + perPage: { + type: "integer", + label: "Per Page", + description: "The number of reviews to retrieve per page", + min: 1, + max: 100, + default: 20, + optional: true, }, - language: { - propDefinition: [ - trustpilot, - "language", + orderBy: { + type: "string", + label: "Order By", + description: "The order in which the results should be sorted", + options: [ + { + label: "Created At (Ascending)", + value: "createdat.asc", + }, + { + label: "Created At (Descending)", + value: "createdat.desc", + }, + { + label: "Stars (Ascending)", + value: "stars.asc", + }, + { + label: "Stars (Descending)", + value: "stars.desc", + }, ], + default: "createdat.desc", + optional: true, }, - offset: { - type: "integer", - label: "Offset", - description: "Number of results to skip (for pagination)", - min: 0, - default: 0, + tagGroup: { + type: "string", + label: "Tag Group", + description: "Filtering reviews on Tag group", + optional: true, + }, + tagValue: { + type: "string", + label: "Tag Value", + description: "Filtering reviews on Tag value", + optional: true, + }, + ignoreTagValueCase: { + type: "boolean", + label: "Ignore Tag Value Case", + description: "Ignore tag value case", + default: false, + optional: true, + }, + responded: { + type: "boolean", + label: "Responded", + description: "Filter reviews by responded state", + optional: true, + }, + referenceId: { + type: "string", + label: "Reference ID", + description: "Filter reviews by reference Id", + optional: true, + }, + referralEmail: { + type: "string", + label: "Referral Email", + description: "Filter reviews by referral email", + optional: true, + }, + reported: { + type: "boolean", + label: "Reported", + description: "Filter reviews by reported state", + optional: true, + }, + startDateTime: { + type: "string", + label: "Start Date Time", + description: "Filter reviews by datetime range. If no time is specified, then time is implicitly `00:00:00`. Format: `2013-09-07T13:37:00`", + optional: true, + }, + endDateTime: { + type: "string", + label: "End Date Time", + description: "Filter reviews by datetime range. If no time is specified, then time is implicitly `00:00:00`. Format: `2013-09-07T13:37:00`", + optional: true, + }, + source: { + type: "string", + label: "Source", + description: "Filter reviews by source", + optional: true, + }, + username: { + type: "string", + label: "Username", + description: "Filter reviews by user name", + optional: true, + }, + findReviewer: { + type: "string", + label: "Find Reviewer", + description: "Filter reviews by Find Reviewer requests (contacted or not contacted)", + options: [ + "contacted", + "notContacted", + ], optional: true, }, }, @@ -63,47 +156,52 @@ export default { const { businessUnitId, stars, - sortBy, - limit, - includeReportedReviews, - tags, language, - offset, + page = 1, + internalLocationId, + perPage = 20, + orderBy = "createdat.desc", + tagGroup, + tagValue, + ignoreTagValueCase = false, + responded, + referenceId, + referralEmail, + reported, + startDateTime, + endDateTime, + source, + username, + findReviewer, } = this; try { - const result = await this.trustpilot.getServiceReviews({ + // Use the shared method from the app + const result = await this.trustpilot.fetchServiceReviews($, { businessUnitId, stars, - sortBy, - limit, - includeReportedReviews, - tags, language, - offset, + page, + internalLocationId, + perPage, + orderBy, + tagGroup, + tagValue, + ignoreTagValueCase, + responded, + referenceId, + referralEmail, + reported, + startDateTime, + endDateTime, + source, + username, + findReviewer, }); - const { - reviews, pagination, - } = result; - - $.export("$summary", `Successfully fetched ${reviews.length} service review(s) for business unit ${businessUnitId}`); + $.export("$summary", `Successfully fetched ${result.reviews.length} service review(s) for business unit ${businessUnitId}`); - return { - reviews, - pagination, - metadata: { - businessUnitId, - filters: { - stars, - sortBy, - includeReportedReviews, - tags, - language, - }, - requestTime: new Date().toISOString(), - }, - }; + return result; } catch (error) { throw new Error(`Failed to fetch service reviews: ${error.message}`); } diff --git a/components/trustpilot/actions/get-conversation-from-product-review/get-conversation-from-product-review.mjs b/components/trustpilot/actions/get-conversation-from-product-review/get-conversation-from-product-review.mjs new file mode 100644 index 0000000000000..a71fadb03802c --- /dev/null +++ b/components/trustpilot/actions/get-conversation-from-product-review/get-conversation-from-product-review.mjs @@ -0,0 +1,90 @@ +import { ConfigurationError } from "@pipedream/platform"; +import trustpilot from "../../trustpilot.app.mjs"; +import { makeRequest } from "../../common/api-client.mjs"; +import { ENDPOINTS } from "../../common/constants.mjs"; +import { + buildUrl, + validateReviewId, +} from "../../common/utils.mjs"; + +export default { + key: "trustpilot-get-conversation-from-product-review", + name: "Get Conversation from Product Review", + description: "Get conversation and related comments from a product review. First fetches the review to get the conversationId, then retrieves the full conversation details. [See the documentation](https://developers.trustpilot.com/conversations-api#get-conversation)", + version: "0.0.1", + type: "action", + props: { + trustpilot, + reviewId: { + propDefinition: [ + trustpilot, + "reviewId", + ], + }, + }, + async run({ $ }) { + const { reviewId } = this; + + // Validate required parameters + if (!reviewId) { + throw new ConfigurationError("Review ID is required"); + } + if (!validateReviewId(reviewId)) { + throw new ConfigurationError("Invalid review ID format"); + } + + // Step 1: Get the product review to get the conversationId + $.export("$summary", "Fetching product review details..."); + + const getReviewEndpoint = buildUrl(ENDPOINTS.PRIVATE_PRODUCT_REVIEW_BY_ID, { + reviewId, + }); + + const review = await makeRequest($, this.trustpilot, { + endpoint: getReviewEndpoint, + }); + + const conversationId = review.conversationId; + + if (!conversationId) { + return { + success: false, + message: "No conversation found for this product review", + review: { + id: reviewId, + hasConversation: false, + }, + metadata: { + reviewId, + requestTime: new Date().toISOString(), + }, + }; + } + + // Step 2: Get the conversation details + $.export("$summary", "Fetching conversation details..."); + + const getConversationEndpoint = buildUrl(ENDPOINTS.CONVERSATION_BY_ID, { + conversationId, + }); + + const conversation = await makeRequest($, this.trustpilot, { + endpoint: getConversationEndpoint, + }); + + $.export("$summary", `Successfully retrieved conversation ${conversationId} for product review ${reviewId}`); + + return { + success: true, + conversation, + metadata: { + reviewId, + conversationId, + commentCount: conversation.comments?.length || 0, + conversationState: conversation.state, + source: conversation.source, + requestTime: new Date().toISOString(), + }, + }; + }, +}; 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 527c9d6af84b3..1bbf3e34150b9 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 @@ -1,11 +1,17 @@ import { ConfigurationError } from "@pipedream/platform"; import trustpilot from "../../trustpilot.app.mjs"; +import { makeRequest } from "../../common/api-client.mjs"; +import { ENDPOINTS } from "../../common/constants.mjs"; +import { + buildUrl, + validateReviewId, +} from "../../common/utils.mjs"; export default { key: "trustpilot-reply-to-product-review", name: "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.3", + description: "Reply to a product review by creating a conversation and posting a comment. This follows the proper flow: fetch review -> create conversation if needed -> post comment. [See the documentation](https://developers.trustpilot.com/product-reviews-api#reply-to-product-review)", + version: "0.1.0", type: "action", props: { trustpilot, @@ -15,37 +21,124 @@ export default { "reviewId", ], }, - message: { + content: { type: "string", - label: "Reply Message", - description: "The message to reply to the review with", + label: "Reply Content", + description: "The content of your reply to the review", + }, + integrationId: { + type: "string", + label: "Integration ID", + description: "Optional integration ID to track the source of the reply", + optional: true, + }, + businessUserId: { + type: "string", + label: "Business User ID", + description: "The ID of the business user posting the reply (required for creating comments)", }, }, async run({ $ }) { const { reviewId, - message, + content, + integrationId, + businessUserId, } = this; - if (!message || message.trim().length === 0) { - throw new ConfigurationError("Reply message cannot be empty"); + // Validate required parameters + if (!reviewId) { + throw new ConfigurationError("Review ID is required"); + } + if (!validateReviewId(reviewId)) { + throw new ConfigurationError("Invalid review ID format"); + } + if (!content || content.trim().length === 0) { + throw new ConfigurationError("Reply content cannot be empty"); + } + if (!businessUserId) { + throw new ConfigurationError("Business User ID is required"); } - const result = await this.trustpilot.replyToProductReview({ - reviewId, - message: message.trim(), - }); + const trimmedContent = content.trim(); - $.export("$summary", `Successfully replied to product review ${reviewId}`); + try { + // Step 1: Get the product review to check if it has a conversationId + $.export("$summary", "Fetching product review details..."); - return { - success: true, - reply: result, - metadata: { + const getReviewEndpoint = buildUrl(ENDPOINTS.PRIVATE_PRODUCT_REVIEW_BY_ID, { reviewId, - messageLength: message.trim().length, - requestTime: new Date().toISOString(), - }, - }; + }); + + const review = await makeRequest($, this.trustpilot, { + endpoint: getReviewEndpoint, + }); + + let conversationId = review.conversationId; + + // Step 2: Create conversation if it doesn't exist + if (!conversationId) { + $.export("$summary", "Creating conversation for review..."); + + const createConversationEndpoint = buildUrl(ENDPOINTS.CREATE_CONVERSATION_FOR_REVIEW, { + reviewId, + }); + + const createConversationResponse = await makeRequest($, this.trustpilot, { + endpoint: createConversationEndpoint, + method: "POST", + }); + + conversationId = createConversationResponse.conversationId; + + if (!conversationId) { + throw new Error("Failed to create conversation - no conversationId returned"); + } + } + + // Step 3: Create comment on the conversation + $.export("$summary", "Posting reply comment..."); + + const replyEndpoint = buildUrl(ENDPOINTS.REPLY_TO_CONVERSATION, { + conversationId, + }); + + // Prepare request data + const requestData = { + content: trimmedContent, + }; + + // Add integrationId if provided + if (integrationId) { + requestData.integrationId = integrationId; + } + + const replyResponse = await makeRequest($, this.trustpilot, { + endpoint: replyEndpoint, + method: "POST", + data: requestData, + additionalHeaders: { + "x-business-user-id": businessUserId, + }, + }); + + $.export("$summary", `Successfully replied to product review ${reviewId}`); + + return { + success: true, + comment: replyResponse, + metadata: { + reviewId, + conversationId, + businessUserId, + contentLength: trimmedContent.length, + integrationId: integrationId || null, + wasConversationCreated: !review.conversationId, + requestTime: new Date().toISOString(), + }, + }; + } catch (error) { + throw new ConfigurationError(`Failed to reply to product review: ${error.message}`); + } }, }; 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 fdc8b71e01360..f9a082ef815cf 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 @@ -1,20 +1,20 @@ import { ConfigurationError } from "@pipedream/platform"; import trustpilot from "../../trustpilot.app.mjs"; +import { makeRequest } from "../../common/api-client.mjs"; +import { ENDPOINTS } from "../../common/constants.mjs"; +import { + buildUrl, + validateReviewId, +} from "../../common/utils.mjs"; export default { key: "trustpilot-reply-to-service-review", name: "Reply to Service 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.3", + description: "Reply to a service review on Trustpilot.", + version: "0.1.0", type: "action", props: { trustpilot, - businessUnitId: { - propDefinition: [ - trustpilot, - "businessUnitId", - ], - }, reviewId: { propDefinition: [ trustpilot, @@ -24,37 +24,79 @@ export default { message: { type: "string", label: "Reply Message", - description: "The message to reply to the review with", + description: "The message content of your reply to the review", + }, + authorBusinessUserId: { + type: "string", + label: "Author Business User ID", + description: "The ID of the business user posting the reply", }, }, async run({ $ }) { const { - businessUnitId, reviewId, message, + authorBusinessUserId, } = this; + // Validate required parameters + if (!reviewId) { + throw new ConfigurationError("Review ID is required"); + } + if (!validateReviewId(reviewId)) { + throw new ConfigurationError("Invalid review ID format"); + } if (!message || message.trim().length === 0) { throw new ConfigurationError("Reply message cannot be empty"); } + if (!authorBusinessUserId) { + throw new ConfigurationError("Author Business User ID is required"); + } - const result = await this.trustpilot.replyToServiceReview({ - businessUnitId, - reviewId, - message: message.trim(), - }); - - $.export("$summary", `Successfully replied to service review ${reviewId}`); + const trimmedMessage = message.trim(); - return { - success: true, - reply: result, - metadata: { - businessUnitId, + try { + // Build the endpoint URL for replying to service review + const endpoint = buildUrl(ENDPOINTS.REPLY_TO_SERVICE_REVIEW, { reviewId, - messageLength: message.trim().length, - requestTime: new Date().toISOString(), - }, - }; + }); + + // Prepare request data according to API specification + const requestData = { + authorBusinessUserId, + message: trimmedMessage, + }; + + // Make the API request + await makeRequest($, this.trustpilot, { + endpoint, + method: "POST", + data: requestData, + }); + + $.export("$summary", `Successfully replied to service review ${reviewId}`); + + // API returns 201 Created on success, response body may be empty + return { + success: true, + reply: { + message: trimmedMessage, + authorBusinessUserId, + reviewId, + status: "created", + statusCode: 201, + postedAt: new Date().toISOString(), + }, + metadata: { + reviewId, + authorBusinessUserId, + messageLength: trimmedMessage.length, + requestTime: new Date().toISOString(), + httpStatus: "201 Created", + }, + }; + } catch (error) { + throw new ConfigurationError(`Failed to reply to service review: ${error.message}`); + } }, }; diff --git a/components/trustpilot/common/api-client.mjs b/components/trustpilot/common/api-client.mjs new file mode 100644 index 0000000000000..74d357963bb5c --- /dev/null +++ b/components/trustpilot/common/api-client.mjs @@ -0,0 +1,111 @@ +import { axios } from "@pipedream/platform"; +import { BASE_URL } from "./constants.mjs"; +import { formatQueryParams } from "./utils.mjs"; + +/** + * Make an authenticated request to the Trustpilot API + * @param {object} trustpilotApp - The Trustpilot app instance with auth credentials + * @param {object} options - Request options + * @param {string} options.endpoint - API endpoint path + * @param {string} [options.method="GET"] - HTTP method + * @param {object} [options.params={}] - Query parameters + * @param {object} [options.data] - Request body data + * @param {object} [options.additionalHeaders={}] - Additional headers to include in the request + * @param {number} [options.timeout=30000] - Request timeout + * @returns {Promise} API response data + */ +export async function makeRequest($, trustpilotApp, { + endpoint, + method = "GET", + params = {}, + data = null, + timeout = 30000, + additionalHeaders = {}, + ...args +}) { + const url = `${BASE_URL}${endpoint}`; + const headers = { + ...getAuthHeaders(trustpilotApp, url), + ...additionalHeaders, + }; + + const config = { + method, + url, + headers, + params: formatQueryParams(params), + timeout, + ...args, + }; + + if (data) { + config.data = data; + } + + const response = await axios($ ?? trustpilotApp, config); + return response.data || response; +} + +/** + * Determine if a URL requires private authentication + * @param {string} url - The full URL + * @returns {boolean} - True if URL requires OAuth token + */ +function isPrivateURL(url) { + return url.includes("private"); +} + +/** + * Get authentication headers for private URLs (OAuth) + * @param {object} trustpilotApp - The Trustpilot app instance + * @returns {object} - Headers with OAuth token + */ +function getAuthHeadersForPrivateURL(trustpilotApp) { + if (!trustpilotApp.$auth?.oauth_access_token) { + throw new Error("Authentication required: OAuth token is required for private requests"); + } + return { + "Authorization": `Bearer ${trustpilotApp.$auth.oauth_access_token}`, + }; +} + +/** + * Get authentication headers for public URLs (API key) + * @param {object} trustpilotApp - The Trustpilot app instance + * @returns {object} - Headers with API key + */ +function getAuthHeadersForPublicURL(trustpilotApp) { + if (!trustpilotApp.$auth?.api_key) { + throw new Error("Authentication required: API key is required for public requests"); + } + return { + "apikey": trustpilotApp.$auth.api_key, + }; +} + +/** + * Get appropriate authentication headers based on URL + * @param {object} trustpilotApp - The Trustpilot app instance + * @param {string} url - The full URL + * @returns {object} - Complete headers for the request + */ +function getAuthHeaders(trustpilotApp, url) { + const headers = { + "Content-Type": "application/json", + "User-Agent": "Pipedream/1.0", + }; + + const isPrivate = isPrivateURL(url); + + if (isPrivate) { + return { + ...headers, + ...getAuthHeadersForPrivateURL(trustpilotApp), + }; + } else { + return { + ...headers, + ...getAuthHeadersForPublicURL(trustpilotApp), + }; + } +} diff --git a/components/trustpilot/common/constants.mjs b/components/trustpilot/common/constants.mjs index d993e6bb5b0b9..e2d5a8aedb50a 100644 --- a/components/trustpilot/common/constants.mjs +++ b/components/trustpilot/common/constants.mjs @@ -1,112 +1,23 @@ 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}", + BUSINESS_UNITS: "/business-units/search", - // 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", - - // Public Reviews (Product) - PUBLIC_PRODUCT_REVIEWS: "/product-reviews/business-units/{businessUnitId}/reviews", - PUBLIC_PRODUCT_REVIEW_BY_ID: "/product-reviews/{reviewId}", + // Service Reviews + SERVICE_REVIEWS: "/private/business-units/{businessUnitId}/reviews", + SERVICE_REVIEW_BY_ID: "/private/reviews/{reviewId}", + REPLY_TO_SERVICE_REVIEW: "/private/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", + CREATE_CONVERSATION_FOR_REVIEW: "/private/product-reviews/{reviewId}/create-conversation", // 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 - // 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}", + REPLY_TO_CONVERSATION: "/private/conversations/{conversationId}/comments", }; -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", -}; diff --git a/components/trustpilot/common/utils.mjs b/components/trustpilot/common/utils.mjs index 20677e7cac566..89c3e059f1139 100644 --- a/components/trustpilot/common/utils.mjs +++ b/components/trustpilot/common/utils.mjs @@ -100,46 +100,113 @@ export function parseReview(review) { } /** - * Parse Trustpilot business unit data - * @param {object} businessUnit - Raw business unit data from API - * @returns {object} - Parsed business unit data + * Parse Trustpilot product review data + * @param {object} review - Raw product review data from API + * @returns {object} - Parsed product review data */ -export function parseBusinessUnit(businessUnit) { +export function parseProductReview(review) { 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 || [], + id: review.id, + createdAt: review.createdAt, + updatedAt: review.updatedAt, + businessUnitId: review.businessUnitId, + stars: review.stars, + content: escapeHtml(review.content), + product: review.product + ? { + id: review.product.id, + productUrl: review.product.productUrl, + productImages: review.product.productImages || [], + name: escapeHtml(review.product.name), + sku: review.product.sku, + gtin: review.product.gtin, + mpn: review.product.mpn, + brand: escapeHtml(review.product.brand), + } + : null, + consumer: review.consumer + ? { + id: review.consumer.id, + email: review.consumer.email, + name: escapeHtml(review.consumer.name), + } + : null, + referenceId: review.referenceId, + locale: review.locale, + language: review.language, + redirectUri: review.redirectUri, + state: review.state, + hasModerationHistory: review.hasModerationHistory || false, + conversationId: review.conversationId, + attributeRatings: review.attributeRatings?.map((attr) => ({ + attributeId: attr.attributeId, + attributeName: escapeHtml(attr.attributeName), + attributeType: attr.attributeType, + attributeOptions: attr.attributeOptions, + rating: attr.rating, + })) || [], + attachments: review.attachments || [], }; } /** - * Parse webhook payload - * @param {object} payload - Raw webhook payload - * @returns {object} - Parsed webhook data + * Parse Trustpilot service review data + * @param {object} review - Raw service review data from API + * @returns {object} - Parsed service review data */ -export function parseWebhookPayload(payload) { - const { - event, data, - } = payload; - +export function parseServiceReview(review) { 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, + links: review.links || [], + id: review.id, + consumer: review.consumer + ? { + links: review.consumer.links || [], + id: review.consumer.id, + displayName: escapeHtml(review.consumer.displayName), + displayLocation: escapeHtml(review.consumer.displayLocation), + numberOfReviews: review.consumer.numberOfReviews, + } + : null, + businessUnit: review.businessUnit + ? { + links: review.businessUnit.links || [], + id: review.businessUnit.id, + identifyingName: escapeHtml(review.businessUnit.identifyingName), + displayName: escapeHtml(review.businessUnit.displayName), + } + : null, + location: escapeHtml(review.location), + stars: review.stars, + title: escapeHtml(review.title), + text: escapeHtml(review.text), + language: review.language, + createdAt: review.createdAt, + experiencedAt: review.experiencedAt, + updatedAt: review.updatedAt, + companyReply: review.companyReply + ? { + text: escapeHtml(review.companyReply.text), + authorBusinessUserId: review.companyReply.authorBusinessUserId, + authorBusinessUserName: escapeHtml(review.companyReply.authorBusinessUserName), + createdAt: review.companyReply.createdAt, + updatedAt: review.companyReply.updatedAt, + } + : null, + isVerified: review.isVerified || false, + source: review.source, + numberOfLikes: review.numberOfLikes || 0, + status: review.status, + reportData: review.reportData, + complianceLabels: review.complianceLabels || [], + countsTowardsTrustScore: review.countsTowardsTrustScore || false, + countsTowardsLocationTrustScore: review.countsTowardsLocationTrustScore, + invitation: review.invitation + ? { + businessUnitId: review.invitation.businessUnitId, + } + : null, + businessUnitHistory: review.businessUnitHistory || [], + reviewVerificationLevel: review.reviewVerificationLevel, }; } @@ -214,12 +281,3 @@ export function parseApiError(error) { 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)); -} diff --git a/components/trustpilot/package.json b/components/trustpilot/package.json index 7e03ac2b66435..5c3271fa982b2 100644 --- a/components/trustpilot/package.json +++ b/components/trustpilot/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/trustpilot", - "version": "0.1.3", + "version": "0.2.0", "description": "Pipedream Trustpilot Components", "main": "trustpilot.app.mjs", "keywords": [ diff --git a/components/trustpilot/sources/common/polling.mjs b/components/trustpilot/sources/common/polling.mjs index 2af5b59d4c30a..112ded8cb876a 100644 --- a/components/trustpilot/sources/common/polling.mjs +++ b/components/trustpilot/sources/common/polling.mjs @@ -1,19 +1,11 @@ import trustpilot from "../../trustpilot.app.mjs"; -import { - POLLING_CONFIG, SOURCE_TYPES, -} from "../../common/constants.mjs"; +import { DEFAULT_LIMIT } 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. + * Provides common functionality for polling Trustpilot API endpoints + * and emitting new events with deduplication. */ export default { props: { @@ -22,7 +14,7 @@ export default { timer: { type: "$.interface.timer", default: { - intervalSeconds: POLLING_CONFIG.DEFAULT_TIMER_INTERVAL_SECONDS, + intervalSeconds: 15 * 60, // 15 minutes }, }, businessUnitId: { @@ -30,159 +22,120 @@ export default { 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() { - // Override in child classes to return method-specific parameters + _getLastReviewTime() { + return this.db.get(`lastReviewTime:${this.businessUnitId}`); + }, + _setLastReviewTime(time) { + this.db.set(`lastReviewTime:${this.businessUnitId}`, time); + }, + /** + * Override in child classes to provide review type-specific summary + * @param {Object} _review - The review object + * @returns {string} - Human-readable summary + */ + // eslint-disable-next-line no-unused-vars + generateSummary(_review) { + throw new Error("generateSummary must be implemented in child class"); + }, + /** + * Override in child classes to fetch reviews. + * Requirements: + * - Must return ALL reviews newer than `lastReviewTime` (handle pagination internally), or + * - Return the first page AND expose a pagination cursor so the base can iterate (future). + * @param {Object} _$ - Pipedream step context + * @param {Object} _params - Fetch parameters produced by `getFetchParams(lastReviewTime)` + * @returns {{ reviews: Array }} - Array of normalized reviews + */ + // eslint-disable-next-line no-unused-vars + async fetchReviews(_$, _params) { + throw new Error("fetchReviews must be implemented in child class"); + }, + /** + * Override in child classes to provide fetch parameters + * @param {string} _lastReviewTime - ISO timestamp of last review + * @returns {Object} - Parameters for fetchReviews call + */ + // eslint-disable-next-line no-unused-vars + getFetchParams(_lastReviewTime) { 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); - const timestamp = sourceType.includes("updated") - ? item.updatedAt - : (item.createdAt || item.updatedAt); - - return { - id: dedupeKey, - summary, - ts: new Date(timestamp).getTime(), + perPage: DEFAULT_LIMIT, }; }, - generateSummary(item) { - // Override in child classes for specific summaries - return `${this.getSourceType()} - ${item.id}`; + /** + * Override in child classes to filter reviews (for APIs without time filtering) + * @param {Array} reviews - Array of reviews from API + * @param {string} _lastReviewTime - ISO timestamp of last review + * @returns {Array} - Filtered array of new reviews + */ + // eslint-disable-next-line no-unused-vars + filterNewReviews(reviews, _lastReviewTime) { + if (!_lastReviewTime) return reviews; + const lastMs = Date.parse(_lastReviewTime); + if (Number.isNaN(lastMs)) return reviews; + + return reviews.filter((r) => { + const ms = Date.parse(r?.createdAt); + return Number.isFinite(ms) && ms > lastMs; + }); }, - async fetchItems() { - const method = this.getPollingMethod(); - const params = this.getPollingParams(); - - 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 run({ $ }) { + // Get the last review time for filtering new reviews + const lastReviewTime = this._getLastReviewTime(); + + // Get fetch parameters from child class + const fetchParams = this.getFetchParams(lastReviewTime); + + // Fetch reviews using child class method + const result = await this.fetchReviews($, fetchParams); + const reviews = result.reviews || []; + + if (!reviews.length) { + console.log("No reviews found"); + return; + } + + // Filter for new reviews (child class may override) + const newReviews = this.filterNewReviews(reviews, lastReviewTime); + + if (!newReviews.length) { + console.log("No new reviews since last poll"); + return; + } + + // Track the latest review time + let latestReviewTime = lastReviewTime; + + for (const review of newReviews) { + // Track the latest review time + const createdMs = Date.parse(review?.createdAt); + if (!Number.isFinite(createdMs)) { + ($.logger?.warn ?? console.warn)("Skipping review with invalid createdAt", { + id: review?.id, + createdAt: review?.createdAt, + }); + continue; + } + const reviewTime = new Date(createdMs).toISOString(); + if (!latestReviewTime || createdMs > Date.parse(latestReviewTime)) { + latestReviewTime = reviewTime; } - }, - async pollForItems() { - const sourceType = this.getSourceType(); - const lastPolled = this._getLastPolled(); - const seenItems = this._getSeenItems(); - - // If first run, look back 24 hours - 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; - 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}`); + // Emit the review with unique ID and summary + this.$emit(review, { + id: review.id, + summary: this.generateSummary(review), + ts: createdMs, + }); + } - } catch (error) { - console.error(`Polling failed for ${sourceType}:`, error); - throw error; - } - }, - }, - async run() { - await this.pollForItems(); + // Update the last review time for next poll + if (latestReviewTime && latestReviewTime !== lastReviewTime) { + this._setLastReviewTime(latestReviewTime); + } }, }; diff --git a/components/trustpilot/sources/new-conversations/new-conversations.mjs b/components/trustpilot/sources/new-conversations/new-conversations.mjs deleted file mode 100644 index d9671f964605e..0000000000000 --- a/components/trustpilot/sources/new-conversations/new-conversations.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { - SORT_OPTIONS, - SOURCE_TYPES, -} from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; - -export default { - ...common, - key: "trustpilot-new-conversations", - name: "New Conversations", - description: "Emit new event when a new conversation is started on Trustpilot. This source periodically polls the Trustpilot API 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.3", - type: "source", - dedupe: "unique", - methods: { - ...common.methods, - getSourceType() { - return SOURCE_TYPES.NEW_CONVERSATIONS; - }, - getPollingMethod() { - return "getConversations"; - }, - getPollingParams() { - return { - businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.CREATED_AT_DESC, - offset: 0, - }; - }, - 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})`; - }, - }, -}; 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 deleted file mode 100644 index faeb1b447d0e2..0000000000000 --- a/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { - SORT_OPTIONS, - SOURCE_TYPES, -} from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; - -export default { - ...common, - key: "trustpilot-new-product-review-replies", - name: "New Product Review Replies", - description: "Emit new event when a business replies to a product review on Trustpilot. This source periodically polls the Trustpilot API 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.3", - type: "source", - dedupe: "unique", - methods: { - ...common.methods, - getSourceType() { - return SOURCE_TYPES.NEW_REPLIES; - }, - getPollingMethod() { - return "getProductReviews"; - }, - getPollingParams() { - return { - businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.UPDATED_AT_DESC, // Use updated to catch new replies - offset: 0, - }; - }, - 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) { - // 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) { - 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}"`; - }, - }, -}; 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 8a68b21f553a1..d04132e3c7f92 100644 --- a/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs +++ b/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs @@ -1,40 +1,91 @@ +import common from "../common/polling.mjs"; import { - SORT_OPTIONS, - SOURCE_TYPES, + DEFAULT_LIMIT, + MAX_LIMIT, } from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; export default { ...common, key: "trustpilot-new-product-reviews", name: "New Product Reviews", description: "Emit new event when a customer posts a new product review on Trustpilot. This source periodically polls the Trustpilot API 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.3", + version: "0.1.0", type: "source", dedupe: "unique", methods: { ...common.methods, - getSourceType() { - return SOURCE_TYPES.NEW_REVIEWS; - }, - getPollingMethod() { - return "getProductReviews"; + generateSummary(review) { + const stars = review.stars || "N/A"; + const consumerName = review.consumer?.name || "Anonymous"; + const productName = review.product?.name || "Unknown Product"; + const businessUnit = this.businessUnitId || "Unknown"; + + return `New ${stars}-star product review by ${consumerName} for "${productName}" (${businessUnit})`; }, - getPollingParams() { + getFetchParams() { + // Note: Product reviews API doesn't support time-based filtering, + // so we'll rely on pagination and client-side filtering return { businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.CREATED_AT_DESC, - offset: 0, + perPage: DEFAULT_LIMIT, + page: 1, }; }, - 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"; + async fetchReviews($, params) { + const perPage = params.perPage ?? DEFAULT_LIMIT; + let page = params.page ?? 1; - return `New ${stars}-star product review by ${consumerName} for "${productName}" (${businessUnit})`; + // fetch first page + let result = await this.trustpilot.fetchProductReviews($, { + ...params, + page, + }); + let all = Array.isArray(result.reviews) + ? result.reviews + : []; + let lastPageSize = all.length; + + // keep paging while we get a full page and stay under MAX_LIMIT + while (lastPageSize === perPage && all.length < MAX_LIMIT) { + page += 1; + const next = await this.trustpilot.fetchProductReviews($, { + ...params, + page, + }); + const chunk = Array.isArray(next.reviews) ? + next.reviews : + []; + if (chunk.length === 0) break; + + all = all.concat(chunk); + lastPageSize = chunk.length; + result = next; // preserve any metadata from the latest fetch + } + + // truncate to MAX_LIMIT in case there are more than allowed + result.reviews = all.slice(0, MAX_LIMIT); + return result; + }, + filterNewReviews(reviews, lastReviewTime) { + // Product reviews require client-side filtering since API doesn't support + // time-based filtering + const lastTs = Number(lastReviewTime) || 0; + const toMs = (d) => new Date(d).getTime(); + + return lastTs + ? reviews.filter((r) => toMs(r.createdAt) > lastTs) + : reviews; + }, + _getLastReviewTime() { + // Product reviews store timestamp as number (ms), others store as ISO string + return this.db.get("lastReviewTime"); + }, + _setLastReviewTime(time) { + // Store as number for product reviews to match existing behavior + const timeMs = typeof time === "string" + ? new Date(time).getTime() + : time; + this.db.set("lastReviewTime", timeMs); }, }, }; 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 deleted file mode 100644 index 98e65170cccdf..0000000000000 --- a/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs +++ /dev/null @@ -1,71 +0,0 @@ -import { - SORT_OPTIONS, - SOURCE_TYPES, -} from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; - -export default { - ...common, - key: "trustpilot-new-service-review-replies", - name: "New Service Review Replies", - description: "Emit new event when a business replies to a service review on Trustpilot. This source periodically polls the Trustpilot API 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.3", - type: "source", - dedupe: "unique", - methods: { - ...common.methods, - getSourceType() { - return SOURCE_TYPES.NEW_REPLIES; - }, - getPollingMethod() { - return "getServiceReviews"; - }, - getPollingParams() { - return { - businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.UPDATED_AT_DESC, // Use updated to catch new replies - offset: 0, - }; - }, - 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) { - // 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) { - 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}"`; - }, - }, -}; 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 22c2787be72a8..e58cce907fe95 100644 --- a/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs +++ b/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs @@ -1,40 +1,60 @@ +import common from "../common/polling.mjs"; import { - SORT_OPTIONS, - SOURCE_TYPES, + DEFAULT_LIMIT, + MAX_LIMIT, } from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; export default { ...common, key: "trustpilot-new-service-reviews", name: "New Service Reviews", - description: "Emit new event when a customer posts a new service review on Trustpilot. This source periodically polls the Trustpilot API 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.3", + description: "Emit new event when a customer posts a new service review on Trustpilot. This source periodically polls the Trustpilot API to detect new service reviews using the private reviews API for comprehensive coverage.", + version: "0.1.0", 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"; + generateSummary(review) { + const stars = review.stars || "N/A"; + const consumerName = review.consumer?.displayName || "Anonymous"; + const businessUnit = review.businessUnit?.displayName || this.businessUnitId || "Unknown"; + + return `New ${stars}-star service review by ${consumerName} for ${businessUnit}`; }, - getPollingParams() { - return { + getFetchParams(lastReviewTime) { + const params = { businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.CREATED_AT_DESC, - offset: 0, + perPage: DEFAULT_LIMIT, + orderBy: "createdat.desc", }; + + // If we have a last review time, filter for reviews after that time + if (lastReviewTime) { + params.startDateTime = lastReviewTime; + } + + return params; }, - generateSummary(item) { - const stars = item.stars || "N/A"; - const consumerName = item.consumer?.displayName || "Anonymous"; - const businessUnit = item.businessUnit?.displayName || this.businessUnitId || "Unknown"; + async fetchReviews($, params) { + // Use the shared method from the app directly with pagination support + let result = await this.trustpilot.fetchServiceReviews($, params); - return `New ${stars}-star service review by ${consumerName} for ${businessUnit}`; + // Handle pagination for service reviews + if (result.reviews && result.reviews.length === DEFAULT_LIMIT) { + while (true) { + params.page = (params.page || 1) + 1; + const nextResult = await this.trustpilot.fetchServiceReviews($, params); + result.reviews = result.reviews.concat(nextResult.reviews || []); + + if (!nextResult.reviews || + nextResult.reviews.length < DEFAULT_LIMIT || + result.reviews.length >= MAX_LIMIT) { + break; + } + } + } + + return result; }, }, }; diff --git a/components/trustpilot/sources/updated-conversations/updated-conversations.mjs b/components/trustpilot/sources/updated-conversations/updated-conversations.mjs deleted file mode 100644 index fe839cb905253..0000000000000 --- a/components/trustpilot/sources/updated-conversations/updated-conversations.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { - SORT_OPTIONS, - SOURCE_TYPES, -} from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; - -export default { - ...common, - key: "trustpilot-updated-conversations", - name: "New Updated Conversations", - description: "Emit new event when an existing conversation is updated with new messages on Trustpilot. This source periodically polls the Trustpilot API 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.3", - type: "source", - dedupe: "unique", - methods: { - ...common.methods, - getSourceType() { - return SOURCE_TYPES.UPDATED_CONVERSATIONS; - }, - getPollingMethod() { - return "getConversations"; - }, - getPollingParams() { - return { - businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.UPDATED_AT_DESC, - offset: 0, - }; - }, - 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}`; - }, - }, -}; diff --git a/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs b/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs deleted file mode 100644 index 1d2cec1ae341a..0000000000000 --- a/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { - SORT_OPTIONS, - SOURCE_TYPES, -} from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; - -export default { - ...common, - key: "trustpilot-updated-product-reviews", - name: "New Updated Product Reviews", - description: "Emit new event when an existing product review is updated or revised on Trustpilot. This source periodically polls the Trustpilot API 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.3", - type: "source", - dedupe: "unique", - methods: { - ...common.methods, - getSourceType() { - return SOURCE_TYPES.UPDATED_REVIEWS; - }, - getPollingMethod() { - return "getProductReviews"; - }, - getPollingParams() { - return { - businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.UPDATED_AT_DESC, - offset: 0, - }; - }, - 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})`; - }, - }, -}; diff --git a/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs b/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs deleted file mode 100644 index 13f9721b634a8..0000000000000 --- a/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { - SORT_OPTIONS, - SOURCE_TYPES, -} from "../../common/constants.mjs"; -import common from "../common/polling.mjs"; - -export default { - ...common, - key: "trustpilot-updated-service-reviews", - name: "New Updated Service Reviews", - description: "Emit new event when an existing service review is updated or revised on Trustpilot. This source periodically polls the Trustpilot API 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.3", - type: "source", - dedupe: "unique", - methods: { - ...common.methods, - getSourceType() { - return SOURCE_TYPES.UPDATED_REVIEWS; - }, - getPollingMethod() { - return "getServiceReviews"; - }, - getPollingParams() { - return { - businessUnitId: this.businessUnitId, - limit: 100, - sortBy: SORT_OPTIONS.UPDATED_AT_DESC, - offset: 0, - }; - }, - 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}`; - }, - }, -}; diff --git a/components/trustpilot/trustpilot.app.mjs b/components/trustpilot/trustpilot.app.mjs index 2f6be2e4e5e3b..31e6f84f23d8d 100644 --- a/components/trustpilot/trustpilot.app.mjs +++ b/components/trustpilot/trustpilot.app.mjs @@ -1,25 +1,10 @@ -import { axios } from "@pipedream/platform"; -import * as crypto from "crypto"; -import { - BASE_URL, - DEFAULT_LIMIT, - ENDPOINTS, - HTTP_STATUS, - MAX_LIMIT, - RATING_SCALE, - RETRY_CONFIG, - SORT_OPTIONS, -} from "./common/constants.mjs"; +import { makeRequest } from "./common/api-client.mjs"; +import { ENDPOINTS } from "./common/constants.mjs"; import { buildUrl, - formatQueryParams, - parseBusinessUnit, - parseReview, - parseWebhookPayload, - sanitizeInput, - sleep, + parseServiceReview, + parseProductReview, validateBusinessUnitId, - validateReviewId, } from "./common/utils.mjs"; export default { @@ -30,18 +15,33 @@ export default { type: "string", label: "Business Unit ID", description: "The unique identifier for your business unit on Trustpilot", - async options() { + useQuery: true, + async options({ + page, query, + }) { try { + if (query === "") { + // Trustpilot requires a query to be passed in, default to "a" if empty + query = "a"; + } + const businessUnits = await this.searchBusinessUnits({ - query: "", - limit: 20, + // Trustpilot requires the page to be 1-indexed + // whereas pipedream is 0-indexed + page: page + 1, + query, + }); + + return businessUnits.map((businessUnit) => { + const { + id, displayName, + } = businessUnit; + + return { + label: displayName, + value: id, + }; }); - return businessUnits.map(({ - id, displayName, name: { identifying }, - }) => ({ - label: `${identifying || displayName}`, - value: id, - })); } catch (error) { console.error("Error fetching business units:", error); return []; @@ -53,47 +53,27 @@ export default { 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: { + sku: { 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, - })), + label: "SKU", + description: "Filter by SKU", optional: true, - default: SORT_OPTIONS.CREATED_AT_DESC, }, - limit: { + page: { type: "integer", - label: "Limit", - description: "Maximum number of results to return", + label: "Page", + description: "The page to retrieve", min: 1, - max: MAX_LIMIT, - default: DEFAULT_LIMIT, + default: 1, 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", + perPage: { + type: "integer", + label: "Per Page", + description: "The number of items to retrieve per page", + min: 1, + max: 100, + default: 20, optional: true, }, language: { @@ -102,479 +82,162 @@ export default { description: "Filter reviews by language (ISO 639-1 code)", optional: true, }, - }, - methods: { - // Authentication and base request methods - _getAuthHeaders() { - const headers = { - "Content-Type": "application/json", - "User-Agent": "Pipedream/1.0", - }; - - if (!this.$auth?.api_key && !this.$auth?.oauth_access_token) { - throw new Error("Authentication required: Configure either API key or OAuth token"); - } - - 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; - } - - const response = await axios(this, config); - return response.data || response; - }, - - 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; - } + state: { + type: "string", + label: "State", + description: "Which reviews to retrieve according to their review state. Default is Published.", + options: [ + "published", + "unpublished", + ], + optional: true, }, - - // 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); + locale: { + type: "string", + label: "Locale", + description: "The language in which the attributes, if any, are returned", + optional: true, }, - + }, + methods: { async searchBusinessUnits({ - query = "", limit = DEFAULT_LIMIT, offset = 0, + query = "a", page = 1, } = {}) { - const response = await this._makeRequest({ + const response = await makeRequest(this, this, { endpoint: ENDPOINTS.BUSINESS_UNITS, params: { query, - limit, - offset, + page, }, }); - return response.businessUnits?.map(parseBusinessUnit) || []; + return response.businessUnits || []; }, - // 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, { + // Shared method for fetching service reviews - used by both actions and sources + async fetchServiceReviews($, params = {}) { + const { businessUnitId, - }); - const params = { - stars, - orderBy: sortBy, - perPage: limit, - page: Math.floor(offset / limit) + 1, - language, - }; + page = 1, + perPage = 20, + orderBy = "createdat.desc", + ignoreTagValueCase = false, + ...filters + } = params; - if (tags.length > 0) { - params.tags = tags.join(","); + // Validate required parameters + if (!businessUnitId) { + throw new Error("Business Unit ID is required"); } - - 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"); + throw new Error("Invalid business unit ID format"); } - const endpoint = buildUrl(ENDPOINTS.PUBLIC_REVIEW_BY_ID, { + // Build the endpoint URL + const endpoint = buildUrl(ENDPOINTS.SERVICE_REVIEWS, { businessUnitId, - reviewId, }); - const response = await this._makeRequest({ - endpoint, - }); - return parseReview(response); - }, - - // Private helper for fetching reviews - async _getReviews({ - endpoint, - businessUnitId, - stars = null, - sortBy = SORT_OPTIONS.CREATED_AT_DESC, - limit = DEFAULT_LIMIT, - offset = 0, - includeReportedReviews = false, - tags = [], - language = null, - }) { - if (businessUnitId && !validateBusinessUnitId(businessUnitId)) { - throw new Error("Invalid business unit ID"); - } - const params = { - stars, - orderBy: sortBy, - perPage: limit, - page: Math.floor(offset / limit) + 1, - includeReportedReviews, - language, + // Prepare query parameters + const queryParams = { + ...filters, + page, + perPage, + orderBy, + ignoreTagValueCase, }; - if (tags.length > 0) { - params.tags = tags.join(","); - } - - const response = await this._makeRequestWithRetry({ - endpoint: endpoint || ENDPOINTS.PRIVATE_SERVICE_REVIEWS, - params, - }); + // Make the API request + const response = await makeRequest($, this, { + endpoint, + params: queryParams, + }); + + // Handle the correct response structure (reviews array) + const reviews = response.reviews?.map(parseServiceReview) || []; + const pagination = { + total: typeof response.total === "number" + ? response.total : + null, + // Preserve the page and perPage we requested + page: queryParams.page, + perPage: queryParams.perPage, + // Determine if there’s a next page by checking for a "next" link + hasMore: Array.isArray(response.links) + ? response.links.some((l) => l?.rel === "next-page") + : false, + }; 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, + reviews, + pagination, + metadata: { + businessUnitId, + filters: queryParams, + requestTime: new Date().toISOString(), }, }; }, - // Private Service Review methods - async getServiceReviews(options = {}) { - const endpoint = buildUrl(ENDPOINTS.PUBLIC_REVIEWS, { - businessUnitId: options.businessUnitId, - }); - return this._getReviews({ - endpoint, - ...options, - }); - }, - - 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, { + // Shared method for fetching product reviews - used by both actions and sources + async fetchProductReviews($, params = {}) { + const { businessUnitId, - reviewId, - }); - const response = await this._makeRequest({ - endpoint, - }); - return parseReview(response); - }, + page, + perPage, + sku, + language, + state, + locale, + } = params; - 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"); + // Validate required parameters + if (!businessUnitId) { + throw new Error("Business Unit ID 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 (!validateBusinessUnitId(businessUnitId)) { + throw new Error("Invalid business unit ID format"); } - const endpoint = buildUrl(ENDPOINTS.REPLY_TO_SERVICE_REVIEW, { + // Build the endpoint URL + const endpoint = buildUrl(ENDPOINTS.PRIVATE_PRODUCT_REVIEWS, { businessUnitId, - reviewId, - }); - const response = await this._makeRequest({ - endpoint, - method: "POST", - data: { - message: sanitizedMessage, - }, }); - return response; - }, - - // Product Review methods - async getProductReviews(options = {}) { - const endpoint = buildUrl(ENDPOINTS.PUBLIC_PRODUCT_REVIEWS, { - businessUnitId: options.businessUnitId, - }); - return this._getReviews({ - endpoint, - ...options, - }); - }, - 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"); - } - - // 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"); - } + // Prepare query parameters + const queryParams = { + sku, + state, + locale, + perPage, + page, + language, + }; - const endpoint = buildUrl(ENDPOINTS.REPLY_TO_PRODUCT_REVIEW, { - reviewId, - }); - const response = await this._makeRequest({ + // Make the API request + const response = await makeRequest($, this, { endpoint, - method: "POST", - data: { - message: sanitizedMessage, - }, + params: queryParams, }); - 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, + // Handle the correct response structure (productReviews, not reviews) + const reviews = response.productReviews?.map(parseProductReview) || []; + const pagination = { + total: response.total || 0, + page: queryParams.page || 1, + perPage: queryParams.perPage || 20, + hasMore: response.links?.some((l) => l.rel === "next") || false, }; - 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, + reviews, + pagination, + metadata: { + businessUnitId, + filters: queryParams, + requestTime: new Date().toISOString(), }, }; }, - - 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"); - } - - // 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"); - } - - const endpoint = buildUrl(ENDPOINTS.REPLY_TO_CONVERSATION, { - conversationId, - }); - const response = await this._makeRequest({ - endpoint, - method: "POST", - data: { - message: sanitizedMessage, - }, - }); - 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) { - // Trustpilot uses HMAC-SHA256 for webhook signature validation - // The signature is sent in the x-trustpilot-signature header - if (!signature || !secret) { - return false; - } - - 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), - ); - }, - }, };