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: API 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?page=1&per_page=20" \
-H "Authorization: Bearer YOUR_TOKEN"

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" \
-H "Authorization: Bearer YOUR_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`,
{ headers: { Authorization: `Bearer ${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}`,
{ headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } }
).then(r => r.json());

// Get comments
const comments = await fetch(
`https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments`,
{ headers: { Authorization: `Bearer ${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`,
{ headers: { Authorization: `Bearer ${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`,
{ headers: { Authorization: `Bearer ${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`,
{ headers: { Authorization: `Bearer ${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?` +
`page=${page}&per_page=50`,
{ headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } }
);

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({
page: options.page || 1,
per_page: options.perPage || 20
});

const response = await fetch(
`${this.baseUrl}/${this.orgSlug}/assets/${assetToken}/comments?${params}`,
{ headers: { Authorization: `Bearer ${this.accessToken}` } }
);

return await response.json();
}

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

const response = await fetch(
`${this.baseUrl}/${this.orgSlug}/boards/${boardToken}/comments?${params}`,
{ headers: { Authorization: `Bearer ${this.accessToken}` } }
);

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({
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​