From 68e61d46a17b0d2b56c3ca7fd1816bb3f21b392d Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:12:25 +0300 Subject: [PATCH] fix(highlights): exclude deleted/invisible posts from highlight feeds Highlights point at soft-deleted posts (deleted=true, row kept), so the FK ON DELETE CASCADE never fires and the highlight survives. GraphORM resolves the non-null post field with a deleted=false AND visible=true filter, so the post subquery returns null and the feed throws "Unexpected error". Add a correlated EXISTS filter requiring the post to exist, be visible and not deleted, applied to postHighlightsFeed, majorHeadlines and postHighlights. Since GraphORM emits the nested post as a correlated subquery in the same statement, filtering the outer rows on the same predicate is race-free. --- __tests__/highlights.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/schema/highlights.ts | 20 +++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/__tests__/highlights.ts b/__tests__/highlights.ts index 2e5c9aa23d..2db6f0a8d5 100644 --- a/__tests__/highlights.ts +++ b/__tests__/highlights.ts @@ -1156,6 +1156,44 @@ describe('query postHighlightsFeed', () => { ]); }); + it('should exclude highlights whose post is deleted or not visible', async () => { + await createTestPosts(); + await con.getRepository(ArticlePost).update('h2', { deleted: true }); + await con.getRepository(ArticlePost).update('h3', { visible: false }); + await saveCanonicalHighlights([ + { + postId: 'h1', + channel: 'vibes', + highlightedAt: new Date('2026-03-19T10:40:00.000Z'), + headline: 'Visible headline', + significance: HighlightSignificance.Breaking, + }, + { + postId: 'h2', + channel: 'vibes', + highlightedAt: new Date('2026-03-19T10:35:00.000Z'), + headline: 'Deleted post headline', + significance: HighlightSignificance.Notable, + }, + { + postId: 'h3', + channel: 'agentic', + highlightedAt: new Date('2026-03-19T10:30:00.000Z'), + headline: 'Invisible post headline', + significance: HighlightSignificance.Routine, + }, + ]); + + const res = await client.query(POST_HIGHLIGHTS_FEED_QUERY, { + variables: { first: 10 }, + }); + + expect(res.errors).toBeFalsy(); + expect( + res.data.postHighlightsFeed.edges.map(({ node }) => node.post.id), + ).toEqual(['h1']); + }); + it('should filter by channel', async () => { await createTestPosts(); await saveCanonicalHighlights([ diff --git a/src/schema/highlights.ts b/src/schema/highlights.ts index 741160b7eb..1260ce2be2 100644 --- a/src/schema/highlights.ts +++ b/src/schema/highlights.ts @@ -171,6 +171,20 @@ type HighlightsFilters = { significances?: HighlightSignificance[]; }; +const applyVisiblePostFilter = ( + builder: SelectQueryBuilder, + alias: string, +): SelectQueryBuilder => + builder.andWhere( + `EXISTS ( + SELECT 1 + FROM "post" p + WHERE p."id" = "${alias}"."postId" + AND p."deleted" = false + AND p."visible" = true + )`, + ); + const applyHighlightsFilters = ( builder: SelectQueryBuilder, alias: string, @@ -188,7 +202,7 @@ const applyHighlightsFilters = ( builder.andWhere(`:highlightChannel = ANY("${alias}"."channels")`); } - return builder; + return applyVisiblePostFilter(builder, alias); }; const resolveCanonicalHighlightsFeed = ( @@ -276,6 +290,10 @@ export const resolvers: IResolvers = { }) .orderBy(`"${builder.alias}"."highlightedAt"`, 'DESC') .addOrderBy(`"${builder.alias}"."id"`, 'DESC'); + applyVisiblePostFilter( + builder.queryBuilder as SelectQueryBuilder, + builder.alias, + ); return builder; }, true,