diff --git a/__tests__/common/communitySentiment.ts b/__tests__/common/communitySentiment.ts new file mode 100644 index 0000000000..45ec1bc0eb --- /dev/null +++ b/__tests__/common/communitySentiment.ts @@ -0,0 +1,222 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { + mapCommunitySentimentPayload, + tryMapCommunitySentimentPayload, +} from '../../src/common/communitySentiment'; + +jest.setTimeout(30000); + +const validPayload = { + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: 'Developers like the idea but worry about scale.', + post_count: 410, + sources: ['Hacker News', 'Lobsters'], + pros: ['One database to run'], + cons: ['Purpose-built tools still win at scale'], + by_source: [ + { + source: 'Hacker News', + lean: 'heated', + note: 'Classic flame war', + url: 'https://news.ycombinator.com/item?id=1', + }, + ], + hottest_debate: 'Is consolidating a smart simplification?', + open_questions: ['At what scale does it break down?'], + highlights: [ + { + quote: 'Every service I replace is one less thing paging me at 3am.', + author: 'throwaway_42', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=1', + metrics: { points: 214, replies: 96 }, + }, + ], +}; + +const validDiscussions = [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 329, + comments_count: 172, + }, +]; + +describe('mapCommunitySentimentPayload', () => { + it('should return undefined when no take is present', () => { + expect( + mapCommunitySentimentPayload({ + communitySentiment: undefined, + discussions: validDiscussions, + }), + ).toBeUndefined(); + expect( + mapCommunitySentimentPayload({ + communitySentiment: null, + discussions: validDiscussions, + }), + ).toBeUndefined(); + }); + + it('should map a valid wire payload into the stored shape', () => { + const result = mapCommunitySentimentPayload({ + communitySentiment: validPayload, + discussions: validDiscussions, + }); + + expect(result).toMatchObject({ + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: validPayload.tldr, + postCount: 410, + sources: ['Hacker News', 'Lobsters'], + pros: validPayload.pros, + cons: validPayload.cons, + bySource: [ + { + source: 'Hacker News', + lean: 'heated', + note: 'Classic flame war', + url: 'https://news.ycombinator.com/item?id=1', + }, + ], + hottestDebate: validPayload.hottest_debate, + openQuestions: validPayload.open_questions, + highlights: [ + { + quote: validPayload.highlights[0].quote, + author: 'throwaway_42', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=1', + metrics: { points: 214, replies: 96 }, + }, + ], + discussions: [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 329, + commentsCount: 172, + }, + ], + }); + expect(typeof result?.updatedAt).toBe('string'); + }); + + it('should default discussions to an empty array when omitted', () => { + const result = mapCommunitySentimentPayload({ + communitySentiment: validPayload, + discussions: undefined, + }); + + expect(result?.discussions).toEqual([]); + }); + + it('should accept a minimal payload with omitempty-omitted fields', () => { + const result = mapCommunitySentimentPayload({ + communitySentiment: { + breakdown: { positive: 60, mixed: 25, critical: 15 }, + tldr: 'Early take from a small thread.', + post_count: 12, + }, + discussions: undefined, + }); + + expect(result).toMatchObject({ + breakdown: { positive: 60, mixed: 25, critical: 15 }, + tldr: 'Early take from a small thread.', + postCount: 12, + sources: [], + pros: [], + cons: [], + bySource: [], + hottestDebate: undefined, + openQuestions: [], + highlights: [], + discussions: [], + }); + }); + + it('should throw when the breakdown does not sum to 100', () => { + expect(() => + mapCommunitySentimentPayload({ + communitySentiment: { + ...validPayload, + breakdown: { positive: 50, mixed: 20, critical: 20 }, + }, + discussions: validDiscussions, + }), + ).toThrow(); + }); + + it('should throw when a by_source lean value is invalid', () => { + expect(() => + mapCommunitySentimentPayload({ + communitySentiment: { + ...validPayload, + by_source: [ + { source: 'Hacker News', lean: 'angry', note: 'invalid lean' }, + ], + }, + discussions: validDiscussions, + }), + ).toThrow(); + }); + + it('should drop discussion entries missing provider or url', () => { + const result = mapCommunitySentimentPayload({ + communitySentiment: validPayload, + discussions: [ + ...validDiscussions, + { provider: 'lobsters', points: 12 }, + { url: 'https://lobste.rs/s/abc', comments_count: 3 }, + ], + }); + + expect(result?.discussions).toEqual([ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 329, + commentsCount: 172, + }, + ]); + }); + + it('should throw when discussions are malformed', () => { + expect(() => + mapCommunitySentimentPayload({ + communitySentiment: validPayload, + discussions: 'not-an-array', + }), + ).toThrow(); + }); +}); + +describe('tryMapCommunitySentimentPayload', () => { + const logger = { warn: jest.fn() } as unknown as FastifyBaseLogger; + + it('should return the mapped take for a valid payload', () => { + const result = tryMapCommunitySentimentPayload({ + logger, + communitySentiment: validPayload, + discussions: validDiscussions, + }); + + expect(result?.postCount).toEqual(410); + }); + + it('should swallow validation errors and return undefined', () => { + const result = tryMapCommunitySentimentPayload({ + logger, + communitySentiment: { + ...validPayload, + breakdown: { positive: 50, mixed: 20, critical: 20 }, + }, + discussions: validDiscussions, + }); + + expect(result).toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/posts.ts b/__tests__/posts.ts index f29051c37f..975ff5b08c 100644 --- a/__tests__/posts.ts +++ b/__tests__/posts.ts @@ -324,6 +324,126 @@ describe('slug field', () => { }); }); +describe('communitySentiment field', () => { + const QUERY = /* GraphQL */ ` + { + post(id: "p1") { + communitySentiment { + breakdown { + positive + mixed + critical + } + tldr + postCount + sources + pros + cons + bySource { + source + lean + note + url + } + hottestDebate + openQuestions + highlights { + quote + author + source + url + metrics { + points + replies + likes + } + } + discussions { + provider + url + points + commentsCount + } + updatedAt + } + } + } + `; + + it('should return null when the post has no community take', async () => { + const res = await client.query(QUERY); + expect(res.errors).toBeUndefined(); + expect(res.data.post.communitySentiment).toBeNull(); + }); + + it('should return the community take when present', async () => { + const communitySentiment = { + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: 'Developers like the idea but worry about scale.', + postCount: 410, + sources: ['Hacker News', 'Lobsters'], + pros: ['One database to run'], + cons: ['Purpose-built tools still win at scale'], + bySource: [ + { + source: 'Hacker News', + lean: 'heated', + note: 'Classic flame war', + url: 'https://news.ycombinator.com/item?id=1', + }, + ], + hottestDebate: 'Is consolidating a smart simplification?', + openQuestions: ['At what scale does it break down?'], + highlights: [ + { + quote: 'Every service I replace is one less thing paging me at 3am.', + author: 'throwaway_42', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=1', + metrics: { points: 214, replies: 96 }, + }, + ], + discussions: [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 329, + commentsCount: 172, + }, + ], + updatedAt: new Date().toISOString(), + }; + + await con + .getRepository(ArticlePost) + .update({ id: 'p1' }, { communitySentiment }); + + const res = await client.query(QUERY); + expect(res.errors).toBeUndefined(); + expect(res.data.post.communitySentiment).toMatchObject({ + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: communitySentiment.tldr, + postCount: 410, + sources: ['Hacker News', 'Lobsters'], + pros: communitySentiment.pros, + cons: communitySentiment.cons, + bySource: communitySentiment.bySource, + hottestDebate: communitySentiment.hottestDebate, + openQuestions: communitySentiment.openQuestions, + highlights: [ + { + quote: communitySentiment.highlights[0].quote, + author: 'throwaway_42', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=1', + metrics: { points: 214, replies: 96, likes: null }, + }, + ], + discussions: communitySentiment.discussions, + }); + }); +}); + describe('image fields', () => { const QUERY = /* GraphQL */ ` { diff --git a/__tests__/workers/__snapshots__/postUpdated.ts.snap b/__tests__/workers/__snapshots__/postUpdated.ts.snap index 8377311707..53a5b8213d 100644 --- a/__tests__/workers/__snapshots__/postUpdated.ts.snap +++ b/__tests__/workers/__snapshots__/postUpdated.ts.snap @@ -7,6 +7,7 @@ Object { "banned": false, "canonicalUrl": "https://post.com", "comments": 0, + "communitySentiment": null, "contentCuration": Any, "contentMeta": Any, "contentQuality": Any, diff --git a/__tests__/workers/postUpdated.ts b/__tests__/workers/postUpdated.ts index 2ff53d3bcd..edc5b1fce0 100644 --- a/__tests__/workers/postUpdated.ts +++ b/__tests__/workers/postUpdated.ts @@ -2690,6 +2690,175 @@ describe('on post update', () => { }); }); +describe('community sentiment', () => { + const communitySentimentPayload = ( + overrides: Record = {}, + ) => ({ + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: 'Developers like the idea but worry about scale.', + post_count: 410, + sources: ['Hacker News', 'Lobsters'], + pros: ['One database to run'], + cons: ['Purpose-built tools still win at scale'], + by_source: [ + { + source: 'Hacker News', + lean: 'heated', + note: 'Classic flame war', + url: 'https://news.ycombinator.com/item?id=1', + }, + ], + hottest_debate: 'Is consolidating a smart simplification?', + open_questions: ['At what scale does it break down?'], + highlights: [ + { + quote: 'Every service I replace is one less thing paging me at 3am.', + author: 'throwaway_42', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=1', + metrics: { points: 214, replies: 96 }, + }, + ], + ...overrides, + }); + + const discussionsPayload = [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 329, + comments_count: 172, + }, + ]; + + it('should set communitySentiment when creating a new post', async () => { + const yggdrasilId = randomUUID(); + + await expectSuccessfulBackground(worker, { + id: yggdrasilId, + title: 'New title', + url: 'https://post-with-take.com', + source_id: 'a', + extra: { + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + const post = await con.getRepository(Post).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 overwrite communitySentiment when updating an existing post', async () => { + await expectSuccessfulBackground(worker, { + id: 'f99a445f-e2fb-48e8-959c-e02a17f5e816', + post_id: 'p1', + title: 'New title', + extra: { + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + let post = await con.getRepository(Post).findOneByOrFail({ id: 'p1' }); + expect(post.communitySentiment?.postCount).toEqual(410); + + await expectSuccessfulBackground(worker, { + id: randomUUID(), + post_id: 'p1', + title: 'New title', + extra: { + community_sentiment: communitySentimentPayload({ post_count: 512 }), + discussions: discussionsPayload, + }, + }); + + post = await con.getRepository(Post).findOneByOrFail({ id: 'p1' }); + expect(post.communitySentiment?.postCount).toEqual(512); + }); + + it('should leave communitySentiment untouched when an update carries no take', async () => { + await expectSuccessfulBackground(worker, { + id: 'f99a445f-e2fb-48e8-959c-e02a17f5e816', + post_id: 'p1', + title: 'New title', + extra: { + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + await expectSuccessfulBackground(worker, { + id: randomUUID(), + post_id: 'p1', + title: 'Another title update', + }); + + const post = await con.getRepository(Post).findOneByOrFail({ id: 'p1' }); + expect(post.communitySentiment?.postCount).toEqual(410); + expect(post.title).toEqual('Another title update'); + }); + + it('should create the post without sentiment when the breakdown does not sum to 100', async () => { + const yggdrasilId = randomUUID(); + + await expectSuccessfulBackground(worker, { + id: yggdrasilId, + title: 'New title', + url: 'https://post-with-bad-take.com', + source_id: 'a', + extra: { + community_sentiment: communitySentimentPayload({ + breakdown: { positive: 50, mixed: 20, critical: 20 }, + }), + }, + }); + + const post = await con.getRepository(Post).findOneByOrFail({ yggdrasilId }); + expect(post.title).toEqual('New title'); + expect(post.communitySentiment).toBeNull(); + }); + + it('should keep the previous sentiment but apply the update when the breakdown does not sum to 100', async () => { + await expectSuccessfulBackground(worker, { + id: 'f99a445f-e2fb-48e8-959c-e02a17f5e816', + post_id: 'p1', + title: 'New title', + extra: { + community_sentiment: communitySentimentPayload(), + discussions: discussionsPayload, + }, + }); + + await expectSuccessfulBackground(worker, { + id: 'f99a445f-e2fb-48e8-959c-e02a17f5e816', + post_id: 'p1', + title: 'Title update with bad take', + extra: { + community_sentiment: communitySentimentPayload({ + breakdown: { positive: 50, mixed: 20, critical: 20 }, + }), + }, + }); + + const post = await con.getRepository(Post).findOneByOrFail({ id: 'p1' }); + expect(post.communitySentiment?.postCount).toEqual(410); + expect(post.title).toEqual('Title update with bad take'); + }); +}); + describe('on youtube post', () => { beforeEach(async () => { await saveFixtures(con, Source, [ @@ -3074,6 +3243,50 @@ describe('on collection post', () => { }); }); + it('should set communitySentiment when creating a new collection post', async () => { + await expectSuccessfulBackground(worker, { + id: '7ec0bccb-e41f-4c77-a3b4-fe19d20b3874', + post_id: undefined, + title: 'New title', + content_type: PostType.Collection, + source_id: 'collections', + extra: { + community_sentiment: { + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: 'Developers like the idea but worry about scale.', + post_count: 410, + sources: ['Hacker News', 'Lobsters'], + pros: ['One database to run'], + cons: ['Purpose-built tools still win at scale'], + by_source: [], + open_questions: [], + highlights: [], + }, + discussions: [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=1', + points: 329, + comments_count: 172, + }, + ], + }, + }); + + const collection = await con.getRepository(CollectionPost).findOneByOrFail({ + yggdrasilId: '7ec0bccb-e41f-4c77-a3b4-fe19d20b3874', + }); + expect(collection.communitySentiment).toMatchObject({ + postCount: 410, + discussions: [ + { + provider: 'hackernews', + commentsCount: 172, + }, + ], + }); + }); + it('should add post relations', async () => { await con.getRepository(ArticlePost).save([ { diff --git a/src/common/communitySentiment.ts b/src/common/communitySentiment.ts new file mode 100644 index 0000000000..6f6e300ca8 --- /dev/null +++ b/src/common/communitySentiment.ts @@ -0,0 +1,108 @@ +import { ValidationError } from 'apollo-server-errors'; +import type { FastifyBaseLogger } from 'fastify'; +import type { PostCommunitySentiment } from '../entity/posts/Post'; +import { + communitySentimentDiscussionsSchema, + communitySentimentPayloadSchema, +} from './schema/communitySentiment'; + +export const mapCommunitySentimentPayload = ({ + communitySentiment, + discussions, +}: { + communitySentiment?: unknown; + discussions?: unknown; +}): PostCommunitySentiment | undefined => { + if (communitySentiment === undefined || communitySentiment === null) { + return undefined; + } + + const takeResult = + communitySentimentPayloadSchema.safeParse(communitySentiment); + if (!takeResult.success) { + throw new ValidationError( + JSON.stringify({ + communitySentiment: takeResult.error.flatten().fieldErrors, + }), + ); + } + + const discussionsResult = communitySentimentDiscussionsSchema.safeParse( + discussions ?? [], + ); + if (!discussionsResult.success) { + throw new ValidationError( + JSON.stringify({ + discussions: discussionsResult.error.flatten().fieldErrors, + }), + ); + } + + const take = takeResult.data; + + return { + breakdown: take.breakdown, + tldr: take.tldr, + postCount: take.post_count, + sources: take.sources ?? [], + pros: take.pros ?? [], + cons: take.cons ?? [], + bySource: (take.by_source ?? []).map(({ source, lean, note, url }) => ({ + source, + lean, + note: note ?? '', + url: url ?? undefined, + })), + hottestDebate: take.hottest_debate ?? undefined, + openQuestions: take.open_questions ?? [], + highlights: (take.highlights ?? []).map( + ({ quote, author, source, url, metrics }) => ({ + quote, + author: author ?? '', + source, + url: url ?? '', + metrics: metrics + ? { + points: metrics.points ?? undefined, + replies: metrics.replies ?? undefined, + likes: metrics.likes ?? undefined, + } + : undefined, + }), + ), + discussions: discussionsResult.data.flatMap( + ({ provider, url, points, comments_count }) => + provider && url + ? [ + { + provider, + url, + points: points ?? 0, + commentsCount: comments_count ?? 0, + }, + ] + : [], + ), + updatedAt: new Date().toISOString(), + }; +}; + +export const tryMapCommunitySentimentPayload = ({ + logger, + communitySentiment, + discussions, +}: { + logger: FastifyBaseLogger; + communitySentiment?: unknown; + discussions?: unknown; +}): PostCommunitySentiment | undefined => { + try { + return mapCommunitySentimentPayload({ communitySentiment, discussions }); + } catch (err) { + logger.warn( + { err }, + 'invalid community sentiment payload, skipping sentiment update', + ); + return undefined; + } +}; diff --git a/src/common/schema/communitySentiment.ts b/src/common/schema/communitySentiment.ts new file mode 100644 index 0000000000..6ff6314f13 --- /dev/null +++ b/src/common/schema/communitySentiment.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; + +export const communitySentimentLeanValues = [ + 'positive', + 'mixed', + 'skeptical', + 'heated', +] as const; + +export const communitySentimentLeanSchema = z.enum( + communitySentimentLeanValues, +); + +export const communitySentimentBreakdownSchema = z + .object({ + positive: z.number(), + mixed: z.number(), + critical: z.number(), + }) + .refine( + ({ positive, mixed, critical }) => positive + mixed + critical === 100, + { message: 'breakdown percentages must sum to 100' }, + ); + +export const communitySentimentSourceSchema = z.object({ + source: z.string(), + lean: communitySentimentLeanSchema, + note: z.string().nullish(), + url: z.string().nullish(), +}); + +export const communitySentimentHighlightMetricsSchema = z.object({ + points: z.number().nullish(), + replies: z.number().nullish(), + likes: z.number().nullish(), +}); + +export const communitySentimentHighlightSchema = z.object({ + quote: z.string(), + author: z.string().nullish(), + source: z.string(), + url: z.string().nullish(), + metrics: communitySentimentHighlightMetricsSchema.nullish(), +}); + +export const communitySentimentPayloadSchema = z.object({ + breakdown: communitySentimentBreakdownSchema, + tldr: z.string(), + post_count: z.number(), + sources: z.array(z.string()).nullish(), + pros: z.array(z.string()).nullish(), + cons: z.array(z.string()).nullish(), + by_source: z.array(communitySentimentSourceSchema).nullish(), + hottest_debate: z.string().nullish(), + open_questions: z.array(z.string()).nullish(), + highlights: z.array(communitySentimentHighlightSchema).nullish(), +}); + +export const communitySentimentDiscussionSchema = z.object({ + provider: z.string().nullish(), + url: z.string().nullish(), + points: z.number().nullish(), + comments_count: z.number().nullish(), +}); + +export const communitySentimentDiscussionsSchema = z.array( + communitySentimentDiscussionSchema, +); + +export type CommunitySentimentLean = z.infer< + typeof communitySentimentLeanSchema +>; +export type CommunitySentimentPayload = z.infer< + typeof communitySentimentPayloadSchema +>; +export type CommunitySentimentDiscussionPayload = z.infer< + typeof communitySentimentDiscussionSchema +>; diff --git a/src/entity/posts/Post.ts b/src/entity/posts/Post.ts index 4076f834d3..2889a5e4f3 100644 --- a/src/entity/posts/Post.ts +++ b/src/entity/posts/Post.ts @@ -110,6 +110,67 @@ export type PostTranslation = { [key in TranslateablePostField]?: string; }; +export type PostCommunitySentimentLean = + | 'positive' + | 'mixed' + | 'skeptical' + | 'heated'; + +export type PostCommunitySentimentBreakdown = { + positive: number; + mixed: number; + critical: number; +}; + +export type PostCommunitySentimentSource = { + source: string; + lean: PostCommunitySentimentLean; + note: string; + url?: string | null; +}; + +export type PostCommunitySentimentHighlightMetrics = Partial<{ + points: number; + replies: number; + likes: number; +}>; + +export type PostCommunitySentimentHighlight = { + quote: string; + author: string; + source: string; + url: string; + metrics?: PostCommunitySentimentHighlightMetrics; +}; + +export type PostCommunitySentimentDiscussion = { + provider: string; + url: string; + points: number; + commentsCount: number; +}; + +/** + * Community take: what the developer community outside daily.dev thinks + * about a post, aggregated from external discussions (Hacker News, Lobsters). + * Mirrors the frontend `CommunitySentimentData` contract, plus the + * discussion links the take was generated from and when it was last updated. + */ +export type PostCommunitySentiment = { + breakdown: PostCommunitySentimentBreakdown; + tldr: string; + postCount: number; + sources: string[]; + pros: string[]; + cons: string[]; + bySource: PostCommunitySentimentSource[]; + hottestDebate?: string; + openQuestions: string[]; + highlights: PostCommunitySentimentHighlight[]; + discussions: PostCommunitySentimentDiscussion[]; + updatedAt: string; +}; + @Entity() @Index('IDX_post_id_sourceid', ['id', 'sourceId']) @Index('IDX_post_deleted_visible_type_views', [ @@ -359,4 +420,7 @@ export class Post { @Column({ nullable: true }) readTime?: number; + + @Column({ type: 'jsonb', nullable: true }) + communitySentiment?: PostCommunitySentiment | null; } diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 6c8b19d22f..cecfa527c2 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -945,6 +945,9 @@ const obj = new GraphORM({ toc: { jsonType: true, }, + communitySentiment: { + jsonType: true, + }, sharedPost: { relation: { isMany: false, diff --git a/src/migration/1784035940370-AddCommunitySentimentToPost.ts b/src/migration/1784035940370-AddCommunitySentimentToPost.ts new file mode 100644 index 0000000000..74def0c26a --- /dev/null +++ b/src/migration/1784035940370-AddCommunitySentimentToPost.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCommunitySentimentToPost1784035940370 + implements MigrationInterface +{ + name = 'AddCommunitySentimentToPost1784035940370'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "post" + ADD COLUMN IF NOT EXISTS "communitySentiment" jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "post" + DROP COLUMN IF EXISTS "communitySentiment" + `); + } +} diff --git a/src/schema/posts.ts b/src/schema/posts.ts index 09abc9e043..cb07c72689 100644 --- a/src/schema/posts.ts +++ b/src/schema/posts.ts @@ -69,6 +69,7 @@ import { Post, PostFlagsPublic, PostMention, + type PostCommunitySentiment, PostQuestion, PostRelation, PostRelationType, @@ -235,6 +236,7 @@ export interface GQLPost { endsAt?: Date; pollOptions?: GQLPollOption[]; numPollVotes?: number; + communitySentiment?: PostCommunitySentiment | null; } type ScheduledPostsContext = AuthContext & { @@ -908,6 +910,13 @@ export const typeDefs = /* GraphQL */ ` """ creatorTwitterImage: String + """ + Community take: what the developer community outside daily.dev thinks + about this post, aggregated from external discussions (Hacker News, + Lobsters). Null when no take has been generated for this post yet. + """ + communitySentiment: PostCommunitySentiment + """ Featured award for the post, currently the most expensive one """ @@ -1015,6 +1024,74 @@ export const typeDefs = /* GraphQL */ ` question: String! } + """ + Sentiment breakdown percentages across the external developer community + """ + type PostCommunitySentimentBreakdown { + positive: Int! + mixed: Int! + critical: Int! + } + + """ + How a specific external community (e.g. Hacker News) leans on the topic + """ + type PostCommunitySentimentSource { + source: String! + lean: String! + note: String! + url: String + } + + """ + Engagement metrics for a highlighted quote + """ + type PostCommunitySentimentHighlightMetrics { + points: Int + replies: Int + likes: Int + } + + """ + A verbatim highlight quote from an external discussion + """ + type PostCommunitySentimentHighlight { + quote: String! + author: String! + source: String! + url: String! + metrics: PostCommunitySentimentHighlightMetrics + } + + """ + A linked external discussion the take is drawn from + """ + type PostCommunitySentimentDiscussion { + provider: String! + url: String! + points: Int! + commentsCount: Int! + } + + """ + Community take: what the developer community outside daily.dev thinks + about a post + """ + type PostCommunitySentiment { + breakdown: PostCommunitySentimentBreakdown! + tldr: String! + postCount: Int! + sources: [String!]! + pros: [String!]! + cons: [String!]! + bySource: [PostCommunitySentimentSource!]! + hottestDebate: String + openQuestions: [String!]! + highlights: [PostCommunitySentimentHighlight!]! + discussions: [PostCommunitySentimentDiscussion!]! + updatedAt: DateTime + } + type PollOption { id: String! numVotes: Int! diff --git a/src/workers/postUpdated/article/processing.ts b/src/workers/postUpdated/article/processing.ts index aee03822a9..4cd1a12736 100644 --- a/src/workers/postUpdated/article/processing.ts +++ b/src/workers/postUpdated/article/processing.ts @@ -1,6 +1,7 @@ import { PostType } from '../../../entity'; import type { ProcessPostProps, ProcessedPost } from '../types'; import { resolveCommonDeps, buildCommonPostFields } from '../common'; +import { tryMapCommunitySentimentPayload } from '../../../common/communitySentiment'; export const processArticle = async ({ logger, @@ -36,6 +37,15 @@ export const processArticle = async ({ contentType, }); + const communitySentiment = tryMapCommunitySentimentPayload({ + logger, + communitySentiment: data?.extra?.community_sentiment, + discussions: data?.extra?.discussions, + }); + if (communitySentiment) { + fixedData.communitySentiment = communitySentiment; + } + return { contentType, fixedData, diff --git a/src/workers/postUpdated/collection/processing.ts b/src/workers/postUpdated/collection/processing.ts index 4445746ae0..7ab4e38723 100644 --- a/src/workers/postUpdated/collection/processing.ts +++ b/src/workers/postUpdated/collection/processing.ts @@ -8,6 +8,7 @@ import { import type { EntityManager } from 'typeorm'; import type { Data, ProcessPostProps, ProcessedPost } from '../types'; import { resolveCommonDeps, buildCommonPostFields } from '../common'; +import { tryMapCommunitySentimentPayload } from '../../../common/communitySentiment'; export const processCollection = async ({ logger, @@ -41,6 +42,15 @@ export const processCollection = async ({ contentType: PostType.Collection, }); + const communitySentiment = tryMapCommunitySentimentPayload({ + logger, + communitySentiment: data?.extra?.community_sentiment, + discussions: data?.extra?.discussions, + }); + if (communitySentiment) { + fixedData.communitySentiment = communitySentiment; + } + return { contentType: PostType.Collection, fixedData, diff --git a/src/workers/postUpdated/types.ts b/src/workers/postUpdated/types.ts index ee87c72d65..bbc395bf10 100644 --- a/src/workers/postUpdated/types.ts +++ b/src/workers/postUpdated/types.ts @@ -50,6 +50,8 @@ export type Data = { author_username?: string; author_name?: string; author_avatar?: string; + community_sentiment?: unknown; + discussions?: unknown; }; meta?: { scraped_html?: string;