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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ dist
.swc/
*.tsbuildinfo
apps/api/src/generated/
packages/api-contract/src/generated/

# Tool caches
.eslintcache
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
.codex/
.omx/
.turbo/
packages/api-contract/src/generated/
pnpm-lock.yaml
tmp/
38 changes: 26 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,29 @@
## Repository Structure

Repo = Turborepo monorepo.
Preserve monorepo shape. Do not break workspace layout, package boundaries, shared config, task graph, repo conventions.
Keep monorepo shape.
No break workspace layout, package boundaries, shared config, task graph, repo conventions.
Comment on lines +6 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify policy sentences to avoid ambiguous enforcement language.

Several updated lines use terse grammar (“No break…”, “No add…”, “No make…”), which weakens interpretation consistency for contributors and automation-facing guidance.

✍️ Proposed wording cleanup
-Keep monorepo shape.
-No break workspace layout, package boundaries, shared config, task graph, repo conventions.
+Keep the monorepo shape intact.
+Do not break workspace layout, package boundaries, shared config, task graph, or repo conventions.

-No move package responsibility to repo root.
+Do not move package responsibility to the repo root.

-If none fits perfect, use closest existing category.
-No add new category unless both language trees expand on purpose.
+If none fits perfectly, use the closest existing category.
+Do not add a new category unless both language trees are intentionally expanded.

-  - In `apps/web`, prefer `src/app`, `src/widgets`, `src/features`, `src/entities`, `src/shared` for business structure. If page-scoped business slice needed, keep under `src/app` or `src/widgets` with route-aligned name. No add `src/pages`.
-  - Keep root `app/` thin: route entrypoints, top-level providers, metadata wiring, route handlers, minimal bridge code only. No make root `app/` main home for reusable business slices.
+  - In `apps/web`, prefer `src/app`, `src/widgets`, `src/features`, `src/entities`, and `src/shared` for business structure. If a page-scoped business slice is needed, keep it under `src/app` or `src/widgets` with a route-aligned name. Do not add `src/pages`.
+  - Keep root `app/` thin: route entrypoints, top-level providers, metadata wiring, route handlers, and minimal bridge code only. Do not make root `app/` the main home for reusable business slices.

Also applies to: 13-13, 44-46, 57-58

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` around lines 6 - 7, The terse policy fragments like "No break
workspace layout", "No add…", and "No make…" in AGENTS.md are ambiguous; replace
them with clear, consistent policy sentences (e.g., use "Do not break workspace
layout", "Do not add X that..." or "Avoid making changes that...") across the
referenced lines (including the instances flagged at lines ~6-7, 13, 44-46,
57-58) so contributors and automation can interpret them unambiguously; update
each clause to specify the prohibited action and its scope (what, where, and
why) and ensure uniform phrasing and tone throughout the document.


Follow canonical Turborepo layout in `.agents/skills/turborepo/references/best-practices/RULE.md`.
Keep deployable apps in `apps/`.
Keep shared libs + shared config in `packages/`.
No nested packages.
Do not move package responsibilities into repo root.
No move package responsibility to repo root.

## Documentation Language Policy

All durable docs stay synced in Chinese + English.

- Rule covers all docs under `docs/`: plans, brainstorms, solutions, other long-lived docs.
- Chinese docs live in `docs/zh-Hans/`.
- English docs live in `docs/en/`.
- Rule covers all docs under `docs/`: plans, brainstorms, solutions, other long-life docs.
- Chinese docs in `docs/zh-Hans/`.
- English docs in `docs/en/`.
- If one language doc exists, matching other-language doc must exist.
- Both versions stay semantically synced. Update both in same work. No drift.
- Both versions stay same meaning. Update both in same work. No drift.

## Repository Docs Convention

Use language-scoped layout: `docs/en/` = English, `docs/zh-Hans/` = Simplified Chinese.
Place each doc in matching category dir for both languages.
Put each doc in matching category dir for both languages.

- Brainstorms in `docs/{lang}/brainstorms/` - requirements, ideas, options, early framing.
- Plans in `docs/{lang}/plans/` - implementation plans, milestones, delivery order, progress tracking.
Expand All @@ -40,8 +41,8 @@ Use closest category below.
- `integration-issues/` - project integrations, generated outputs, external platform behavior mismatch; cross-platform issues; third-party API/service mismatch.
- `workflow-issues/` - agent workflow patterns, skill design, orchestration improvements, repo process decisions, repeatable execution guidance.

If none fits perfectly, use closest existing category.
Do not add new category unless both language trees intentionally expand.
If none fits perfect, use closest existing category.
No add new category unless both language trees expand on purpose.

## Skill-Level Conventions

Expand All @@ -52,11 +53,24 @@ Do not add new category unless both language trees intentionally expand.
- For `apps/web` frontend architecture work, also reference `feature-sliced-design`.
- When `next-best-practices` and `feature-sliced-design` overlap, use rules below:
- Next.js owns framework entry semantics + special files. Keep root `app/` as App Router entry. Put required files like `layout.tsx`, `page.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`, `template.tsx`, `default.tsx`, `route.ts` there when Next.js requires.
- FSD owns business structure inside `src/`, but do **not** create `src/pages` in `apps/web`. Next.js treats `src/pages` as Pages Router root; this conflicts with root App Router `app/`. Adapt FSD around framework rule.
- In `apps/web`, prefer `src/app`, `src/widgets`, `src/features`, `src/entities`, `src/shared` for business structure. If page-scoped business slice needed, keep under `src/app` or `src/widgets` with route-aligned name. Do not add `src/pages`.
- Keep root `app/` thin: route entrypoints, top-level providers, metadata wiring, route handlers, minimal bridge code only. Do not make root `app/` main home for reusable business slices.
- FSD owns business structure inside `src/`, but do **not** create `src/pages` in `apps/web`. Next.js treats `src/pages` as Pages Router root; this conflicts with root App Router `app/`. Bend FSD around framework rule.
- In `apps/web`, prefer `src/app`, `src/widgets`, `src/features`, `src/entities`, `src/shared` for business structure. If page-scoped business slice needed, keep under `src/app` or `src/widgets` with route-aligned name. No add `src/pages`.
- Keep root `app/` thin: route entrypoints, top-level providers, metadata wiring, route handlers, minimal bridge code only. No make root `app/` main home for reusable business slices.
- Follow `next-best-practices` first for RSC boundaries, Server vs Client Components, Server Actions, route handlers, metadata, async Next.js APIs, runtime constraints.
- Follow `feature-sliced-design` first for slice boundaries, public API usage, import direction, Pages First decomposition inside business layer.
- If FSD placement conflicts with required Next.js convention, keep Next.js convention and bend FSD around it. `src/pages` vs root `app/` = explicit example.
- For `apps/api` NestJS implementation work, invoke `nestjs-best-practices`.
- For `apps/api` backend architecture, follow `.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md` and organize by feature modules. Prefer self-contained feature folders grouping controllers, services, DTOs, entities, repositories, module defs. Avoid repo-wide tech-layer folders unless deeper scoped rule overrides.

## Plan Completion Checks

- After finishing a plan, check if GitHub Actions need updates; if yes, update relevant workflow files in same work.
- Before handoff, run full repo validation from root: `pnpm lint`, `pnpm format:check`, `pnpm typecheck`, `pnpm test`, and `pnpm test:e2e`.
- If any command fails, fix root issue first and rerun full set before closing turn.

## Git Hook Discipline

- Never bypass Git hooks or hook-time checks with flags that suppress warnings, ignore files, or hide failure.
- If a hook or staged check fails, fix underlying config or code first.
Do not use `--no-warn-ignored`, `--quiet`, or similar skip-style workarounds to make hook pass.
- Keep hook behavior honest: passing commit or push means check really ran and passed, not got silenced.
18 changes: 14 additions & 4 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
This app owns the PostgreSQL-backed feed ingestion backbone for the repository.
It reads `apps/api/feeds.opml`, ingests feeds on boot with best-effort
isolation, attempts article body extraction during ingestion, and serves the
existing read-only `/articles` contract from persisted data.
existing read-only `/articles` and health contracts from persisted data.

## Local Run

Expand Down Expand Up @@ -102,7 +102,7 @@ inside the container instead of the app-local default `./feeds.opml`.
- Prepared summaries use the official `openai` SDK against the configured
OpenAI-compatible gateway, validate the structured result with `zod`, and
persist canonical Markdown, `translatedTitle`, and any terminal failure reason
in `summaryErrorReason`.
in `summaryError`.
- Same-process sleep/freeze recovery is allowed to trigger a background feed
auto-refresh in the future, but `INGEST_ON_BOOT` remains startup-only; the
wake interval is an elapsed-hours check, not a cron schedule.
Expand Down Expand Up @@ -132,14 +132,14 @@ inside the container instead of the app-local default `./feeds.opml`.
- `sourceTitle`
- `publishedAt`
- `summary`
- `summaryErrorReason`
- `summaryError`
- `originalUrl`
- Returns `404` for unknown article IDs.

Article body markdown stays internal in this slice. The public `GET /articles`
and `GET /articles/:id` payloads expose the original `title`, the prepared
`translatedTitle`, the canonical Markdown `summary`, and any persisted
`summaryErrorReason`, but they still never expose `contentMarkdown` or
`summaryError`, but they still never expose `contentMarkdown` or
`contentExtractedAt`.

## Validation
Expand All @@ -152,6 +152,7 @@ pnpm --filter api db:reset
pnpm --filter api db:seed
pnpm --filter api test
pnpm --filter api test:e2e
pnpm --filter api contract:refresh
```

Use `pnpm --filter api db:deploy` to align the local development database with
Expand Down Expand Up @@ -189,3 +190,12 @@ behind.
- `GET /health/live` reports process liveness for orchestration probes.
- `GET /health/ready` reports readiness only when bootstrap has completed and
the database ping succeeds.

## OpenAPI Contract

- `apps/api/src/openapi/openapi-refresh.ts` refreshes the checked-in contract at
`packages/api-contract/openapi/openapi.yaml`.
- `packages/api-contract` generates the client/types that `apps/web` imports.
- Use `pnpm --filter api contract:refresh` first, then
`pnpm --filter @repo/api-contract contract:refresh` when the API surface
changes.
65 changes: 65 additions & 0 deletions apps/api/e2e/openapi-contract.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterAll, beforeAll, describe, expect, it, jest } from "@jest/globals";
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import YAML from "yaml";

import { AppModule } from "../src/app.module";
import { ArticlesService } from "../src/articles/articles.service";
import { PrismaService } from "../src/prisma/prisma.service";
import { createOpenApiDocument } from "../src/openapi/openapi-document";
import { FeedBootstrapService } from "../src/feeds/feed-bootstrap.service";

describe("OpenAPI contract", () => {
let app: INestApplication | undefined;

beforeAll(async () => {
process.env["TEST_DATABASE_URL"] ??=
"postgresql://rssift:rssift@127.0.0.1:5432/rssift_test";
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
process.env["INGEST_ON_BOOT"] = "false";

const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(ArticlesService)
.useValue({
getArticleById: jest.fn(),
getArticles: jest.fn(),
})
.overrideProvider(PrismaService)
.useValue({
$queryRawUnsafe: jest.fn(),
})
.overrideProvider(FeedBootstrapService)
.useValue({
onApplicationBootstrap: () => undefined,
})
.compile();

app = moduleRef.createNestApplication();
await app.init();
});

afterAll(async () => {
if (app) {
await app.close();
}
});

it("matches the checked-in contract", () => {
const emitted = createOpenApiDocument(app as INestApplication);
const checkedIn: unknown = YAML.parse(
readFileSync(
resolve(
__dirname,
"../../../packages/api-contract/openapi/openapi.yaml",
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"utf8",
),
);

expect(emitted).toEqual(checkedIn);
});
});
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"db:seed": "prisma db execute --config ./prisma.config.ts --file ./prisma/seed/seed.sql",
"db:studio": "prisma studio --config ./prisma.config.ts",
"dev": "nest start --watch",
"contract:refresh": "tsx src/openapi/openapi-refresh.ts",
"prebuild": "pnpm db:generate",
"build": "nest build",
"start": "nest start",
Expand All @@ -29,6 +30,7 @@
"@nestjs/config": "4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"@nestjs/swagger": "11.4.1",
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"escape-html": "^1.0.3",
Expand Down Expand Up @@ -63,6 +65,8 @@
"ts-jest": "^29.4.9",
"ts-loader": "^9.5.7",
"tsconfig-paths": "^4.2.0",
"tsx": "4.21.0",
"yaml": "2.8.3",
"typescript": "6.0.3"
}
}
14 changes: 14 additions & 0 deletions apps/api/src/articles/articles.controller.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { Controller, Get, NotFoundException, Param } from "@nestjs/common";
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from "@nestjs/swagger";

import { ArticleDetailItemDto } from "./dto/article-detail-item.dto";
import { ArticleListItemDto } from "./dto/article-list-item.dto";
import { ArticlesService } from "./articles.service";

@ApiTags("articles")
@Controller("articles")
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}

@Get()
@ApiOperation({ operationId: "articles" })
@ApiOkResponse({ type: ArticleListItemDto, isArray: true })
async getArticles(): Promise<ArticleListItemDto[]> {
return this.articlesService.getArticles();
}

@Get(":id")
@ApiOperation({ operationId: "articleById" })
@ApiParam({ name: "id", required: true, type: String })
@ApiOkResponse({ type: ArticleDetailItemDto })
@ApiNotFoundResponse({ description: "Article not found" })
async getArticleById(@Param("id") id: string): Promise<ArticleDetailItemDto> {
const article = await this.articlesService.getArticleById(id);

Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/articles/dto/article-detail-item.dto.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,41 @@
import { ApiProperty } from "@nestjs/swagger";

export class ArticleSummaryErrorDto {
@ApiProperty({ type: String })
action!: string;

@ApiProperty({ type: String })
code!: string;

@ApiProperty({ type: String })
copyText!: string;

@ApiProperty({ type: String })
message!: string;

@ApiProperty({ type: String })
title!: string;
}

export class ArticleDetailItemDto {
@ApiProperty({ type: String })
title!: string;

@ApiProperty({ type: String })
translatedTitle!: string;

@ApiProperty({ type: String })
sourceTitle!: string;

@ApiProperty({ type: String, format: "date-time" })
publishedAt!: string;

@ApiProperty({ type: String })
summary!: string;

@ApiProperty({ type: () => ArticleSummaryErrorDto, nullable: true })
summaryError!: ArticleSummaryErrorDto | null;

@ApiProperty({ type: String })
originalUrl!: string;
}
13 changes: 13 additions & 0 deletions apps/api/src/articles/dto/article-list-item.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { ApiProperty } from "@nestjs/swagger";

export class ArticleListItemDto {
@ApiProperty({ type: String })
id!: string;

@ApiProperty({ type: String })
title!: string;

@ApiProperty({ type: String })
translatedTitle!: string;

@ApiProperty({ type: String })
sourceTitle!: string;

@ApiProperty({ type: String, format: "date-time" })
publishedAt!: string;

@ApiProperty({ type: String })
originalUrl!: string;
}
36 changes: 36 additions & 0 deletions apps/api/src/health/dto/health-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiProperty } from "@nestjs/swagger";

export class HealthLiveChecksDto {
@ApiProperty({ type: String })
application!: string;
}

export class HealthReadyChecksDto {
@ApiProperty({ type: String })
application!: string;

@ApiProperty({ type: String })
database!: string;
}

export class HealthLiveResponseDto {
@ApiProperty({ type: () => HealthLiveChecksDto })
checks!: HealthLiveChecksDto;

@ApiProperty({ type: String })
service!: string;

@ApiProperty({ type: String })
status!: string;
}

export class HealthReadyResponseDto {
@ApiProperty({ type: () => HealthReadyChecksDto })
checks!: HealthReadyChecksDto;

@ApiProperty({ type: String })
service!: string;

@ApiProperty({ type: String })
status!: string;
}
Loading