Skip to main content

Comments and Collaboration

Learn how to work with comments on assets and boards for team collaboration and feedback.

Overview

Comments enable team collaboration directly on assets and boards. They support:

  • Threaded discussions
  • @mentions (via UI)
  • Comment resolution
  • Timestamps and authorship

Prerequisites

  • Access Token: OAuth2 token with read permissions
  • Organization Slug: Your organization identifier
  • Asset/Board Tokens: IDs of content to comment on

Viewing Asset Comments

Get all comments for a specific asset:

curl "https://api.playbook.com/v1/my-org/assets/product-photo/comments?access_token=TOKEN&page=1&per_page=20"

Response:

{
"data": [
{
"token": "comment-abc123",
"description": "Love the lighting in this shot!",
"user_token": "user-xyz789",
"created_at": "2024-10-09T14:30:00Z",
"updated_at": "2024-10-09T14:30:00Z",
"resolved_at": null,
"replies": [
{
"token": "comment-def456",
"description": "Thanks! Used a softbox for the effect",
"user_token": "user-abc123",
"created_at": "2024-10-09T15:00:00Z",
"updated_at": "2024-10-09T15:00:00Z",
"resolved_at": null,
"replies": []
}
]
}
],
"pagy": {
"current_page": 1,
"page_items": 1,
"total_pages": 1,
"total_count": 1
}
}

Viewing Board Comments

Get all comments for a board:

curl "https://api.playbook.com/v1/my-org/boards/main-collection/comments?access_token=TOKEN"

Response structure is similar to asset comments.


Working with Comment Threads

Understanding Replies

Comments can have nested replies:

Comment (parent)
└── Reply 1 (child)
└── Reply to Reply 1 (grandchild)
└── Reply 2 (child)

Accessing Replies

async function getAllCommentsFlat(assetToken) {
const response = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments?access_token=${ACCESS_TOKEN}`
);

const { data } = await response.json();

// Flatten replies
const allComments = [];

function flatten(comments, depth = 0) {
for (const comment of comments) {
allComments.push({ ...comment, depth });
if (comment.replies?.length > 0) {
flatten(comment.replies, depth + 1);
}
}
}

flatten(data);
return allComments;
}

Use Cases

1. Review and Approval Workflow

Display comments in a review interface:

class ReviewInterface {
async loadAssetForReview(assetToken) {
// Get asset details
const asset = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}?access_token=${ACCESS_TOKEN}`
).then(r => r.json());

// Get comments
const comments = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments?access_token=${ACCESS_TOKEN}`
).then(r => r.json());

return {
asset: asset.data,
comments: comments.data,
status: asset.data.fields?.['Approval Status'],
needsResolution: comments.data.some(c => !c.resolved_at)
};
}

async canApprove(assetToken) {
const review = await this.loadAssetForReview(assetToken);

// Check all comments are resolved
return !review.needsResolution;
}
}

2. Comment Notifications

Notify users when comments are added:

async function checkNewComments(assetToken, lastCheck) {
const response = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments?access_token=${ACCESS_TOKEN}`
);

const { data } = await response.json();

// Find comments since last check
const newComments = [];

function findNew(comments) {
for (const comment of comments) {
const commentDate = new Date(comment.created_at);
if (commentDate > lastCheck) {
newComments.push(comment);
}
if (comment.replies) {
findNew(comment.replies);
}
}
}

findNew(data);
return newComments;
}

// Usage
const lastChecked = new Date('2024-10-09T12:00:00Z');
const newComments = await checkNewComments('product-photo', lastChecked);

if (newComments.length > 0) {
console.log(`${newComments.length} new comments`);
// Send notification
}

3. Comment Analytics

Track comment activity:

class CommentAnalytics {
async getCommentStats(assetToken) {
const response = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments?access_token=${ACCESS_TOKEN}`
);

const { data } = await response.json();

const stats = {
totalComments: 0,
resolvedComments: 0,
unresolvedComments: 0,
totalReplies: 0,
uniqueUsers: new Set(),
commentsByUser: {},
averageResponseTime: 0
};

function analyze(comments, isReply = false) {
for (const comment of comments) {
if (isReply) {
stats.totalReplies++;
} else {
stats.totalComments++;
}

if (comment.resolved_at) {
stats.resolvedComments++;
} else {
stats.unresolvedComments++;
}

stats.uniqueUsers.add(comment.user_token);

stats.commentsByUser[comment.user_token] =
(stats.commentsByUser[comment.user_token] || 0) + 1;

if (comment.replies) {
analyze(comment.replies, true);
}
}
}

analyze(data);

stats.uniqueUsers = stats.uniqueUsers.size;

return stats;
}

async getMostActiveAssets(assetTokens) {
const results = await Promise.all(
assetTokens.map(async token => ({
token,
stats: await this.getCommentStats(token)
}))
);

return results.sort((a, b) =>
b.stats.totalComments - a.stats.totalComments
);
}
}

4. Export Comments

Export comments for reporting or archival:

async function exportCommentsToCSV(assetToken) {
const response = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments?access_token=${ACCESS_TOKEN}`
);

const { data } = await response.json();

const rows = [
['Comment ID', 'User', 'Text', 'Created', 'Resolved', 'Depth']
];

function addRows(comments, depth = 0) {
for (const comment of comments) {
rows.push([
comment.token,
comment.user_token,
comment.description,
comment.created_at,
comment.resolved_at || 'No',
depth
]);

if (comment.replies) {
addRows(comment.replies, depth + 1);
}
}
}

addRows(data);

return rows.map(row => row.join(',')).join('\n');
}

// Usage
const csv = await exportCommentsToCSV('product-photo');
console.log(csv);

Pagination

Handle large comment threads with pagination:

async function getAllComments(assetToken) {
let allComments = [];
let page = 1;
let hasMore = true;

while (hasMore) {
const response = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments?` +
`access_token=${ACCESS_TOKEN}&page=${page}&per_page=50`
);

const { data, pagy } = await response.json();

allComments = allComments.concat(data);

hasMore = page < pagy.total_pages;
page++;
}

return allComments;
}

Comment Resolution Tracking

Track which comments have been resolved:

class CommentResolutionTracker {
async getUnresolvedComments(assetToken) {
const comments = await getAllComments(assetToken);

const unresolved = [];

function findUnresolved(commentList) {
for (const comment of commentList) {
if (!comment.resolved_at) {
unresolved.push(comment);
}
if (comment.replies) {
findUnresolved(comment.replies);
}
}
}

findUnresolved(comments);
return unresolved;
}

async generateResolutionReport(assetTokens) {
const report = {};

for (const token of assetTokens) {
const unresolved = await this.getUnresolvedComments(token);
report[token] = {
unresolvedCount: unresolved.length,
comments: unresolved
};
}

return report;
}

async assetsNeedingAttention(assetTokens, threshold = 5) {
const report = await this.generateResolutionReport(assetTokens);

return Object.entries(report)
.filter(([_, data]) => data.unresolvedCount >= threshold)
.map(([token, data]) => ({ token, ...data }));
}
}

// Usage
const tracker = new CommentResolutionTracker();

const needsAttention = await tracker.assetsNeedingAttention(
['asset-1', 'asset-2', 'asset-3'],
3 // Alert if 3+ unresolved comments
);

console.log('Assets needing attention:', needsAttention);

Complete Comment Management System

Here's a full implementation:

class CommentManager {
constructor(orgSlug, accessToken) {
this.orgSlug = orgSlug;
this.accessToken = accessToken;
this.baseUrl = 'https://api.playbook.com/v1';
}

async getAssetComments(assetToken, options = {}) {
const params = new URLSearchParams({
access_token: this.accessToken,
page: options.page || 1,
per_page: options.perPage || 20
});

const response = await fetch(
`${this.baseUrl}/${this.orgSlug}/assets/${assetToken}/comments?${params}`
);

return await response.json();
}

async getBoardComments(boardToken, options = {}) {
const params = new URLSearchParams({
access_token: this.accessToken,
page: options.page || 1,
per_page: options.perPage || 20
});

const response = await fetch(
`${this.baseUrl}/${this.orgSlug}/boards/${boardToken}/comments?${params}`
);

return await response.json();
}

async getAllCommentsFlat(resourceType, token) {
const getComments = resourceType === 'asset'
? this.getAssetComments.bind(this)
: this.getBoardComments.bind(this);

let allComments = [];
let page = 1;

while (true) {
const { data, pagy } = await getComments(token, { page });

// Flatten comments and replies
const flatComments = this.flattenComments(data);
allComments = allComments.concat(flatComments);

if (page >= pagy.total_pages) break;
page++;
}

return allComments;
}

flattenComments(comments, depth = 0, parentToken = null) {
const result = [];

for (const comment of comments) {
result.push({
...comment,
depth,
parentToken,
hasReplies: comment.replies && comment.replies.length > 0
});

if (comment.replies) {
const replies = this.flattenComments(
comment.replies,
depth + 1,
comment.token
);
result.push(...replies);
}
}

return result;
}

async getCommentStatistics(resourceType, token) {
const comments = await this.getAllCommentsFlat(resourceType, token);

const stats = {
total: comments.length,
topLevel: comments.filter(c => c.depth === 0).length,
replies: comments.filter(c => c.depth > 0).length,
resolved: comments.filter(c => c.resolved_at).length,
unresolved: comments.filter(c => !c.resolved_at).length,
users: [...new Set(comments.map(c => c.user_token))].length,
avgDepth: comments.reduce((sum, c) => sum + c.depth, 0) / comments.length || 0,
oldestUnresolved: this.findOldestUnresolved(comments)
};

return stats;
}

findOldestUnresolved(comments) {
const unresolved = comments.filter(c => !c.resolved_at);

if (unresolved.length === 0) return null;

return unresolved.reduce((oldest, current) =>
new Date(current.created_at) < new Date(oldest.created_at)
? current
: oldest
);
}

async generateReport(resourceType, tokens) {
const report = [];

for (const token of tokens) {
const stats = await this.getCommentStatistics(resourceType, token);
report.push({ token, ...stats });
}

return report;
}
}

// Usage
const manager = new CommentManager('my-org', 'your_token');

// Get all comments for an asset
const comments = await manager.getAllCommentsFlat('asset', 'product-photo');
console.log(`Found ${comments.length} total comments`);

// Get statistics
const stats = await manager.getCommentStatistics('asset', 'product-photo');
console.log('Comment stats:', stats);

// Generate report for multiple assets
const report = await manager.generateReport('asset', [
'product-photo',
'banner-image',
'logo-design'
]);
console.log('Report:', report);

Best Practices

1. Monitor Unresolved Comments

Set up alerts for assets with many unresolved comments:

async function checkUnresolvedAlerts() {
const assets = await getAssetsInReview();

for (const asset of assets) {
const stats = await manager.getCommentStatistics('asset', asset.token);

if (stats.unresolved > 5) {
await sendAlert({
to: 'team@company.com',
subject: `Action needed: ${asset.title}`,
body: `This asset has ${stats.unresolved} unresolved comments.`
});
}
}
}

2. Track Response Times

Measure how quickly teams respond to comments:

function calculateResponseTime(comments) {
const topLevel = comments.filter(c => c.depth === 0);

const responseTimes = topLevel
.filter(c => c.hasReplies)
.map(c => {
const firstReply = c.replies[0];
const parentTime = new Date(c.created_at);
const replyTime = new Date(firstReply.created_at);
return replyTime - parentTime;
});

const avgResponseTime = responseTimes.reduce((sum, t) => sum + t, 0) / responseTimes.length;

return {
average: avgResponseTime / 1000 / 60, // minutes
count: responseTimes.length
};
}

3. Archive Old Threads

Export resolved comment threads for archival:

async function archiveResolvedComments(assetToken, olderThan) {
const comments = await manager.getAllCommentsFlat('asset', assetToken);

const toArchive = comments.filter(c =>
c.resolved_at && new Date(c.resolved_at) < olderThan
);

if (toArchive.length > 0) {
await saveToArchive(assetToken, toArchive);
console.log(`Archived ${toArchive.length} comments`);
}
}


Next Steps