Canny migration

Importing data from Canny to Cycle has never been easier! This guide will walk you through using the Cycle API to seamlessly transfer your valuable customer feedback from Canny into your Cycle inbox alongside your features from your Canny roadmap.

For more details and to access the script, check out the code repository. 🚀

What this script does

  • Fetches each Canny post from all your boards.

  • Retrieves votes and comments for each post.

  • Creates a document with the desired type (from config) for each post in Cycle.

  • Links feedback to each vote for the created document.

  • Tags all imported documents with an imported property, allowing you to filter or bulk delete these documents if needed.

How to use the script

Configure Your Data:

Fill your data in ./config.ts.

Run the Import Script:

Execute the command npm run import:canny.

Code overview

Let’s try to understand how the script works and why it’s done that way:

Cycle core logic

From your Cycle workspace, you get a unique slug, the script will proceed as follows

  1. Retrieve the “productId” from the slug (”product” is the old name for “workspace”)

  2. Create the import attribute (”attribute” is the tech name for “properties”) in order to let you filter on imported docs in views

  3. Link the attributes to doc type in order to assign the attribute value to imported docs

  4. Fetch Canny data and create docs out of it

Fetching Canny

The script defines a fetchCanny function to retrieve data from Canny's API. It handles pagination to ensure all posts, comments, and votes are fetched.

const fetchCanny = async <T>(
  endpoint: string,
  body = {},
  pagination = { skip: 0, limit: 10 }
): Promise<T> => {
  const requestOptions = {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      apiKey: cannyConfig.cannySecretKey,
      skip: pagination.skip,
      limit: pagination.limit,
      ...body,
    }),
  };

  const response = await fetch(`https://canny.io/api/v1/${endpoint}`, requestOptions);
  if (!response.ok) throw Error('Failed to fetch');
  const json = await response.json();
  return json;
};

Retrieving boards and posts

Let’s start by retrieving all your boards ans keep the one with posts only:

const retrieveBoards = async () => {
  const response = await fetchCanny<{ boards: Board[] }>('boards/list');
  return response.boards;
};

const filterBoardWithPosts = (boards: Board[]) => {
  return boards.filter((b) => b.postCount >= 1);
};

Fetching comments and votes

For each post, the script fetches related comments and votes:

const fetchAllComments = async (postID: string, skip: number = 0, accumulatedComments: Comment[] = []): Promise<Comment[]> => {
  const { hasMore, comments } = await fetchCanny<{ hasMore: boolean; comments: Comment[] }>('comments/list', { postID }, { limit: 100, skip });
  accumulatedComments.push(...comments);

  if (hasMore) {
    return fetchAllComments(postID, skip + comments.length, accumulatedComments);
  } else {
    return accumulatedComments;
  }
};

const fetchAllVotes = async (postID: string, skip: number = 0, accumulatedVotes: Vote[] = []): Promise<Vote[]> => {
  const { hasMore, votes } = await fetchCanny<{ hasMore: boolean; votes: Vote[] }>('votes/list', { postID }, { limit: 100, skip });
  accumulatedVotes.push(...votes);

  if (hasMore) {
    return fetchAllVotes(postID, skip + votes.length, accumulatedVotes);
  } else {
    return accumulatedVotes;
  }
};

Creating documents in Cycle

The script creates documents in Cycle for each post, along with linked feedback for each vote:

const createDocAndFeedback = async (workspaceId: string, post: Post, comments: Comment[], votes: Vote[], importAttributeData: any, docTypeToImport: any, insight: any) => {
  const createdDoc = await createDoc({
    workspaceId,
    attributes: [importAttributeData],
    contentJSON: getJSONDocContent({ post, comments: getFormattedComments(comments) }),
    doctypeId: docTypeToImport.id,
    title: post.title,
  });

  if (createdDoc) {
    for (const vote of votes) {
      const voteFeedback = await createFeedback({
        workspaceId,
        attributes: [importAttributeData],
        content: `Vote for ${post.title}`,
        sourceUrl: post.url,
        title: `Vote from ${vote.voter?.name || 'unknown'} on ${post.title}`,
        customerEmail: vote.voter.email,
      });
      await createDoc({
        doctypeId: insight.id,
        workspaceId,
        attributes: [importAttributeData],
        contentJSON: '',
        title: `Vote from ${vote.voter?.name || 'unknown'} on ${post.title}`,
        customerId: voteFeedback.customer.id,
        docSourceId: voteFeedback.id,
        parentId: createdDoc.id,
      });
    }
  }
};

As you can see, the way to have feedback connected to features in Cycle is using Insight concept which is a certain type of doc plus a link between feedback and feature. The link between a feedback and an insight is made via a doc containing a “docSourceId”. Then to link the insight to the feature, use a the “parentId” field.

So you always need to have the parent before the child in any relation import in Cycle.

The right order to proceed is then:

  1. Create the feature (Well the name is up to you since it’s fully customisable)

  2. Create the feedback

  3. Create the link between both (insight)

Hope this will help you to build crazy integrations with our API.

For more details and to access the script, check out the code repository. 🚀

Last updated