Skip to content

Commit c5bea4b

Browse files
committed
feat(core): stream persistable article bodies as NDJSON
Add POST /articles/bodies so clients can batch-fetch post and note bodies with paywall, language, and unchanged-version lines.
1 parent 70c524f commit c5bea4b

16 files changed

Lines changed: 837 additions & 10 deletions

apps/core/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { ActivityModule } from './modules/activity/activity.module'
2525
import { AggregateModule } from './modules/aggregate/aggregate.module'
2626
import { AiModule } from './modules/ai/ai.module'
2727
import { AnalyzeModule } from './modules/analyze/analyze.module'
28+
import { ArticleBodyModule } from './modules/article-body/article-body.module'
2829
import { AuthModule } from './modules/auth/auth.module'
2930
import { ReviewDemoModule } from './modules/auth/review-demo.module'
3031
import { BackupModule } from './modules/backup/backup.module'
@@ -92,6 +93,7 @@ import { SampleResponseInterceptor } from './shared/sample/sample-response.inter
9293
AckModule,
9394
ActivityModule,
9495
AggregateModule,
96+
ArticleBodyModule,
9597
AnalyzeModule,
9698
EnrichmentModule,
9799
AuthModule.forRoot(),

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { ConfigsService } from '../../configs/configs.service'
1010
import { EntitlementService } from '../../membership/entitlement.service'
1111
import { AI_PROMPTS } from '../ai.prompts'
1212
import { AiService } from '../ai.service'
13-
import { isGlobalArticleVisible } from '../ai-article-visibility.util'
13+
import {
14+
type ArticleViewer,
15+
isArticleVisibleToViewer,
16+
} from '../ai-article-visibility.util'
1417
import type { GenerationUsage } from '../ai-generation-metrics/ai-generation-metrics.types'
1518
import {
1619
emptyUsage,
@@ -97,7 +100,7 @@ export class AiInsightsAdapter implements MultilangAdapter<
97100
) {
98101
throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS)
99102
}
100-
if (!isGlobalArticleVisible(article)) {
103+
if (!isArticleVisibleToViewer(article, options ?? {})) {
101104
throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS)
102105
}
103106
if (
@@ -127,8 +130,12 @@ export class AiInsightsAdapter implements MultilangAdapter<
127130

128131
async resolveArticle(
129132
refId: string,
133+
viewer?: ArticleViewer,
130134
): Promise<MultilangResolvedArticle<ArticleForInsights>> {
131-
const { article, sourceLang } = await this.resolveArticleDetailed(refId)
135+
const { article, sourceLang } = await this.resolveArticleDetailed(
136+
refId,
137+
viewer,
138+
)
132139
return { article, text: article.text, sourceLang }
133140
}
134141

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ export class MultilangGenerationService {
208208

209209
const { article, text, sourceLang } = await adapter.resolveArticle(
210210
payload.refId,
211+
{ isOwner: true },
211212
)
212213
const targets = normalizeTargetLangs(payload.targetLanguages).filter(
213214
(lang) => lang !== sourceLang,

apps/core/src/modules/ai/ai-multilang/ai-multilang.types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ArticleViewer } from '../ai-article-visibility.util'
12
import type { GenerationUsage } from '../ai-generation-metrics/ai-generation-metrics.types'
23
import type { AiStreamEvent } from '../ai-inflight/ai-inflight.types'
34

@@ -30,7 +31,10 @@ export interface MultilangResolvedArticle<TArticle> {
3031
export interface MultilangAdapter<TArticle, TDoc> {
3132
readonly feature: MultilangFeature
3233
assertEnabled: () => Promise<void>
33-
resolveArticle: (refId: string) => Promise<MultilangResolvedArticle<TArticle>>
34+
resolveArticle: (
35+
refId: string,
36+
viewer?: ArticleViewer,
37+
) => Promise<MultilangResolvedArticle<TArticle>>
3438
generate: (
3539
article: TArticle,
3640
lang: string,

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import { DatabaseService } from '~/processors/database/database.service'
1010
import { ConfigsService } from '../../configs/configs.service'
1111
import { AI_PROMPTS } from '../ai.prompts'
1212
import { AiService } from '../ai.service'
13-
import { isGlobalArticleVisible } from '../ai-article-visibility.util'
13+
import {
14+
type ArticleViewer,
15+
isArticleVisibleToViewer,
16+
} from '../ai-article-visibility.util'
1417
import type { GenerationUsage } from '../ai-generation-metrics/ai-generation-metrics.types'
1518
import {
1619
emptyUsage,
@@ -71,7 +74,10 @@ export class AiSummaryAdapter implements MultilangAdapter<
7174
}
7275
}
7376

74-
async resolveArticleDetailed(articleId: string): Promise<{
77+
async resolveArticleDetailed(
78+
articleId: string,
79+
viewer: ArticleViewer = {},
80+
): Promise<{
7581
article: ArticleForSummary
7682
sourceLang: string
7783
type: CollectionRefTypes.Post | CollectionRefTypes.Note
@@ -86,9 +92,7 @@ export class AiSummaryAdapter implements MultilangAdapter<
8692
) {
8793
throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS)
8894
}
89-
// Never expose summaries for draft / password-protected / future-dated
90-
// content. Public endpoints and background tasks both flow through here.
91-
if (!isGlobalArticleVisible(article)) {
95+
if (!isArticleVisibleToViewer(article, viewer)) {
9296
throw createAppException(AppErrorCode.CONTENT_NOT_FOUND_CANT_PROCESS)
9397
}
9498
const doc = article.document as { title: string; text: string }
@@ -103,8 +107,12 @@ export class AiSummaryAdapter implements MultilangAdapter<
103107

104108
async resolveArticle(
105109
refId: string,
110+
viewer?: ArticleViewer,
106111
): Promise<MultilangResolvedArticle<ArticleForSummary>> {
107-
const { article, sourceLang } = await this.resolveArticleDetailed(refId)
112+
const { article, sourceLang } = await this.resolveArticleDetailed(
113+
refId,
114+
viewer,
115+
)
108116
return { article, text: article.text, sourceLang }
109117
}
110118

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Body, Post, Req, Res } from '@nestjs/common'
2+
import type { FastifyReply, FastifyRequest } from 'fastify'
3+
4+
import { ApiController } from '~/common/decorators/api-controller.decorator'
5+
import { CurrentReaderId } from '~/common/decorators/current-user.decorator'
6+
import { HTTPDecorators } from '~/common/decorators/http.decorator'
7+
import { Lang } from '~/common/decorators/lang.decorator'
8+
import { HasAdminAccess } from '~/common/decorators/role.decorator'
9+
import { applyNdjsonHeaders, subscribeNdjson } from '~/utils/ndjson.util'
10+
11+
import { ArticleBodiesDto } from './article-body.schema'
12+
import { ArticleBodyService } from './article-body.service'
13+
14+
@ApiController('articles')
15+
export class ArticleBodyController {
16+
constructor(private readonly articleBodyService: ArticleBodyService) {}
17+
18+
@Post('/bodies')
19+
@HTTPDecorators.RawResponse
20+
@HTTPDecorators.SkipLogging
21+
async streamBodies(
22+
@Body() body: ArticleBodiesDto,
23+
@Lang() lang: string | undefined,
24+
@HasAdminAccess() isOwner: boolean,
25+
@CurrentReaderId() readerId: string | undefined,
26+
@Req() request: FastifyRequest,
27+
@Res() reply: FastifyReply,
28+
) {
29+
applyNdjsonHeaders(reply, request)
30+
await subscribeNdjson(
31+
reply,
32+
this.articleBodyService.streamBodies(body.items, {
33+
isOwner,
34+
lang,
35+
readerId,
36+
}),
37+
)
38+
}
39+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Module } from '@nestjs/common'
2+
3+
import { MembershipModule } from '../membership/membership.module'
4+
import { NoteModule } from '../note/note.module'
5+
import { PostModule } from '../post/post.module'
6+
import { ArticleBodyController } from './article-body.controller'
7+
import { ArticleBodyService } from './article-body.service'
8+
9+
@Module({
10+
imports: [PostModule, NoteModule, MembershipModule],
11+
controllers: [ArticleBodyController],
12+
providers: [ArticleBodyService],
13+
})
14+
export class ArticleBodyModule {}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createZodDto } from 'nestjs-zod'
2+
import { z } from 'zod'
3+
4+
import { zEntityId } from '~/common/zod'
5+
6+
export const ARTICLE_BODY_BATCH_LIMIT = 20
7+
8+
export const ArticleBodyItemSchema = z.object({
9+
bodyVersion: z.number().int().nonnegative().optional(),
10+
id: zEntityId,
11+
kind: z.enum(['note', 'post']),
12+
})
13+
14+
export const ArticleBodiesSchema = z.object({
15+
items: z.array(ArticleBodyItemSchema).min(1).max(ARTICLE_BODY_BATCH_LIMIT),
16+
})
17+
18+
export class ArticleBodiesDto extends createZodDto(ArticleBodiesSchema) {}
19+
20+
export type ArticleBodyItem = z.infer<typeof ArticleBodyItemSchema>

0 commit comments

Comments
 (0)