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`);
}
}
Related API Endpoints
Next Steps
- Learn about Sharing and Publishing to share assets with external reviewers
- Explore Webhooks to get notified of new comments
- Read about Asset Management for organizing commented assets