Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
114 changes: 113 additions & 1 deletion __tests__/highlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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, {
Expand Down
18 changes: 18 additions & 0 deletions src/schema/highlights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -217,6 +222,19 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
},
true,
),
channelDigestConfigurations: async (_, __, ctx: Context, info) =>
graphorm.query<GQLChannelDigestConfiguration>(
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,
Expand Down
Loading