diff --git a/__tests__/highlights.ts b/__tests__/highlights.ts index 462aa6842f..81ee2b028e 100644 --- a/__tests__/highlights.ts +++ b/__tests__/highlights.ts @@ -8,7 +8,10 @@ import { saveFixtures, } from './helpers'; import createOrGetConnection from '../src/db'; -import { ArticlePost, Source } from '../src/entity'; +import { ArticlePost } from '../src/entity/posts/ArticlePost'; +import { ChannelDigest } from '../src/entity/ChannelDigest'; +import { ChannelHighlightDefinition } from '../src/entity/ChannelHighlightDefinition'; +import { Source, SourceType } from '../src/entity/Source'; import { PostHighlight, PostHighlightSignificance, @@ -86,9 +89,13 @@ const createTestPosts = async () => { beforeEach(async () => { jest.resetAllMocks(); + await con.getRepository(ChannelDigest).clear(); + await con.getRepository(ChannelHighlightDefinition).clear(); await con.getRepository(PostHighlight).clear(); await con.getRepository(ArticlePost).delete(['h1', 'h2', 'h3', 'h4']); - await con.getRepository(Source).delete(['a', 'b', 'c']); + await con + .getRepository(Source) + .delete(['a', 'b', 'c', 'backend_digest', 'career_digest']); }); const QUERY = ` @@ -130,6 +137,89 @@ const MAJOR_HEADLINES_QUERY = ` } `; +const CHANNEL_CONFIGURATIONS_QUERY = ` + query ChannelConfigurations { + channelConfigurations { + channel + displayName + digest { + frequency + source { + id + name + handle + } + } + } + } +`; + +describe('query channelConfigurations', () => { + it('should return non-disabled highlight channels with digest metadata', async () => { + await con.getRepository(Source).save({ + id: 'backend_digest', + name: 'Backend Digest', + image: 'https://example.com/backend.png', + handle: 'backend_digest', + type: SourceType.Machine, + active: true, + private: false, + }); + + await con.getRepository(ChannelHighlightDefinition).save([ + { + channel: 'career', + displayName: 'Career Growth', + mode: 'shadow', + }, + { + channel: 'backend', + displayName: 'Backend Engineering', + mode: 'publish', + }, + { + channel: 'disabled', + displayName: 'Disabled', + mode: 'disabled', + }, + ]); + + await con.getRepository(ChannelDigest).save({ + key: 'backend-digest', + channel: 'backend', + sourceId: 'backend_digest', + targetAudience: 'backend developers', + frequency: 'daily', + includeSentiment: false, + sentimentGroupIds: [], + enabled: true, + }); + + const res = await client.query(CHANNEL_CONFIGURATIONS_QUERY); + + expect(res.errors).toBeFalsy(); + expect(res.data.channelConfigurations).toEqual([ + { + channel: 'backend', + displayName: 'Backend Engineering', + digest: { + frequency: 'daily', + source: { + id: 'backend_digest', + name: 'Backend Digest', + handle: 'backend_digest', + }, + }, + }, + { + channel: 'career', + displayName: 'Career Growth', + digest: null, + }, + ]); + }); +}); + describe('query postHighlights', () => { it('should return empty array when no highlights exist', async () => { const res = await client.query(QUERY, { diff --git a/src/entity/ChannelHighlightDefinition.ts b/src/entity/ChannelHighlightDefinition.ts index 5974ec66ad..e349dd7675 100644 --- a/src/entity/ChannelHighlightDefinition.ts +++ b/src/entity/ChannelHighlightDefinition.ts @@ -12,6 +12,9 @@ export class ChannelHighlightDefinition { @PrimaryColumn({ type: 'text' }) channel: string; + @Column({ type: 'text', default: '' }) + displayName: string; + @Column({ type: 'text', default: 'disabled' }) mode: ChannelHighlightMode; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index c5ad189e90..e5c540f3e7 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -139,6 +139,14 @@ const existsByUserAndHotTake = const nullIfNotLoggedIn = (value: T, ctx: Context): T | null => ctx.userId ? value : null; +const defaultChannelDisplayName = ({ + channel, + displayName, +}: { + channel?: string; + displayName?: string | null; +}): string => displayName?.trim() || channel || ''; + export const nullIfNotTeamMember = < T, Ctx extends Pick, @@ -1381,6 +1389,45 @@ const obj = new GraphORM({ }, }, }, + ChannelConfiguration: { + from: 'ChannelHighlightDefinition', + requiredColumns: ['channel'], + fields: { + displayName: { + transform: (value: string, _, parent) => + defaultChannelDisplayName({ + channel: (parent as { channel?: string }).channel, + displayName: value, + }), + }, + digest: { + relation: { + isMany: false, + customRelation: (_, parentAlias, childAlias, qb): QueryBuilder => + qb + .where(`"${childAlias}"."channel" = "${parentAlias}"."channel"`) + .andWhere(`"${childAlias}"."enabled" = true`) + .orderBy(`"${childAlias}"."key"`, 'ASC') + .limit(1), + }, + }, + }, + }, + ChannelDigestConfiguration: { + from: 'ChannelDigest', + fields: { + source: { + relation: { + isMany: false, + customRelation: (_, parentAlias, childAlias, qb): QueryBuilder => + qb + .where(`"${childAlias}"."id" = "${parentAlias}"."sourceId"`) + .andWhere(`"${childAlias}"."active" = true`) + .limit(1), + }, + }, + }, + }, UserComment: { requiredColumns: ['votedAt', 'awardTransactionId'], fields: { diff --git a/src/migration/1776106185909-ChannelHighlightDisplayName.ts b/src/migration/1776106185909-ChannelHighlightDisplayName.ts new file mode 100644 index 0000000000..941e850616 --- /dev/null +++ b/src/migration/1776106185909-ChannelHighlightDisplayName.ts @@ -0,0 +1,33 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ChannelHighlightDisplayName1776106185909 + implements MigrationInterface +{ + name = 'ChannelHighlightDisplayName1776106185909'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "channel_highlight_definition" + ADD COLUMN "displayName" text NOT NULL DEFAULT '' + `); + + await queryRunner.query(/* sql */ ` + UPDATE "channel_highlight_definition" + SET "displayName" = INITCAP( + REPLACE( + REPLACE("channel", '-', ' '), + '_', + ' ' + ) + ) + WHERE "displayName" = '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(/* sql */ ` + ALTER TABLE "channel_highlight_definition" + DROP COLUMN "displayName" + `); + } +} diff --git a/src/schema/highlights.ts b/src/schema/highlights.ts index 765105776a..9b3b772573 100644 --- a/src/schema/highlights.ts +++ b/src/schema/highlights.ts @@ -12,8 +12,31 @@ import { PostHighlight, PostHighlightSignificance, } from '../entity/PostHighlight'; +import type { GQLSource } from './sources'; + +type GQLChannelDigestConfiguration = { + frequency: string; + source?: GQLSource | null; +}; + +type GQLChannelConfiguration = { + channel: string; + displayName: string; + digest?: GQLChannelDigestConfiguration | null; +}; export const typeDefs = /* GraphQL */ ` + type ChannelDigestConfiguration { + frequency: String! + source: Source + } + + type ChannelConfiguration { + channel: String! + displayName: String! + digest: ChannelDigestConfiguration + } + type PostHighlight { id: ID! post: Post! @@ -35,6 +58,11 @@ export const typeDefs = /* GraphQL */ ` } extend type Query { + """ + Get highlight-backed channel configuration with digest metadata + """ + channelConfigurations: [ChannelConfiguration!]! + """ Get highlights for a channel, ordered by recency """ @@ -87,6 +115,20 @@ const getDedupedMajorHeadlinesQuery = ( export const resolvers: IResolvers = { Query: { + channelConfigurations: async (_, __, ctx: Context, info) => + graphorm.query( + ctx, + info, + (builder) => { + builder.queryBuilder + .where(`"${builder.alias}"."mode" != :disabledMode`, { + disabledMode: 'disabled', + }) + .orderBy(`"${builder.alias}"."channel"`, 'ASC'); + return builder; + }, + true, + ), postHighlights: async (_, args: { channel: string }, ctx: Context, info) => graphorm.query( ctx,