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..b02a17c892bd3 --- /dev/null +++ b/components/trustpilot/actions/fetch-product-review-by-id/fetch-product-review-by-id.mjs @@ -0,0 +1,40 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +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.1", + publishedAt: "2025-07-18T00:00:00.000Z", + 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}`); + } + }, +}; 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..0566684602624 --- /dev/null +++ b/components/trustpilot/actions/fetch-product-reviews/fetch-product-reviews.mjs @@ -0,0 +1,112 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +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.1", + publishedAt: "2025-07-18T00:00:00.000Z", + 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}`); + } + }, +}; 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..f5c70845d377b --- /dev/null +++ b/components/trustpilot/actions/fetch-service-review-by-id/fetch-service-review-by-id.mjs @@ -0,0 +1,51 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +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.1", + publishedAt: "2025-07-18T00:00:00.000Z", + 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}`); + } + }, +}; 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..236d28d756726 --- /dev/null +++ b/components/trustpilot/actions/fetch-service-reviews/fetch-service-reviews.mjs @@ -0,0 +1,112 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +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.1", + type: "action", + publishedAt: "2025-07-18T00:00:00.000Z", + 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}`); + } + }, +}; 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..a6a19f6d78dc6 --- /dev/null +++ b/components/trustpilot/actions/reply-to-product-review/reply-to-product-review.mjs @@ -0,0 +1,55 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +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.1", + publishedAt: "2025-07-18T00:00:00.000Z", + 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}`); + } + }, +}; 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..3c46a7dcbb6c7 --- /dev/null +++ b/components/trustpilot/actions/reply-to-service-review/reply-to-service-review.mjs @@ -0,0 +1,64 @@ +import trustpilot from "../../app/trustpilot.app.ts"; + +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.1", + publishedAt: "2025-07-18T00:00:00.000Z", + 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}`); + } + }, +}; diff --git a/components/trustpilot/app/trustpilot.app.ts b/components/trustpilot/app/trustpilot.app.ts index 3f00f2e9d4a85..2f6b8ff77fa2e 100644 --- a/components/trustpilot/app/trustpilot.app.ts +++ b/components/trustpilot/app/trustpilot.app.ts @@ -1,13 +1,550 @@ import { defineApp } from "@pipedream/types"; +import { axios } from "@pipedream/platform"; +import crypto from "crypto"; +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, + sanitizeInput, +} 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 - authKeys() { - console.log(Object.keys(this.$auth)); + // 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"); + } + + // 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: sanitizedMessage }, + }); + 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"); + } + + // 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: sanitizedMessage }, + }); + 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"); + } + + // 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: 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) + ); + }, + }, }); \ 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..5862a7521527a --- /dev/null +++ b/components/trustpilot/common/constants.mjs @@ -0,0 +1,108 @@ +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 + // 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}", +}; + +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 new file mode 100644 index 0000000000000..20677e7cac566 --- /dev/null +++ b/components/trustpilot/common/utils.mjs @@ -0,0 +1,225 @@ +/** + * 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 + * @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 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; +} + +/** + * 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: escapeHtml(review.title), + text: escapeHtml(review.text), + language: review.language, + location: escapeHtml(review.location), + tags: review.tags || [], + createdAt: review.createdAt, + updatedAt: review.updatedAt, + consumer: { + id: review.consumer?.id, + displayName: escapeHtml(review.consumer?.displayName), + numberOfReviews: review.consumer?.numberOfReviews, + }, + company: { + reply: review.companyReply + ? { + text: escapeHtml(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) { + // Trustpilot Business Unit IDs are 24-character hexadecimal strings (MongoDB ObjectID format) + return ( + typeof businessUnitId === "string" && + /^[a-f0-9]{24}$/.test(businessUnitId) + ); +} + +/** + * Validate review ID format + * @param {string} reviewId - Review ID to validate + * @returns {boolean} - Whether the ID is valid + */ +export function validateReviewId(reviewId) { + // Trustpilot Review IDs are 24-character hexadecimal strings (MongoDB ObjectID format) + return ( + typeof reviewId === "string" && + /^[a-f0-9]{24}$/.test(reviewId) + ); +} + +/** + * 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)); +} diff --git a/components/trustpilot/package.json b/components/trustpilot/package.json index 4f83c4cbf38e1..8280ac25a15a7 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": "^3.0.0" } } diff --git a/components/trustpilot/sources/common/polling.mjs b/components/trustpilot/sources/common/polling.mjs new file mode 100644 index 0000000000000..dcc63c8f8c4a0 --- /dev/null +++ b/components/trustpilot/sources/common/polling.mjs @@ -0,0 +1,188 @@ +import trustpilot from "../../app/trustpilot.app.ts"; +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, + 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() { + // 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); + const timestamp = sourceType.includes("updated") + ? item.updatedAt + : (item.createdAt || item.updatedAt); + + return { + id: dedupeKey, + summary, + ts: new Date(timestamp).getTime(), + }; + }, + generateSummary(item) { + // Override in child classes for specific summaries + return `${this.getSourceType()} - ${item.id}`; + }, + 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 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}`); + + } catch (error) { + console.error(`Polling failed for ${sourceType}:`, error); + throw error; + } + }, + }, + async run() { + await this.pollForItems(); + }, +}; 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..ff7bf1a9eac7c --- /dev/null +++ b/components/trustpilot/sources/new-conversations/new-conversations.mjs @@ -0,0 +1,41 @@ +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 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: { + ...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 new file mode 100644 index 0000000000000..140459ce51369 --- /dev/null +++ b/components/trustpilot/sources/new-product-review-replies/new-product-review-replies.mjs @@ -0,0 +1,72 @@ +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 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: { + ...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 new file mode 100644 index 0000000000000..76319371a59d1 --- /dev/null +++ b/components/trustpilot/sources/new-product-reviews/new-product-reviews.mjs @@ -0,0 +1,40 @@ +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 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: { + ...common.methods, + getSourceType() { + return SOURCE_TYPES.NEW_REVIEWS; + }, + getPollingMethod() { + return "getProductReviews"; + }, + getPollingParams() { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.CREATED_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 `New ${stars}-star product review by ${consumerName} for "${productName}" (${businessUnit})`; + }, + }, +}; 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..b00fcc567d582 --- /dev/null +++ b/components/trustpilot/sources/new-service-review-replies/new-service-review-replies.mjs @@ -0,0 +1,71 @@ +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 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: { + ...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 new file mode 100644 index 0000000000000..ea6f98c21847c --- /dev/null +++ b/components/trustpilot/sources/new-service-reviews/new-service-reviews.mjs @@ -0,0 +1,40 @@ +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 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: { + ...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() { + return { + businessUnitId: this.businessUnitId, + limit: 100, + sortBy: SORT_OPTIONS.CREATED_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 `New ${stars}-star service review by ${consumerName} for ${businessUnit}`; + }, + }, +}; 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..4a011c9b8aa6a --- /dev/null +++ b/components/trustpilot/sources/updated-conversations/updated-conversations.mjs @@ -0,0 +1,42 @@ +import common from "../common/polling.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.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 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: { + ...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 new file mode 100644 index 0000000000000..f6dc778dd5999 --- /dev/null +++ b/components/trustpilot/sources/updated-product-reviews/updated-product-reviews.mjs @@ -0,0 +1,40 @@ +import common from "../common/polling.mjs"; +import { + SOURCE_TYPES, SORT_OPTIONS, +} from "../../common/constants.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 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: { + ...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 new file mode 100644 index 0000000000000..fb18407d0234c --- /dev/null +++ b/components/trustpilot/sources/updated-service-reviews/updated-service-reviews.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-service-reviews", + 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: { + ...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/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: