Skip to content

Commit 0aaf43a

Browse files
committed
feat(daily): return digest posts for highlights
1 parent ff79078 commit 0aaf43a

2 files changed

Lines changed: 115 additions & 153 deletions

File tree

__tests__/highlights.ts

Lines changed: 76 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from './helpers';
1111
import createOrGetConnection from '../src/db';
1212
import { ArticlePost } from '../src/entity/posts/ArticlePost';
13+
import { FreeformPost } from '../src/entity/posts/FreeformPost';
1314
import { ChannelDigest } from '../src/entity/ChannelDigest';
1415
import { ChannelHighlightDefinition } from '../src/entity/ChannelHighlightDefinition';
1516
import { HighlightsCanonical } from '../src/entity/HighlightsCanonical';
@@ -303,9 +304,9 @@ const CHANNEL_DIGEST_CONFIGURATIONS_QUERY = `
303304
}
304305
`;
305306

306-
const DAILY_HIGHLIGHTS_QUERY = `
307-
query DailyHighlights($first: Int, $after: String) {
308-
dailyHighlights(first: $first, after: $after) {
307+
const DAILY_HEADLINES_QUERY = `
308+
query DailyHeadlines($first: Int, $after: String) {
309+
dailyHeadlines(first: $first, after: $after) {
309310
pageInfo {
310311
hasNextPage
311312
endCursor
@@ -314,21 +315,14 @@ const DAILY_HIGHLIGHTS_QUERY = `
314315
cursor
315316
node {
316317
id
317-
channel
318-
highlightedAt
319-
headline
320-
significance
321-
post {
322-
id
323-
title
324-
}
318+
title
325319
}
326320
}
327321
}
328322
}
329323
`;
330324

331-
describe('query dailyHighlights', () => {
325+
describe('query dailyHeadlines', () => {
332326
const saveDigestSource = (id: string) =>
333327
con.getRepository(Source).save({
334328
id,
@@ -340,6 +334,38 @@ describe('query dailyHighlights', () => {
340334
private: false,
341335
});
342336

337+
const saveChannelDigest = (key: string, sourceId: string, channel: string) =>
338+
con.getRepository(ChannelDigest).save({
339+
key,
340+
sourceId,
341+
channel,
342+
targetAudience: 'devs',
343+
frequency: 'daily',
344+
enabled: true,
345+
});
346+
347+
const saveDigestPost = (
348+
id: string,
349+
sourceId: string,
350+
title: string,
351+
createdAt: Date,
352+
) =>
353+
con.getRepository(FreeformPost).save({
354+
id,
355+
shortId: id,
356+
sourceId,
357+
type: PostType.Freeform,
358+
title,
359+
content: title,
360+
contentHtml: `<p>${title}</p>`,
361+
visible: true,
362+
visibleAt: createdAt,
363+
createdAt,
364+
metadataChangedAt: createdAt,
365+
showOnFeed: true,
366+
private: false,
367+
});
368+
343369
const subscribeToDigest = (sourceId: string) =>
344370
con.getRepository(NotificationPreferenceSource).save({
345371
userId: '1',
@@ -351,126 +377,72 @@ describe('query dailyHighlights', () => {
351377

352378
beforeEach(async () => {
353379
await saveFixtures(con, User, usersFixture);
354-
await createTestPosts();
355380
});
356381

357382
it('should require authentication', () =>
358383
testQueryErrorCode(
359384
client,
360-
{ query: DAILY_HIGHLIGHTS_QUERY },
385+
{ query: DAILY_HEADLINES_QUERY },
361386
'UNAUTHENTICATED',
362387
));
363388

364-
it('should return the top highlight of the day per subscribed channel', async () => {
389+
it('should return the latest digest post per subscribed channel', async () => {
365390
loggedUser = '1';
366391

367392
await saveDigestSource('backend_digest');
368393
await saveDigestSource('career_digest');
369-
await con.getRepository(ChannelDigest).save([
370-
{
371-
key: 'backend',
372-
sourceId: 'backend_digest',
373-
channel: 'backend',
374-
targetAudience: 'backend devs',
375-
frequency: 'daily',
376-
enabled: true,
377-
},
378-
{
379-
key: 'career',
380-
sourceId: 'career_digest',
381-
channel: 'career',
382-
targetAudience: 'everyone',
383-
frequency: 'daily',
384-
enabled: true,
385-
},
386-
]);
394+
await saveDigestSource('backend_digest_b');
395+
await saveChannelDigest('backend', 'backend_digest', 'backend');
396+
await saveChannelDigest('career', 'career_digest', 'career');
397+
await saveChannelDigest('backendb', 'backend_digest_b', 'backendb');
387398
await subscribeToDigest('backend_digest');
388399
await subscribeToDigest('career_digest');
389400

390-
const today = new Date();
391-
today.setUTCHours(12, 0, 0, 0);
392-
const earlierToday = new Date(today.getTime() - 60 * 60 * 1000);
393-
const yesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
394-
395-
await saveCanonicalHighlights([
396-
{
397-
id: '11111111-1111-1111-1111-111111111111',
398-
postId: 'h1',
399-
channel: 'backend',
400-
highlightedAt: today,
401-
headline: 'Backend major',
402-
significance: HighlightSignificance.Major,
403-
},
404-
{
405-
id: '22222222-2222-2222-2222-222222222222',
406-
postId: 'h2',
407-
channel: 'backend',
408-
highlightedAt: earlierToday,
409-
headline: 'Backend breaking',
410-
significance: HighlightSignificance.Breaking,
411-
},
412-
{
413-
id: '33333333-3333-3333-3333-333333333333',
414-
postId: 'h3',
415-
channel: 'career',
416-
highlightedAt: today,
417-
headline: 'Career notable',
418-
significance: HighlightSignificance.Notable,
419-
},
420-
{
421-
id: '44444444-4444-4444-4444-444444444444',
422-
postId: 'h4',
423-
channel: 'backend',
424-
highlightedAt: yesterday,
425-
headline: 'Backend stale breaking',
426-
significance: HighlightSignificance.Breaking,
427-
},
428-
]);
401+
await saveDigestPost(
402+
'bd-old',
403+
'backend_digest',
404+
'Backend old',
405+
new Date('2026-06-19T08:00:00.000Z'),
406+
);
407+
await saveDigestPost(
408+
'bd-new',
409+
'backend_digest',
410+
'Backend latest',
411+
new Date('2026-06-19T12:00:00.000Z'),
412+
);
413+
await saveDigestPost(
414+
'career-d',
415+
'career_digest',
416+
'Career latest',
417+
new Date('2026-06-19T10:00:00.000Z'),
418+
);
419+
await saveDigestPost(
420+
'bdb-d',
421+
'backend_digest_b',
422+
'Unsubscribed',
423+
new Date('2026-06-19T13:00:00.000Z'),
424+
);
429425

430-
const res = await client.query(DAILY_HIGHLIGHTS_QUERY);
426+
const res = await client.query(DAILY_HEADLINES_QUERY);
431427

432428
expect(res.errors).toBeFalsy();
433-
// backend → breaking (beats today's major); career → notable; stale excluded.
434-
// ordered by highlightedAt desc: career @12:00 then backend @11:00.
435-
expect(
436-
res.data.dailyHighlights.edges.map(({ node }) => ({
437-
channel: node.channel,
438-
headline: node.headline,
439-
postId: node.post.id,
440-
})),
441-
).toEqual([
442-
{ channel: 'career', headline: 'Career notable', postId: 'h3' },
443-
{ channel: 'backend', headline: 'Backend breaking', postId: 'h2' },
429+
expect(res.data.dailyHeadlines.edges.map(({ node }) => node.id)).toEqual([
430+
'bd-new',
431+
'career-d',
444432
]);
445433
});
446434

447435
it('should return empty when the user has no subscriptions', async () => {
448436
loggedUser = '1';
449437

450438
await saveDigestSource('backend_digest');
451-
await con.getRepository(ChannelDigest).save({
452-
key: 'backend',
453-
sourceId: 'backend_digest',
454-
channel: 'backend',
455-
targetAudience: 'backend devs',
456-
frequency: 'daily',
457-
enabled: true,
458-
});
459-
await saveCanonicalHighlights([
460-
{
461-
id: '55555555-5555-5555-5555-555555555555',
462-
postId: 'h1',
463-
channel: 'backend',
464-
highlightedAt: new Date(),
465-
headline: 'Backend today',
466-
significance: HighlightSignificance.Major,
467-
},
468-
]);
439+
await saveChannelDigest('backend', 'backend_digest', 'backend');
440+
await saveDigestPost('bd-1', 'backend_digest', 'Backend', new Date());
469441

470-
const res = await client.query(DAILY_HIGHLIGHTS_QUERY);
442+
const res = await client.query(DAILY_HEADLINES_QUERY);
471443

472444
expect(res.errors).toBeFalsy();
473-
expect(res.data.dailyHighlights.edges).toEqual([]);
445+
expect(res.data.dailyHeadlines.edges).toEqual([]);
474446
});
475447
});
476448

src/schema/highlights.ts

Lines changed: 39 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
NotificationType,
2525
} from '../notifications/common';
2626
import { queryReadReplica } from '../common/queryReadReplica';
27-
import { isMockEnabled } from '../mocks/common';
27+
import { Post, PostType } from '../entity/posts/Post';
2828

2929
type GQLChannelDigestConfiguration = {
3030
frequency: string;
@@ -128,11 +128,11 @@ export const typeDefs = /* GraphQL */ `
128128
): PostHighlightConnection!
129129
130130
"""
131-
Get the top highlight of the current day for each channel digest the
132-
current user is subscribed to (via source post-added notifications),
133-
one highlight per channel, ordered by recency.
131+
Get the latest digest post for each channel digest the current user is
132+
subscribed to (via source post-added notifications), one per channel,
133+
ordered by recency.
134134
"""
135-
dailyHighlights(first: Int, after: String): PostHighlightConnection! @auth
135+
dailyHeadlines(first: Int, after: String): PostConnection! @auth
136136
}
137137
138138
extend type Subscription {
@@ -281,52 +281,44 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
281281
channel: args.channel,
282282
significances: parseSignificanceFilters(args.significance),
283283
}),
284-
dailyHighlights: async (
284+
dailyHeadlines: async (
285285
_,
286286
args: ConnectionArguments,
287287
ctx: AuthContext,
288288
info,
289289
) => {
290-
const startOfDay = new Date();
291-
startOfDay.setUTCHours(0, 0, 0, 0);
292-
// Locally (mock mode) ignore the current-day window so seeded highlights
293-
// with past dates still surface and the query always returns something.
294-
const since = isMockEnabled() ? new Date(0) : startOfDay;
295-
296-
// Top highlight of the day per subscribed channel: dedupe to one row per
297-
// channel ordered by significance (Unspecified last), then recency.
298-
const topPerChannel = await queryReadReplica(ctx.con, ({ queryRunner }) =>
299-
queryRunner.manager
300-
.getRepository(NotificationPreferenceSource)
301-
.createQueryBuilder('np')
302-
.select('h.id', 'id')
303-
.innerJoin(
304-
ChannelDigest,
305-
'cd',
306-
'cd."sourceId" = np."sourceId" AND cd.enabled = true',
307-
)
308-
.innerJoin(
309-
HighlightsCanonical,
310-
'h',
311-
'cd.channel = ANY(h.channels) AND h."highlightedAt" >= :since',
312-
{ since },
313-
)
314-
.where('np."userId" = :userId', { userId: ctx.userId })
315-
.andWhere('np."notificationType" = :notificationType', {
316-
notificationType: NotificationType.SourcePostAdded,
317-
})
318-
.andWhere('np.status = :status', {
319-
status: NotificationPreferenceStatus.Subscribed,
320-
})
321-
.distinctOn(['cd.channel'])
322-
.orderBy('cd.channel', 'ASC')
323-
.addOrderBy('(h.significance = 0)', 'ASC')
324-
.addOrderBy('h.significance', 'ASC')
325-
.addOrderBy('h."highlightedAt"', 'DESC')
326-
.getRawMany<{ id: string }>(),
290+
const latestDigestPerChannel = await queryReadReplica(
291+
ctx.con,
292+
({ queryRunner }) =>
293+
queryRunner.manager
294+
.getRepository(NotificationPreferenceSource)
295+
.createQueryBuilder('np')
296+
.select('p.id', 'id')
297+
.innerJoin(
298+
ChannelDigest,
299+
'cd',
300+
'cd."sourceId" = np."sourceId" AND cd.enabled = true',
301+
)
302+
.innerJoin(
303+
Post,
304+
'p',
305+
'p."sourceId" = cd."sourceId" AND p.type = :postType AND p.visible = true AND p.deleted = false',
306+
{ postType: PostType.Freeform },
307+
)
308+
.where('np."userId" = :userId', { userId: ctx.userId })
309+
.andWhere('np."notificationType" = :notificationType', {
310+
notificationType: NotificationType.SourcePostAdded,
311+
})
312+
.andWhere('np.status = :status', {
313+
status: NotificationPreferenceStatus.Subscribed,
314+
})
315+
.distinctOn(['cd."sourceId"'])
316+
.orderBy('cd."sourceId"', 'ASC')
317+
.addOrderBy('p."createdAt"', 'DESC')
318+
.getRawMany<{ id: string }>(),
327319
);
328320

329-
const highlightIds = topPerChannel.map((row) => row.id);
321+
const postIds = latestDigestPerChannel.map((row) => row.id);
330322
const page = getHighlightsPage(args);
331323

332324
return graphorm.queryPaginated(
@@ -337,12 +329,10 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
337329
(_, index) => offsetToCursor(page.offset + index),
338330
(builder) => {
339331
builder.queryBuilder
340-
.where(`"${builder.alias}"."id" IN (:...highlightIds)`, {
341-
highlightIds: highlightIds.length
342-
? highlightIds
343-
: ['00000000-0000-0000-0000-000000000000'],
332+
.where(`"${builder.alias}"."id" IN (:...postIds)`, {
333+
postIds: postIds.length ? postIds : ['nosuchid'],
344334
})
345-
.orderBy(`"${builder.alias}"."highlightedAt"`, 'DESC')
335+
.orderBy(`"${builder.alias}"."createdAt"`, 'DESC')
346336
.addOrderBy(`"${builder.alias}"."id"`, 'DESC')
347337
.offset(page.offset)
348338
.limit(page.limit);

0 commit comments

Comments
 (0)