diff --git a/components/canny/actions/change-post-status/change-post-status.mjs b/components/canny/actions/change-post-status/change-post-status.mjs new file mode 100644 index 0000000000000..8be3053413ddb --- /dev/null +++ b/components/canny/actions/change-post-status/change-post-status.mjs @@ -0,0 +1,72 @@ +import canny from "../../canny.app.mjs"; + +export default { + key: "canny-change-post-status", + name: "Change Post Status", + description: "Change the status of a post. [See the documentation](https://developers.canny.io/api-reference#change_post_status)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + canny, + boardId: { + propDefinition: [ + canny, + "boardId", + ], + optional: true, + }, + postId: { + propDefinition: [ + canny, + "postId", + (c) => ({ + boardId: c.boardId, + }), + ], + }, + changerId: { + propDefinition: [ + canny, + "userId", + ], + label: "Changer ID", + description: "The identifier of the admin to record as having changed the post's status", + }, + shouldNotifyVoters: { + type: "boolean", + label: "Should Notify Voters", + description: "Whether or not to notify non-admin voters of the status change", + }, + status: { + type: "string", + label: "Status", + description: "The new status of the post", + options: [ + "open", + "under review", + "planned", + "in progress", + "complete", + "closed", + ], + }, + }, + async run({ $ }) { + const response = await this.canny.updatePostStatus({ + $, + data: { + postID: this.postId, + changerID: this.changerId, + shouldNotifyVoters: this.shouldNotifyVoters, + status: this.status, + }, + }); + $.export("$summary", `Successfully changed the status of post ${this.postId} to ${this.status}`); + return response; + }, +}; diff --git a/components/canny/actions/create-comment/create-comment.mjs b/components/canny/actions/create-comment/create-comment.mjs new file mode 100644 index 0000000000000..08953327e921c --- /dev/null +++ b/components/canny/actions/create-comment/create-comment.mjs @@ -0,0 +1,79 @@ +import canny from "../../canny.app.mjs"; +import { ConfigurationError } from "@pipedream/platform"; + +export default { + key: "canny-create-comment", + name: "Create Comment", + description: "Create a comment. [See the documentation](https://developers.canny.io/api-reference#create_comment)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + canny, + authorId: { + propDefinition: [ + canny, + "userId", + ], + label: "Author ID", + description: "The ID of the author of the comment", + }, + postId: { + propDefinition: [ + canny, + "postId", + ], + description: "The ID of the post to comment on", + }, + value: { + type: "string", + label: "Value", + description: "The comment value. Optional if imageURLs are provided. Must be under 2500 characters.", + optional: true, + }, + imageUrls: { + type: "string[]", + label: "Image URLs", + description: "An array of the URLs of comment's images", + optional: true, + }, + parentId: { + propDefinition: [ + canny, + "commentId", + ], + label: "Parent ID", + description: "The ID of the parent comment", + optional: true, + }, + shouldNotifyVoters: { + type: "boolean", + label: "Should Notify Voters", + description: "Whether this comment should be allowed to trigger email notifications. Default is false.", + optional: true, + }, + }, + async run({ $ }) { + if (!this.value && !this.imageUrls) { + throw new ConfigurationError("Either value or imageUrls must be provided"); + } + + const response = await this.canny.createComment({ + $, + data: { + authorID: this.authorId, + postID: this.postId, + value: this.value, + imageURLs: this.imageUrls, + parentID: this.parentId, + shouldNotifyVoters: this.shouldNotifyVoters, + }, + }); + $.export("$summary", `Successfully created comment with ID ${response.id}`); + return response; + }, +}; diff --git a/components/canny/actions/create-post/create-post.mjs b/components/canny/actions/create-post/create-post.mjs new file mode 100644 index 0000000000000..8dd3fee8a0b41 --- /dev/null +++ b/components/canny/actions/create-post/create-post.mjs @@ -0,0 +1,101 @@ +import canny from "../../canny.app.mjs"; +import { parseObject } from "../../common/utils.mjs"; + +export default { + key: "canny-create-post", + name: "Create Post", + description: "Create a post. [See the documentation](https://developers.canny.io/api-reference#create_post)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + canny, + authorId: { + propDefinition: [ + canny, + "userId", + ], + label: "Author ID", + description: "The ID of the author of the post", + }, + boardId: { + propDefinition: [ + canny, + "boardId", + ], + }, + title: { + type: "string", + label: "Title", + description: "The title of the post", + }, + details: { + type: "string", + label: "Details", + description: "The details of the post", + }, + categoryId: { + propDefinition: [ + canny, + "categoryId", + ], + optional: true, + }, + eta: { + type: "string", + label: "ETA", + description: "The estimated date of the post's completion. In the format of MM/YYYY, eg, 06/2022.", + optional: true, + }, + etaPublic: { + type: "boolean", + label: "ETA Public", + description: "If the ETA should be made visible to all users", + optional: true, + }, + ownerId: { + propDefinition: [ + canny, + "userId", + ], + label: "Owner ID", + description: "The ID of the user responsible for the completion of the work described in the post", + optional: true, + }, + imageUrls: { + type: "string[]", + label: "Image URLs", + description: "An array of the URLs of post's images", + optional: true, + }, + customFields: { + type: "object", + label: "Custom Fields", + description: "Any custom fields associated with the post. Each field name (key) must be between 0 and 30 characters long. If field values are strings, they must be less than 200 characters long.", + optional: true, + }, + }, + async run({ $ }) { + const response = await this.canny.createPost({ + $, + data: { + authorID: this.authorId, + boardID: this.boardId, + title: this.title, + categoryID: this.categoryId, + details: this.details, + eta: this.eta, + etaPublic: this.etaPublic, + ownerID: this.ownerId, + imageURLs: this.imageUrls, + customFields: parseObject(this.customFields), + }, + }); + $.export("$summary", `Successfully created a post with ID ${response.id}`); + return response; + }, +}; diff --git a/components/canny/actions/get-post/get-post.mjs b/components/canny/actions/get-post/get-post.mjs new file mode 100644 index 0000000000000..485af8ee4baa6 --- /dev/null +++ b/components/canny/actions/get-post/get-post.mjs @@ -0,0 +1,52 @@ +import canny from "../../canny.app.mjs"; + +export default { + key: "canny-get-post", + name: "Get Post", + description: "Get a post. [See the documentation](https://developers.canny.io/api-reference#retrieve_post)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + props: { + canny, + boardId: { + propDefinition: [ + canny, + "boardId", + ], + optional: true, + }, + postId: { + propDefinition: [ + canny, + "postId", + (c) => ({ + boardId: c.boardId, + }), + ], + optional: true, + }, + urlName: { + type: "string", + label: "URL Name", + description: "The post's unique urlName", + optional: true, + }, + }, + async run({ $ }) { + const response = await this.canny.getPost({ + $, + data: { + boardID: this.boardId, + urlName: this.urlName, + id: this.postId, + }, + }); + $.export("$summary", `Successfully retrieved post ${this.postId}`); + return response; + }, +}; diff --git a/components/canny/canny.app.mjs b/components/canny/canny.app.mjs index 67c367ab35886..041ec3c70eb8c 100644 --- a/components/canny/canny.app.mjs +++ b/components/canny/canny.app.mjs @@ -1,11 +1,255 @@ +import { axios } from "@pipedream/platform"; +const DEFAULT_PAGE_SIZE = 50; + export default { type: "app", app: "canny", - propDefinitions: {}, + propDefinitions: { + boardId: { + type: "string", + label: "Board ID", + description: "The ID of a board", + async options() { + const { boards } = await this.listBoards(); + return boards?.map(({ + id: value, name: label, + }) => ({ + label, + value, + })) || []; + }, + }, + postId: { + type: "string", + label: "Post ID", + description: "The ID of a post", + async options({ + page, boardId, + }) { + const { posts } = await this.listPosts({ + data: { + limit: DEFAULT_PAGE_SIZE, + skip: page * DEFAULT_PAGE_SIZE, + boardID: boardId, + }, + }); + return posts?.map(({ + id: value, title: label, + }) => ({ + label, + value, + })) || []; + }, + }, + userId: { + type: "string", + label: "User ID", + description: "The ID of a user", + async options() { + const users = await this.listUsers(); + return users?.map(({ + id: value, name: label, + }) => ({ + label, + value, + })) || []; + }, + }, + companyId: { + type: "string", + label: "Company ID", + description: "The ID of a company", + async options({ page }) { + const { companies } = await this.listCompanies({ + data: { + limit: DEFAULT_PAGE_SIZE, + skip: page * DEFAULT_PAGE_SIZE, + }, + }); + return companies?.map(({ + id: value, name: label, + }) => ({ + label, + value, + })) || []; + }, + }, + tagId: { + type: "string", + label: "Tag ID", + description: "The ID of a tag", + async options({ page }) { + const { tags } = await this.listTags({ + data: { + limit: DEFAULT_PAGE_SIZE, + skip: page * DEFAULT_PAGE_SIZE, + }, + }); + return tags?.map(({ + id: value, name: label, + }) => ({ + label, + value, + })) || []; + }, + }, + categoryId: { + type: "string", + label: "Category ID", + description: "The ID of a category", + async options({ page }) { + const { categories } = await this.listCategories({ + data: { + limit: DEFAULT_PAGE_SIZE, + skip: page * DEFAULT_PAGE_SIZE, + }, + }); + return categories?.map(({ + id: value, name: label, + }) => ({ + label, + value, + })) || []; + }, + }, + commentId: { + type: "string", + label: "Comment ID", + description: "The ID of a comment", + async options({ page }) { + const { comments } = await this.listComments({ + data: { + limit: DEFAULT_PAGE_SIZE, + skip: page * DEFAULT_PAGE_SIZE, + }, + }); + return comments?.map(({ + id: value, value: label, + }) => ({ + label, + value, + })) || []; + }, + }, + }, methods: { - // this.$auth contains connected account data - authKeys() { - console.log(Object.keys(this.$auth)); + _baseUrl() { + return "https://canny.io/api/v1"; + }, + _makeRequest({ + $ = this, path, data, ...opts + }) { + return axios($, { + ...opts, + method: "POST", + url: `${this._baseUrl()}${path}`, + data: { + ...data, + apiKey: `${this.$auth.api_key}`, + }, + }); + }, + getPost(opts = {}) { + return this._makeRequest({ + path: "/posts/retrieve", + ...opts, + }); + }, + listBoards(opts = {}) { + return this._makeRequest({ + path: "/boards/list", + ...opts, + }); + }, + listPosts(opts = {}) { + return this._makeRequest({ + path: "/posts/list", + ...opts, + }); + }, + listComments(opts = {}) { + return this._makeRequest({ + path: "/comments/list", + ...opts, + }); + }, + listVotes(opts = {}) { + return this._makeRequest({ + path: "/votes/list", + ...opts, + }); + }, + listStatusChanges(opts = {}) { + return this._makeRequest({ + path: "/status_changes/list", + ...opts, + }); + }, + listUsers(opts = {}) { + return this._makeRequest({ + path: "/users/list", + ...opts, + }); + }, + listCompanies(opts = {}) { + return this._makeRequest({ + path: "/companies/list", + ...opts, + }); + }, + listTags(opts = {}) { + return this._makeRequest({ + path: "/tags/list", + ...opts, + }); + }, + listCategories(opts = {}) { + return this._makeRequest({ + path: "/categories/list", + ...opts, + }); + }, + createPost(opts = {}) { + return this._makeRequest({ + path: "/posts/create", + ...opts, + }); + }, + createComment(opts = {}) { + return this._makeRequest({ + path: "/comments/create", + ...opts, + }); + }, + updatePostStatus(opts = {}) { + return this._makeRequest({ + path: "/posts/change_status", + ...opts, + }); + }, + async *paginate({ + fn, data, resourceKey, max, + }) { + data = { + ...data, + limit: 100, + skip: 0, + }; + let hasMore, count = 0; + do { + const result = await fn({ + data, + }); + const items = result[resourceKey]; + for (const item of items) { + yield item; + if (max && ++count >= max) { + return; + } + } + hasMore = result.hasMore; + data.skip += data.limit; + } while (hasMore); }, }, }; diff --git a/components/canny/common/utils.mjs b/components/canny/common/utils.mjs new file mode 100644 index 0000000000000..f21200ff0563d --- /dev/null +++ b/components/canny/common/utils.mjs @@ -0,0 +1,27 @@ +export const parseObject = (obj) => { + if (!obj) return undefined; + + if (typeof obj === "string") { + try { + return JSON.parse(obj); + } catch (e) { + return obj; + } + } + + if (Array.isArray(obj)) { + return obj.map(parseObject); + } + + if (typeof obj === "object" && obj !== null) { + return Object.fromEntries(Object.entries(obj).map(([ + key, + value, + ]) => [ + key, + parseObject(value), + ])); + } + + return obj; +}; diff --git a/components/canny/package.json b/components/canny/package.json index 005f8915c158c..49589ef5a1466 100644 --- a/components/canny/package.json +++ b/components/canny/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/canny", - "version": "0.6.0", + "version": "0.7.0", "description": "Pipedream canny Components", "main": "canny.app.mjs", "keywords": [ diff --git a/components/canny/sources/common/base-polling.mjs b/components/canny/sources/common/base-polling.mjs new file mode 100644 index 0000000000000..792d6b48f1af2 --- /dev/null +++ b/components/canny/sources/common/base-polling.mjs @@ -0,0 +1,82 @@ +import canny from "../../canny.app.mjs"; +import { + DEFAULT_POLLING_SOURCE_TIMER_INTERVAL, ConfigurationError, +} from "@pipedream/platform"; + +export default { + props: { + canny, + db: "$.service.db", + timer: { + type: "$.interface.timer", + default: { + intervalSeconds: DEFAULT_POLLING_SOURCE_TIMER_INTERVAL, + }, + }, + }, + methods: { + _getLastTs() { + return this.db.get("lastTs") || 0; + }, + _setLastTs(lastTs) { + this.db.set("lastTs", lastTs); + }, + getData() { + return {}; + }, + getTsField() { + return "created"; + }, + async processEvents(max) { + const lastTs = this._getLastTs(); + const fn = this.getResourceFn(); + const data = this.getData(); + const resourceKey = this.getResourceKey(); + + const results = this.canny.paginate({ + fn, + data, + resourceKey, + max, + }); + + const items = []; + for await (const item of results) { + const ts = Date.parse(item[this.getTsField()]); + if (ts >= lastTs) { + items.push(item); + } else { + break; + } + } + + if (!items.length) { + return; + } + + this._setLastTs(Date.parse(items[0][this.getTsField()])); + + items.forEach((item) => { + const meta = this.generateMeta(item); + this.$emit(item, meta); + }); + }, + getResourceFn() { + throw new ConfigurationError("getResourceFn must be implemented"); + }, + getResourceKey() { + throw new ConfigurationError("getResourceKey must be implemented"); + }, + generateMeta() { + throw new ConfigurationError("generateMeta must be implemented"); + }, + }, + hooks: { + async deploy() { + await this.processEvents(10); + }, + }, + async run() { + await this.processEvents(); + }, +}; diff --git a/components/canny/sources/new-comment-created/new-comment-created.mjs b/components/canny/sources/new-comment-created/new-comment-created.mjs new file mode 100644 index 0000000000000..f7e1e198e0ebb --- /dev/null +++ b/components/canny/sources/new-comment-created/new-comment-created.mjs @@ -0,0 +1,73 @@ +import common from "../common/base-polling.mjs"; + +export default { + ...common, + key: "canny-new-comment-created", + name: "New Comment Created", + description: "Emit new event when a new comment is created. [See the documentation](https://developers.canny.io/api-reference#list_comments)", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + ...common.props, + boardId: { + propDefinition: [ + common.props.canny, + "boardId", + ], + description: "The ID of the board to watch for new comments", + optional: true, + }, + authorId: { + propDefinition: [ + common.props.canny, + "userId", + ], + description: "Filter comments by author ID", + optional: true, + }, + companyId: { + propDefinition: [ + common.props.canny, + "companyId", + ], + description: "Filter comments by company ID", + optional: true, + }, + postId: { + propDefinition: [ + common.props.canny, + "postId", + (c) => ({ + boardId: c.boardId, + }), + ], + description: "Filter comments by post ID", + optional: true, + }, + }, + methods: { + ...common.methods, + getResourceFn() { + return this.canny.listComments; + }, + getData() { + return { + boardID: this.boardId, + authorID: this.authorId, + companyID: this.companyId, + postID: this.postId, + }; + }, + getResourceKey() { + return "comments"; + }, + generateMeta(comment) { + return { + id: comment.id, + summary: `New Comment: ${comment.value}`, + ts: Date.parse(comment[this.getTsField()]), + }; + }, + }, +}; diff --git a/components/canny/sources/new-vote-created/new-vote-created.mjs b/components/canny/sources/new-vote-created/new-vote-created.mjs new file mode 100644 index 0000000000000..0995a4b26450c --- /dev/null +++ b/components/canny/sources/new-vote-created/new-vote-created.mjs @@ -0,0 +1,73 @@ +import common from "../common/base-polling.mjs"; + +export default { + ...common, + key: "canny-new-vote-created", + name: "New Vote Created", + description: "Emit new event when a new vote is created. [See the documentation](https://developers.canny.io/api-reference#list_votes)", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + ...common.props, + boardId: { + propDefinition: [ + common.props.canny, + "boardId", + ], + description: "The ID of the board to watch for new votes", + optional: true, + }, + companyId: { + propDefinition: [ + common.props.canny, + "companyId", + ], + description: "Filter votes by company ID", + optional: true, + }, + postId: { + propDefinition: [ + common.props.canny, + "postId", + (c) => ({ + boardId: c.boardId, + }), + ], + description: "Filter votes by post ID", + optional: true, + }, + userId: { + propDefinition: [ + common.props.canny, + "userId", + ], + description: "Filter votes by user ID", + optional: true, + }, + }, + methods: { + ...common.methods, + getResourceFn() { + return this.canny.listVotes; + }, + getData() { + return { + boardID: this.boardId, + companyID: this.companyId, + postID: this.postId, + userID: this.userId, + }; + }, + getResourceKey() { + return "votes"; + }, + generateMeta(vote) { + return { + id: vote.id, + summary: `New Vote with ID ${vote.id}`, + ts: Date.parse(vote[this.getTsField()]), + }; + }, + }, +}; diff --git a/components/canny/sources/post-status-changed/post-status-changed.mjs b/components/canny/sources/post-status-changed/post-status-changed.mjs new file mode 100644 index 0000000000000..f1fac4e0f06d0 --- /dev/null +++ b/components/canny/sources/post-status-changed/post-status-changed.mjs @@ -0,0 +1,43 @@ +import common from "../common/base-polling.mjs"; + +export default { + ...common, + key: "canny-post-status-changed", + name: "Post Status Changed", + description: "Emit new event when the status of a post is changed. [See the documentation](https://developers.canny.io/api-reference#list_status_changes)", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + ...common.props, + boardId: { + propDefinition: [ + common.props.canny, + "boardId", + ], + description: "The ID of the board to watch for status changes", + optional: true, + }, + }, + methods: { + ...common.methods, + getResourceFn() { + return this.canny.listStatusChanges; + }, + getData() { + return { + boardID: this.boardId, + }; + }, + getResourceKey() { + return "statusChanges"; + }, + generateMeta(statusChange) { + return { + id: statusChange.id, + summary: `Post Status Changed: ${statusChange.status}`, + ts: Date.parse(statusChange[this.getTsField()]), + }; + }, + }, +};