From fba7f913769609e9b71c7a6cffb9690b91d083cd Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 24 Jul 2026 11:56:35 +0200 Subject: [PATCH] feat: persist community sentiment on social twitter posts The socialTwitter postUpdated processor now maps extra.community_sentiment/extra.discussions exactly like the article and collection processors, and communitySentiment joins twitterAllowedFields (the whitelist would otherwise silently strip it on every twitter update). No migration (column exists on the Post STI base) and no GraphQL changes (field already exposed). Companion to yggdrasil#711 (X as a community-take discussion provider). Co-Authored-By: Claude Fable 5 --- __tests__/workers/postUpdated.ts | 161 ++++++++++++++++++ .../postUpdated/socialTwitter/processing.ts | 11 ++ 2 files changed, 172 insertions(+) diff --git a/__tests__/workers/postUpdated.ts b/__tests__/workers/postUpdated.ts index 9615fa0f7e..3296081ccf 100644 --- a/__tests__/workers/postUpdated.ts +++ b/__tests__/workers/postUpdated.ts @@ -2901,6 +2901,167 @@ describe('community sentiment', () => { expect(post.communitySentiment?.postCount).toEqual(410); expect(post.title).toEqual('Title update with bad take'); }); + + describe('on social twitter posts', () => { + it('should set communitySentiment when creating a new social twitter post', async () => { + const yggdrasilId = randomUUID(); + + await expectSuccessfulBackground(worker, { + id: yggdrasilId, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/3001', + source_id: 'a', + extra: { + subtype: 'tweet', + content: 'Post with a take', + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + const post = await con + .getRepository(SocialTwitterPost) + .findOneByOrFail({ yggdrasilId }); + expect(post.communitySentiment).toMatchObject({ + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: communitySentimentPayload().tldr, + postCount: 410, + discussions: [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 329, + commentsCount: 172, + }, + ], + }); + }); + + it('should persist communitySentiment when updating an existing social twitter post', async () => { + const yggdrasilId = randomUUID(); + + await expectSuccessfulBackground(worker, { + id: yggdrasilId, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/3002', + source_id: 'a', + extra: { + subtype: 'tweet', + content: 'Original tweet', + }, + }); + + const created = await con + .getRepository(SocialTwitterPost) + .findOneByOrFail({ yggdrasilId }); + expect(created.communitySentiment).toBeNull(); + + await expectSuccessfulBackground(worker, { + id: randomUUID(), + post_id: created.id, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/3002', + source_id: 'a', + updated_at: new Date('2030-01-01'), + extra: { + subtype: 'tweet', + content: 'Original tweet', + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + const updated = await con + .getRepository(SocialTwitterPost) + .findOneByOrFail({ id: created.id }); + expect(updated.communitySentiment?.postCount).toEqual(410); + }); + + it('should leave social twitter communitySentiment untouched when an update carries no take', async () => { + const yggdrasilId = randomUUID(); + + await expectSuccessfulBackground(worker, { + id: yggdrasilId, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/3003', + source_id: 'a', + extra: { + subtype: 'tweet', + content: 'Original tweet', + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + const created = await con + .getRepository(SocialTwitterPost) + .findOneByOrFail({ yggdrasilId }); + expect(created.communitySentiment?.postCount).toEqual(410); + + await expectSuccessfulBackground(worker, { + id: randomUUID(), + post_id: created.id, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/3003', + source_id: 'a', + updated_at: new Date('2030-01-01'), + extra: { + subtype: 'tweet', + content: 'Updated tweet body', + }, + }); + + const updated = await con + .getRepository(SocialTwitterPost) + .findOneByOrFail({ id: created.id }); + expect(updated.communitySentiment?.postCount).toEqual(410); + expect(updated.title).toEqual('Updated tweet body'); + }); + + it('should ignore a malformed communitySentiment payload on social twitter update', async () => { + const yggdrasilId = randomUUID(); + + await expectSuccessfulBackground(worker, { + id: yggdrasilId, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/3004', + source_id: 'a', + extra: { + subtype: 'tweet', + content: 'Original tweet', + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + const created = await con + .getRepository(SocialTwitterPost) + .findOneByOrFail({ yggdrasilId }); + expect(created.communitySentiment?.postCount).toEqual(410); + + await expectSuccessfulBackground(worker, { + id: randomUUID(), + post_id: created.id, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/3004', + source_id: 'a', + updated_at: new Date('2030-01-01'), + extra: { + subtype: 'tweet', + content: 'Updated tweet with a bad take', + community_sentiment: communitySentimentPayload({ + breakdown: { positive: 50, mixed: 20, critical: 20 }, + }), + }, + }); + + const updated = await con + .getRepository(SocialTwitterPost) + .findOneByOrFail({ id: created.id }); + expect(updated.communitySentiment?.postCount).toEqual(410); + expect(updated.title).toEqual('Updated tweet with a bad take'); + }); + }); }); describe('on youtube post', () => { diff --git a/src/workers/postUpdated/socialTwitter/processing.ts b/src/workers/postUpdated/socialTwitter/processing.ts index d700f3a024..623dc450be 100644 --- a/src/workers/postUpdated/socialTwitter/processing.ts +++ b/src/workers/postUpdated/socialTwitter/processing.ts @@ -24,9 +24,11 @@ import type { } from '../types'; import { buildCommonPostFields, shouldSkipAuthorMatch } from '../common'; import { getSourcePrivacy } from '../shared'; +import { tryMapCommunitySentimentPayload } from '../../../common/communitySentiment'; export const twitterAllowedFields = [ 'canonicalUrl', + 'communitySentiment', 'content', 'contentCuration', 'contentHtml', @@ -241,6 +243,15 @@ export const processSocialTwitter = async ({ ? generateTitleHtml(fixedData.title, []) : null; + const communitySentiment = tryMapCommunitySentimentPayload({ + logger, + communitySentiment: data?.extra?.community_sentiment, + discussions: data?.extra?.discussions, + }); + if (communitySentiment) { + fixedData.communitySentiment = communitySentiment; + } + return { contentType: PostType.SocialTwitter, fixedData,