ReviewOS

also looking at this

stacks/postline

Sync Bluesky engagement metrics into analytics

#12
Merged glennmichael123 wants to merge feat/metrics-sync into main
10 files +200 -4
app/Actions/Postline/AnalyticsAction.tsmodified+19-3
Changes to app/Actions/Postline/AnalyticsAction.ts
@@ -34,7 +34,7 @@ export default new Action({
3434
3535 const targets = await database
3636 .selectFrom('post_targets')
37 .select(['provider', 'status', 'updated_at'])
37 .select(['provider', 'status', 'created_at', 'metrics'])
3838 .where('status', '=', 'published')
3939 .execute()
4040
@@ -77,10 +77,11 @@ export default new Action({
7777 share: totalTargets ? Math.round((count / totalTargets) * 100) : 0,
7878 }))
7979
80 // Publish-hour histogram (UTC) → best posting windows.
80 // Publish-hour histogram (UTC) → best posting windows. Uses
81 // created_at: metric syncs bump updated_at, which would skew it.
8182 const byHour = new Map<number, number>()
8283 for (const target of targets) {
83 const date = parseUtc(target.updated_at)
84 const date = parseUtc(target.created_at)
8485 if (date) byHour.set(date.getUTCHours(), (byHour.get(date.getUTCHours()) || 0) + 1)
8586 }
8687 const windows = [...byHour.entries()]
@@ -88,6 +89,20 @@ export default new Action({
8889 .slice(0, 3)
8990 .map(([hour, count]) => ({ hour, count }))
9091
92 // Engagement totals from synced post_targets.metrics.
93 const engagement = { likes: 0, reposts: 0, replies: 0, syncedTargets: 0 }
94 for (const target of targets) {
95 if (!target.metrics) continue
96 try {
97 const metrics = JSON.parse(String(target.metrics))
98 engagement.likes += Number(metrics.likes) || 0
99 engagement.reposts += Number(metrics.reposts) || 0
100 engagement.replies += Number(metrics.replies) || 0
101 engagement.syncedTargets += 1
102 }
103 catch {}
104 }
105
91106 return response.json({
92107 ok: true,
93108 data: {
@@ -96,6 +111,7 @@ export default new Action({
96111 postsLastWeek,
97112 bestChannel: channelMix[0] || null,
98113 bestHour: windows[0] || null,
114 engagement,
99115 },
100116 series,
101117 channelMix,
app/Actions/Postline/MetricsSyncAction.tsadded+22-0
Changes to app/Actions/Postline/MetricsSyncAction.ts
@@ -0,0 +1,22 @@
1import { Action } from '@stacksjs/actions'
2import { response } from '@stacksjs/router'
3import { bluesky } from '../../Services/Social/BlueskyService'
4
5export default new Action({
6 name: 'Postline Metrics Sync',
7 description: 'Refresh engagement metrics for published posts on demand.',
8 method: 'POST',
9
10 async handle() {
11 try {
12 const data = await bluesky.syncMetrics()
13 return response.json({ ok: true, data })
14 }
15 catch (error) {
16 return response.json({
17 ok: false,
18 error: error instanceof Error ? error.message : String(error),
19 }, { status: 422 })
20 }
21 },
22})
app/Jobs/SyncEngagementMetrics.tsadded+24-0
Changes to app/Jobs/SyncEngagementMetrics.ts
@@ -0,0 +1,24 @@
1import { Job } from '@stacksjs/queue'
2import { Every } from '@stacksjs/types'
3import { bluesky } from '../Services/Social/BlueskyService'
4
5export default new Job({
6 name: 'SyncEngagementMetrics',
7 description: 'Refresh Bluesky engagement counts into post_targets.metrics',
8 queue: 'default',
9 tries: 1,
10 backoff: 3,
11 rate: Every.HalfHour,
12
13 handle: async () => {
14 try {
15 const { synced } = await bluesky.syncMetrics()
16 if (synced) console.log(`[postline] engagement sync: ${synced} targets refreshed`)
17 return { synced }
18 }
19 catch (error) {
20 // No connected account yet is normal on fresh installs — stay quiet.
21 return { synced: 0, skipped: error instanceof Error ? error.message : String(error) }
22 }
23 },
24})
app/Scheduler.tsmodified+5-0
Changes to app/Scheduler.ts
@@ -21,6 +21,11 @@ export default function () {
2121 .job('PublishScheduledPosts')
2222 .everyMinute()
2323
24 // Refresh Bluesky engagement counts for the analytics page
25 schedule
26 .job('SyncEngagementMetrics')
27 .everyThirtyMinutes()
28
2429 // Run a custom action every five minutes
2530 // schedule.action('CleanupTempFiles').everyFiveMinutes()
2631
app/Services/Social/BlueskyService.tsmodified+52-0
Changes to app/Services/Social/BlueskyService.ts
@@ -273,6 +273,58 @@ export class BlueskyService {
273273 }
274274 }
275275
276 /**
277 * Refresh engagement counts (likes/reposts/replies) for recently
278 * published Bluesky targets into `post_targets.metrics`. Batched 25
279 * URIs per API call; missing posts (deleted upstream) are skipped.
280 */
281 async syncMetrics(limit = 100): Promise<{ synced: number }> {
282 const identity = await this.requireIdentity()
283 await this.ensureDriver()
284
285 const targets = await database
286 .selectFrom('post_targets')
287 .select(['id', 'remote_uri'])
288 .where('provider', '=', 'bluesky')
289 .where('status', '=', 'published')
290 .where('remote_uri', 'like', 'at://%')
291 .orderBy('id', 'desc')
292 .limit(limit)
293 .execute()
294
295 if (targets.length === 0) return { synced: 0 }
296
297 let synced = 0
298 for (let offset = 0; offset < targets.length; offset += 25) {
299 const chunk = targets.slice(offset, offset + 25)
300 const metrics = await this.withFreshSession(identity, freshIdentity =>
301 this.driver.postMetrics({
302 handle: freshIdentity.handle,
303 did: freshIdentity.external_id || undefined,
304 accessToken: freshIdentity.access_token || undefined,
305 refreshToken: freshIdentity.refresh_token || undefined,
306 }, chunk.map((target: any) => String(target.remote_uri))),
307 )
308
309 const byUri = new Map(metrics.map(item => [item.uri, item]))
310 for (const target of chunk) {
311 const counts = byUri.get(String(target.remote_uri))
312 if (!counts) continue
313 await database.updateTable('post_targets').set({
314 metrics: JSON.stringify({
315 likes: counts.likeCount,
316 reposts: counts.repostCount,
317 replies: counts.replyCount,
318 syncedAt: now(),
319 }),
320 }).where('id', '=', target.id).execute()
321 synced += 1
322 }
323 }
324
325 return { synced }
326 }
327
276328 async syncTimeline(limit = 30): Promise<TimelineResult & { saved: number }> {
277329 const identity = await this.requireIdentity()
278330 const driver = await this.ensureDriver()
resources/assets/scripts/postline-analytics.tsmodified+11-0
Changes to resources/assets/scripts/postline-analytics.ts
@@ -36,6 +36,7 @@ interface AnalyticsPayload {
3636 postsLastWeek: number
3737 bestChannel: { provider: string, published: number, share: number } | null
3838 bestHour: { hour: number, count: number } | null
39 engagement?: { likes: number, reposts: number, replies: number, syncedTargets: number }
3940 }
4041 series: Point[]
4142 channelMix: Array<{ provider: string, published: number, share: number }>
@@ -254,6 +255,16 @@ function fillStats(payload: AnalyticsPayload): void {
254255
255256 set('[data-analytics-posts-week]', String(stats.postsThisWeek))
256257 set('[data-analytics-posts-trend]', `${delta >= 0 ? '+' : ''}${delta} from last week`)
258
259 const engagement = stats.engagement
260 const interactions = engagement ? engagement.likes + engagement.reposts + engagement.replies : 0
261 set('[data-analytics-engagement]', engagement?.syncedTargets ? String(interactions) : '—')
262 set(
263 '[data-analytics-engagement-trend]',
264 engagement?.syncedTargets
265 ? `${engagement.likes} likes · ${engagement.reposts} reposts · ${engagement.replies} replies`
266 : 'Syncs after publishing to Bluesky',
267 )
257268 set('[data-analytics-best-channel]', stats.bestChannel ? (providerNames[stats.bestChannel.provider] || stats.bestChannel.provider) : '—')
258269 set('[data-analytics-best-channel-trend]', stats.bestChannel ? `${stats.bestChannel.published} published posts` : 'No published posts yet')
259270 set('[data-analytics-best-window]', stats.bestHour ? formatHour(stats.bestHour.hour) : '—')
resources/views/analytics.stxmodified+6-1
Changes to resources/views/analytics.stx
@@ -13,12 +13,17 @@
1313 </div>
1414 </header>
1515
16 <div class="grid gap-3.5 lg:grid-cols-3">
16 <div class="grid gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
1717 <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm">
1818 <span class="text-xs text-zinc-500">Posts this week</span>
1919 <strong class="text-2xl" data-analytics-posts-week>—</strong>
2020 <span class="text-xs text-zinc-500" data-analytics-posts-trend>Loading…</span>
2121 </article>
22 <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm">
23 <span class="text-xs text-zinc-500">Engagement</span>
24 <strong class="text-2xl" data-analytics-engagement>—</strong>
25 <span class="text-xs text-zinc-500" data-analytics-engagement-trend>Loading…</span>
26 </article>
2227 <article class="grid gap-2 p-3.5 bg-white border border-zinc-200 rounded-2xl shadow-sm">
2328 <span class="text-xs text-zinc-500">Best channel</span>
2429 <strong class="text-2xl" data-analytics-best-channel>—</strong>
routes/api.tsmodified+1-0
Changes to routes/api.ts
@@ -61,4 +61,5 @@ route.group({ prefix: '/postline' }, () => {
6161 route.post('/queue', 'Actions/Postline/QueueSaveAction').middleware('auth').skipCsrf()
6262 route.post('/queue/delete', 'Actions/Postline/QueueDeleteAction').middleware('auth').skipCsrf()
6363 route.post('/queue/publish-now', 'Actions/Postline/QueuePublishNowAction').middleware('auth').skipCsrf()
64 route.post('/metrics/sync', 'Actions/Postline/MetricsSyncAction').middleware('auth').skipCsrf()
6465})
storage/framework/core/socials/src/drivers/bluesky.tsmodified+27-0
Changes to storage/framework/core/socials/src/drivers/bluesky.ts
@@ -218,6 +218,33 @@ export class BlueskyPublishingDriver implements SocialPublishingDriver {
218218 }
219219 }
220220
221 /**
222 * Engagement counts for up to 25 of the account's posts per call
223 * (app.bsky.feed.getPosts). Deleted posts are simply absent from the
224 * response.
225 */
226 async postMetrics(
227 identity: SocialIdentityCredentials,
228 uris: string[],
229 ): Promise<Array<{ uri: string, likeCount: number, repostCount: number, replyCount: number }>> {
230 if (!identity.accessToken) throw new Error('Bluesky access token is missing for this identity.')
231 if (uris.length === 0) return []
232
233 const url = new URL(`${this.service}/xrpc/app.bsky.feed.getPosts`)
234 for (const uri of uris.slice(0, 25)) url.searchParams.append('uris', uri)
235
236 const payload = await this.request<{ posts?: Array<{ uri: string, likeCount?: number, repostCount?: number, replyCount?: number }> }>(url, {
237 headers: { authorization: `Bearer ${identity.accessToken}` },
238 })
239
240 return (payload.posts || []).map(post => ({
241 uri: post.uri,
242 likeCount: post.likeCount || 0,
243 repostCount: post.repostCount || 0,
244 replyCount: post.replyCount || 0,
245 }))
246 }
247
221248 async timeline(identity: SocialIdentityCredentials, query: TimelineQuery = {}): Promise<TimelineResult> {
222249 if (!identity.accessToken) throw new Error('Bluesky access token is missing for this identity.')
223250
tests/unit/social-publish.test.tsmodified+33-0
Changes to tests/unit/social-publish.test.ts
@@ -159,6 +159,39 @@ describe('BlueskyPublishingDriver.publish', () => {
159159 expect(record.embed.images[0].image.ref.$link).toBeDefined()
160160 })
161161
162 test('postMetrics fetches engagement counts for up to 25 URIs', async () => {
163 const captured: CapturedRequest[] = []
164 globalThis.fetch = (async (input: any) => {
165 const url = new URL(String(input))
166 captured.push({ url: String(input), body: undefined })
167 const uris = url.searchParams.getAll('uris')
168 return new Response(JSON.stringify({
169 // Second post "deleted" upstream — absent from the response.
170 posts: uris.slice(0, 1).map(uri => ({ uri, likeCount: 7, repostCount: 2, replyCount: 3 })),
171 }), { status: 200, headers: { 'content-type': 'application/json' } })
172 }) as typeof fetch
173
174 const driver = new BlueskyPublishingDriver()
175 const metrics = await driver.postMetrics(identity, [
176 'at://did:plc:test/app.bsky.feed.post/aaa',
177 'at://did:plc:test/app.bsky.feed.post/bbb',
178 ])
179
180 expect(captured[0].url).toContain('app.bsky.feed.getPosts')
181 expect(metrics).toEqual([
182 { uri: 'at://did:plc:test/app.bsky.feed.post/aaa', likeCount: 7, repostCount: 2, replyCount: 3 },
183 ])
184 })
185
186 test('postMetrics returns empty without hitting the API for no URIs', async () => {
187 const captured: CapturedRequest[] = []
188 mockBlueskyApi(captured)
189
190 const driver = new BlueskyPublishingDriver()
191 expect(await driver.postMetrics(identity, [])).toEqual([])
192 expect(captured).toHaveLength(0)
193 })
194
162195 test('uploadBlob rejects images over 1MB', async () => {
163196 const captured: CapturedRequest[] = []
164197 mockBlueskyApi(captured)