Skip to content

Commit a448c57

Browse files
committed
feat(ai): serve premium insights to entitled readers
Public Yohaku reads used a hard premium block. Reuse the TTS entitlement check so owners and active members can load cached insights.
1 parent 9bcf6f7 commit a448c57

6 files changed

Lines changed: 105 additions & 6 deletions

File tree

apps/core/src/modules/ai/ai-insights/ai-insights.adapter.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CollectionRefTypes } from '~/constants/db.constant'
77
import { DatabaseService } from '~/processors/database/database.service'
88

99
import { ConfigsService } from '../../configs/configs.service'
10+
import { EntitlementService } from '../../membership/entitlement.service'
1011
import { AI_PROMPTS } from '../ai.prompts'
1112
import { AiService } from '../ai.service'
1213
import { isGlobalArticleVisible } from '../ai-article-visibility.util'
@@ -54,6 +55,7 @@ export class AiInsightsAdapter implements MultilangAdapter<
5455
private readonly configService: ConfigsService,
5556
private readonly aiService: AiService,
5657
private readonly eventEmitter: EventEmitter2,
58+
private readonly entitlementService: EntitlementService,
5759
) {}
5860

5961
toInsightsDoc(row: AiInsightsRow | null): AIInsightsModel | null {
@@ -75,7 +77,11 @@ export class AiInsightsAdapter implements MultilangAdapter<
7577

7678
async resolveArticleDetailed(
7779
articleId: string,
78-
options?: { blockPremium?: boolean },
80+
options?: {
81+
blockPremium?: boolean
82+
isOwner?: boolean
83+
readerId?: string
84+
},
7985
): Promise<{
8086
article: ArticleForInsights
8187
sourceLang: string
@@ -97,7 +103,12 @@ export class AiInsightsAdapter implements MultilangAdapter<
97103
if (
98104
options?.blockPremium &&
99105
article.type === CollectionRefTypes.Post &&
100-
(article.document as { isPremium?: boolean | null }).isPremium
106+
(await this.entitlementService.isPremiumLocked({
107+
isPremium: (article.document as { isPremium?: boolean | null })
108+
.isPremium,
109+
isOwner: Boolean(options.isOwner),
110+
readerId: options.readerId,
111+
}))
101112
) {
102113
throw createAppException(AppErrorCode.POST_HIDDEN_OR_ENCRYPTED)
103114
}

apps/core/src/modules/ai/ai-insights/ai-insights.controller.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import type { FastifyReply } from 'fastify'
1212

1313
import { ApiController } from '~/common/decorators/api-controller.decorator'
1414
import { Auth } from '~/common/decorators/auth.decorator'
15+
import { CurrentReaderId } from '~/common/decorators/current-user.decorator'
1516
import { HTTPDecorators } from '~/common/decorators/http.decorator'
17+
import { HasAdminAccess } from '~/common/decorators/role.decorator'
1618
import { AppErrorCode, createAppException } from '~/common/errors'
1719
import { withMeta } from '~/common/response/envelope.types'
1820
import { MetaObjectBuilder } from '~/common/response/meta-builder'
@@ -118,10 +120,14 @@ export class AiInsightsController {
118120
getArticleInsights(
119121
@Param() params: EntityIdDto,
120122
@Query() query: GetInsightsQueryDto,
123+
@HasAdminAccess() isOwner?: boolean,
124+
@CurrentReaderId() readerId?: string,
121125
) {
122126
return this.service.getOrGenerateInsightsForArticle(params.id, {
123127
lang: query.lang ? parseLanguageCode(query.lang) : DEFAULT_SUMMARY_LANG,
124128
onlyDb: query.onlyDb,
129+
isOwner: Boolean(isOwner),
130+
readerId,
125131
})
126132
}
127133

@@ -131,6 +137,8 @@ export class AiInsightsController {
131137
@Param() params: EntityIdDto,
132138
@Query() query: GetInsightsStreamQueryDto,
133139
@Res() reply: FastifyReply,
140+
@HasAdminAccess() isOwner?: boolean,
141+
@CurrentReaderId() readerId?: string,
134142
) {
135143
initSse(reply)
136144
let closed = false
@@ -144,6 +152,8 @@ export class AiInsightsController {
144152
lang: query.lang
145153
? parseLanguageCode(query.lang)
146154
: DEFAULT_SUMMARY_LANG,
155+
isOwner: Boolean(isOwner),
156+
readerId,
147157
},
148158
)
149159
let sentToken = false

apps/core/src/modules/ai/ai-insights/ai-insights.service.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export class AiInsightsService implements OnModuleInit {
140140

141141
async streamInsightsForArticle(
142142
articleId: string,
143-
options: { lang: string },
143+
options: { lang: string; isOwner?: boolean; readerId?: string },
144144
): Promise<{
145145
events: AsyncIterable<AiStreamEvent>
146146
result: Promise<AIInsightsModel>
@@ -151,7 +151,11 @@ export class AiInsightsService implements OnModuleInit {
151151
}
152152
const { article, sourceLang } = await this.adapter.resolveArticleDetailed(
153153
articleId,
154-
{ blockPremium: true },
154+
{
155+
blockPremium: true,
156+
isOwner: options.isOwner,
157+
readerId: options.readerId,
158+
},
155159
)
156160
const lang = options.lang || sourceLang
157161
const existing = await this.findValidInsights(articleId, lang, article.text)
@@ -169,11 +173,20 @@ export class AiInsightsService implements OnModuleInit {
169173

170174
async getOrGenerateInsightsForArticle(
171175
articleId: string,
172-
options: { lang: string; onlyDb?: boolean },
176+
options: {
177+
lang: string
178+
onlyDb?: boolean
179+
isOwner?: boolean
180+
readerId?: string
181+
},
173182
): Promise<AIInsightsModel | null> {
174183
const { article, sourceLang } = await this.adapter.resolveArticleDetailed(
175184
articleId,
176-
{ blockPremium: true },
185+
{
186+
blockPremium: true,
187+
isOwner: options.isOwner,
188+
readerId: options.readerId,
189+
},
177190
)
178191
const lang = options.lang || sourceLang
179192
const existing = await this.findValidInsights(articleId, lang, article.text)

apps/core/test/src/modules/ai/ai-insights-translation.service.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const createService = () => {
4141
configService as any,
4242
aiService as any,
4343
eventEmitter as any,
44+
{ isPremiumLocked: vi.fn(async () => false) } as any,
4445
)
4546
const multilang = new MultilangGenerationService(
4647
aiInFlightService as any,

apps/core/test/src/modules/ai/ai-insights.faux.e2e.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ function createService(runtime: PiRuntimeAdapter) {
119119
configService as any,
120120
aiService as any,
121121
eventEmitter as any,
122+
{ isPremiumLocked: vi.fn(async () => false) } as any,
122123
)
123124
const multilang = new MultilangGenerationService(
124125
aiInFlightService as any,

apps/core/test/src/modules/ai/ai-insights.service.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ const createService = () => {
5656
createInsightsTranslationTask: vi.fn(),
5757
}
5858
const eventEmitter = { emit: vi.fn() }
59+
const entitlementService = {
60+
isPremiumLocked: vi.fn(
61+
async (input: {
62+
isPremium?: boolean | null
63+
isOwner: boolean
64+
readerId?: string
65+
}) => Boolean(input.isPremium) && !input.isOwner && !input.readerId,
66+
),
67+
}
5968
const generationMetrics = {
6069
attachLatest: vi.fn(async (_type: string, items: unknown[]) =>
6170
items.map((item) => ({
@@ -72,6 +81,7 @@ const createService = () => {
7281
configService as any,
7382
aiService as any,
7483
eventEmitter as any,
84+
entitlementService as any,
7585
)
7686
const multilang = new MultilangGenerationService(
7787
aiInFlightService as any,
@@ -94,6 +104,7 @@ const createService = () => {
94104
aiTaskService,
95105
configService,
96106
databaseService,
107+
entitlementService,
97108
eventEmitter,
98109
generationMetrics,
99110
repository,
@@ -270,6 +281,58 @@ describe('AiInsightsService', () => {
270281
).rejects.toThrow(AppException)
271282
})
272283

284+
it('serves cached insights for a premium post to an entitled reader', async () => {
285+
const { databaseService, repository, service } = createService()
286+
databaseService.findGlobalById.mockResolvedValue({
287+
type: CollectionRefTypes.Post,
288+
document: {
289+
id: 'post-1',
290+
title: 'Premium Post',
291+
text: 'Premium text',
292+
isPublished: true,
293+
isPremium: true,
294+
},
295+
})
296+
repository.findByRefAndLang.mockResolvedValue({
297+
...row,
298+
hash: 'mismatch-so-onlyDb-returns-null',
299+
} as any)
300+
301+
await expect(
302+
service.getOrGenerateInsightsForArticle('post-1', {
303+
lang: 'zh',
304+
onlyDb: true,
305+
readerId: 'reader-1',
306+
}),
307+
).resolves.toBeNull()
308+
})
309+
310+
it('serves cached insights for a premium post to the owner', async () => {
311+
const { databaseService, repository, service } = createService()
312+
databaseService.findGlobalById.mockResolvedValue({
313+
type: CollectionRefTypes.Post,
314+
document: {
315+
id: 'post-1',
316+
title: 'Premium Post',
317+
text: 'Premium text',
318+
isPublished: true,
319+
isPremium: true,
320+
},
321+
})
322+
repository.findByRefAndLang.mockResolvedValue({
323+
...row,
324+
hash: 'mismatch-so-onlyDb-returns-null',
325+
} as any)
326+
327+
await expect(
328+
service.getOrGenerateInsightsForArticle('post-1', {
329+
lang: 'zh',
330+
onlyDb: true,
331+
isOwner: true,
332+
}),
333+
).resolves.toBeNull()
334+
})
335+
273336
it('does not block background insight regeneration for a premium post', async () => {
274337
const {
275338
aiTaskService,

0 commit comments

Comments
 (0)