diff --git a/AGENTS.md b/AGENTS.md index 6581d02a85..c1666163c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,7 @@ The migration generator compares entities against the local database schema. Ens - `src/directive/` - Custom GraphQL directives for auth, rate limiting, URL processing - **Docs**: See `src/graphorm/AGENTS.md` for comprehensive guide on using GraphORM to solve N+1 queries. GraphORM is the default and preferred method for all GraphQL query responses. Use GraphORM instead of TypeORM repositories for GraphQL resolvers to prevent N+1 queries and enforce best practices. - **GraphORM mappings**: Only add entries in `src/graphorm/index.ts` when you need custom mapping/fields/transforms or GraphQL type names differ from TypeORM entity names. For straightforward reads, keep GraphQL type names aligned with entities and use GraphORM without extra config. +- **One GraphQL type per entity**: Before adding a new GraphQL type (and its GraphORM mapping) for an entity, check whether a type already maps `from` that entity. If so, reuse or extend that single type instead of introducing a parallel type with the same relations — duplicate types backed by the same entity (e.g. a `FooConfiguration` and a `Foo` both `from: 'Foo'`) should be consolidated. Prefer the entity-aligned name and point any nested field at it. - For GraphQL query resolvers, prefer `graphorm.query`, `graphorm.queryOne`, or `graphorm.queryPaginated` over custom TypeORM fetch/pagination code whenever GraphORM can express the query. Reach for manual TypeORM reads only when GraphORM genuinely cannot support the access pattern. - When adding subscriptions for GraphORM-backed types, prefer publishing a payload that already matches the existing GraphQL object shape instead of adding fallback field resolvers that override the normal GraphORM query path. If you must add a field resolver, preserve already-hydrated fields from query results. - When a GraphQL field must be nulled based on viewer permissions, define the rule in `src/graphorm/index.ts` as a shared transform/helper (for example `nullIfNotLoggedIn`). If a resolver cannot use GraphORM for the full query, reuse that GraphORM field mapping from the manual path instead of re-implementing the permission rule in a schema field resolver. diff --git a/__tests__/highlights.ts b/__tests__/highlights.ts index 925e1c5c1f..7730299866 100644 --- a/__tests__/highlights.ts +++ b/__tests__/highlights.ts @@ -93,7 +93,15 @@ beforeEach(async () => { await con.getRepository(ArticlePost).delete(['h1', 'h2', 'h3', 'h4']); await con .getRepository(Source) - .delete(['a', 'b', 'c', 'backend_digest', 'career_digest']); + .delete([ + 'a', + 'b', + 'c', + 'backend_digest', + 'backend_digest_a', + 'backend_digest_b', + 'career_digest', + ]); }); const saveCanonicalHighlights = ( @@ -269,6 +277,110 @@ describe('query channelConfigurations', () => { }); }); +const CHANNEL_DIGEST_CONFIGURATIONS_QUERY = ` + query ChannelDigestConfigurations { + channelDigestConfigurations { + frequency + source { + id + name + handle + } + } + } +`; + +describe('query channelDigestConfigurations', () => { + const saveDigestSource = (id: string, name: string) => + con.getRepository(Source).save({ + id, + name, + image: `https://example.com/${id}.png`, + handle: id, + type: SourceType.Machine, + active: true, + private: false, + }); + + it('should return empty array when no digests exist', async () => { + const res = await client.query(CHANNEL_DIGEST_CONFIGURATIONS_QUERY); + + expect(res.errors).toBeFalsy(); + expect(res.data.channelDigestConfigurations).toEqual([]); + }); + + it('should return enabled digests ordered by channel and key with source resolved, excluding disabled', async () => { + await saveDigestSource('backend_digest_a', 'Backend Digest A'); + await saveDigestSource('backend_digest_b', 'Backend Digest B'); + await saveDigestSource('career_digest', 'Career Digest'); + + await con.getRepository(ChannelDigest).save([ + { + key: 'career-digest', + channel: 'career', + sourceId: 'career_digest', + targetAudience: 'career changers', + frequency: 'weekly', + enabled: true, + }, + { + key: 'backend-b', + channel: 'backend', + sourceId: 'backend_digest_b', + targetAudience: 'backend developers', + frequency: 'daily', + enabled: true, + }, + { + key: 'backend-a', + channel: 'backend', + sourceId: 'backend_digest_a', + targetAudience: 'backend developers', + frequency: 'daily', + enabled: true, + }, + { + key: 'backend-disabled', + channel: 'backend', + sourceId: 'backend_digest_disabled', + targetAudience: 'backend developers', + frequency: 'daily', + enabled: false, + }, + ]); + + const res = await client.query(CHANNEL_DIGEST_CONFIGURATIONS_QUERY); + + expect(res.errors).toBeFalsy(); + expect(res.data.channelDigestConfigurations).toEqual([ + { + frequency: 'daily', + source: { + id: 'backend_digest_a', + name: 'Backend Digest A', + handle: 'backend_digest_a', + }, + }, + { + frequency: 'daily', + source: { + id: 'backend_digest_b', + name: 'Backend Digest B', + handle: 'backend_digest_b', + }, + }, + { + frequency: 'weekly', + source: { + id: 'career_digest', + name: 'Career Digest', + handle: 'career_digest', + }, + }, + ]); + }); +}); + describe('query postHighlights', () => { it('should return empty array when no highlights exist', async () => { const res = await client.query(QUERY, { diff --git a/src/schema/highlights.ts b/src/schema/highlights.ts index 045bc54b9d..c9683f9b86 100644 --- a/src/schema/highlights.ts +++ b/src/schema/highlights.ts @@ -90,6 +90,11 @@ export const typeDefs = /* GraphQL */ ` """ channelConfigurations: [ChannelConfiguration!]! + """ + Get all enabled channel digest configurations, ordered by channel and key + """ + channelDigestConfigurations: [ChannelDigestConfiguration!]! + """ Get highlights for a channel, ordered by recency """ @@ -217,6 +222,19 @@ export const resolvers: IResolvers = { }, true, ), + channelDigestConfigurations: async (_, __, ctx: Context, info) => + graphorm.query( + ctx, + info, + (builder) => { + builder.queryBuilder + .where(`"${builder.alias}"."enabled" = true`) + .orderBy(`"${builder.alias}"."channel"`, 'ASC') + .addOrderBy(`"${builder.alias}"."key"`, 'ASC'); + return builder; + }, + true, + ), postHighlights: async (_, args: { channel: string }, ctx: Context, info) => graphorm.query( ctx,