Browse Source

Merge pull request #10986 from growilabs/feat/181356-news-inappnotification-impl

feat(spec): Retrieve GROWI news
mergify[bot] 1 week ago
parent
commit
4da6fb9f08
51 changed files with 3751 additions and 201 deletions
  1. 221 47
      .kiro/specs/news-inappnotification/design.md
  2. 19 3
      .kiro/specs/news-inappnotification/requirements.md
  3. 2 2
      .kiro/specs/news-inappnotification/spec.json
  4. 71 34
      .kiro/specs/news-inappnotification/tasks.md
  5. 7 0
      apps/app/public/static/locales/en_US/admin.json
  6. 9 3
      apps/app/public/static/locales/en_US/commons.json
  7. 7 0
      apps/app/public/static/locales/fr_FR/admin.json
  8. 9 3
      apps/app/public/static/locales/fr_FR/commons.json
  9. 7 0
      apps/app/public/static/locales/ja_JP/admin.json
  10. 9 3
      apps/app/public/static/locales/ja_JP/commons.json
  11. 7 0
      apps/app/public/static/locales/ko_KR/admin.json
  12. 5 1
      apps/app/public/static/locales/ko_KR/commons.json
  13. 7 0
      apps/app/public/static/locales/zh_CN/admin.json
  14. 9 3
      apps/app/public/static/locales/zh_CN/commons.json
  15. 12 0
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  16. 6 4
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  17. 6 0
      apps/app/src/client/components/InAppNotification/UnreadDot.module.scss
  18. 9 3
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  19. 138 0
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationContent.tsx
  20. 97 0
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.spec.tsx
  21. 69 0
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.tsx
  22. 0 74
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  23. 5 4
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  24. 201 0
      apps/app/src/client/components/Sidebar/InAppNotification/hooks/useMergedInAppNotifications.ts
  25. 191 0
      apps/app/src/features/news/client/components/NewsItem.spec.tsx
  26. 83 0
      apps/app/src/features/news/client/components/NewsItem.tsx
  27. 77 0
      apps/app/src/features/news/client/components/admin/NewsDeliverySetting.tsx
  28. 59 0
      apps/app/src/features/news/client/hooks/use-news.ts
  29. 42 0
      apps/app/src/features/news/client/services/news-delivery-setting.ts
  30. 34 0
      apps/app/src/features/news/interfaces/news-item.ts
  31. 11 0
      apps/app/src/features/news/interfaces/news-read-status.ts
  32. 56 0
      apps/app/src/features/news/server/models/news-item.spec.ts
  33. 56 0
      apps/app/src/features/news/server/models/news-item.ts
  34. 29 0
      apps/app/src/features/news/server/models/news-read-status.spec.ts
  35. 43 0
      apps/app/src/features/news/server/models/news-read-status.ts
  36. 286 0
      apps/app/src/features/news/server/routes/news-integration.integ.ts
  37. 359 0
      apps/app/src/features/news/server/routes/news.spec.ts
  38. 246 0
      apps/app/src/features/news/server/routes/news.ts
  39. 65 0
      apps/app/src/features/news/server/services/feed-parser.ts
  40. 319 0
      apps/app/src/features/news/server/services/news-cron-service.spec.ts
  41. 140 0
      apps/app/src/features/news/server/services/news-cron-service.ts
  42. 445 0
      apps/app/src/features/news/server/services/news-service.spec.ts
  43. 184 0
      apps/app/src/features/news/server/services/news-service.ts
  44. 4 1
      apps/app/src/interfaces/in-app-notification.ts
  45. 6 0
      apps/app/src/server/crowi/index.ts
  46. 4 4
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  47. 4 0
      apps/app/src/server/routes/apiv3/index.js
  48. 8 0
      apps/app/src/server/service/config-manager/config-definition.ts
  49. 62 9
      apps/app/src/stores/in-app-notification.ts
  50. 3 3
      packages/core/src/interfaces/scope.spec.ts
  51. 3 0
      packages/core/src/interfaces/scope.ts

+ 221 - 47
.kiro/specs/news-inappnotification/design.md

@@ -12,7 +12,7 @@
 
 ### Goals
 
-- 外部フィード(`NEWS_FEED_URL`)を cron で定期取得し、MongoDB にキャッシュする
+- 外部フィード(コードにハードコードされた配信元 URL)を cron で定期取得し、MongoDB にキャッシュする
 - InAppNotification パネルで通知とニュースを統合表示する
 - ニュースの既読/未読状態をユーザー単位で管理する
 - ロール別表示制御(admin/general)をサーバーサイドで強制する
@@ -33,7 +33,7 @@
 
 InAppNotification は per-user ドキュメント設計であり、`user` フィールドが必須。通知発生時に全対象ユーザー分のドキュメントを生成する(push 型)。ニュースは全ユーザーで1件のドキュメントを共有し、ユーザーがパネルを開いたときに取得する(pull 型)。この設計上の差異により、ニュースは別モデルとして実装する(詳細は `research.md` の Design Decisions を参照)。
 
-サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、`InAppNotificationSubstance.tsx` へ prop として渡すパターンを採用している。本機能のフィルタ state も同じパターンで実装する。
+サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、子コンポーネントへ prop として渡すパターンを採用している。本機能ではフィルタ state も同じく親で管理する。データ層(2 つの SWR ストリーム合流・マージ・mutation handlers)は責務集中による凝集度低下を避けるため `hooks/useMergedInAppNotifications.ts` のカスタムフックに集約し、Forms(フィルタ UI)と Content(リスト描画)のプレゼンテーションを分離する。
 
 ### Architecture Pattern & Boundary Map
 
@@ -65,8 +65,9 @@ graph TB
 
 **Architecture Integration**:
 - 選択パターン: Pull 型 + クライアントサイドマージ
-- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`
-- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `InAppNotificationSubstance.tsx`(フィルタタブ + InfiniteScroll), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算)
+- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`, `useMergedInAppNotifications`(パネルのデータ層フック), `InAppNotificationForms.tsx`, `InAppNotificationContent.tsx`
+- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算), `InAppNotificationElm.tsx`(既存通知側の修正あり)
+- スコープ拡張: `@growi/core` に `features.in_app_notification` を新設し、News API と既存 `/in-app-notification/*` の通知データ取得系エンドポイントを移行(設定 CRUD は `user_settings.in_app_notification` のまま)
 - 既存 `InfiniteScroll.tsx` をそのまま再利用
 
 ### Technology Stack
@@ -74,7 +75,7 @@ graph TB
 | Layer | 選択 / バージョン | 役割 |
 |---|---|---|
 | Backend Cron | node-cron(既存) | フィード定期取得スケジューリング |
-| Backend HTTP | node `fetch` / axios(既存) | `NEWS_FEED_URL` から feed.json 取得 |
+| Backend HTTP | node `fetch` / axios(既存) | コードに内蔵された配信元 URL から feed.json 取得 |
 | Data Store | MongoDB + Mongoose(既存) | NewsItem, NewsReadStatus の永続化 |
 | Frontend Data | SWR `useSWRInfinite`(既存) | ニュース・通知の無限スクロール取得 |
 | Frontend State | React `useState`(既存パターン) | フィルタタブ・未読トグルのローカル state |
@@ -92,14 +93,15 @@ sequenceDiagram
   participant Feed as GitHub Pages
   participant DB as MongoDB
 
-  Cron->>Cron: getCronSchedule() = '0 1 * * *'
-  Cron->>Cron: NEWS_FEED_URL 未設定? → スキップ
+  Cron->>Cron: getCronSchedule() = '0 0 * * *'(midnight 起動)
+  Cron->>Cron: configManager.getConfig('news:isDeliveryEnabled') が false? → スキップ
+  Cron->>Cron: randomSleep(0–5 時間)でリクエスト時刻を分散
   Cron->>Feed: HTTP GET feed.json
   alt 取得失敗
     Cron->>Cron: ログ記録、既存 DB データ維持
   else 取得成功
     Cron->>Cron: growiVersionRegExps でフィルタ
-    Cron->>DB: externalId で upsert(新規/更新
+    Cron->>DB: bulkWrite で一括 upsert(externalId キー、ordered:false
     Cron->>DB: フィードにないアイテムを削除
   end
   Note over DB: TTL インデックス(90日)で自動削除
@@ -155,7 +157,7 @@ sequenceDiagram
 | 2.1–2.4 | NewsItem モデル | NewsItem Model | MongoDB schema | フィード取得フロー |
 | 3.1–3.5 | 既読/未読管理 | NewsReadStatus Model, NewsService, News API | `POST /mark-read`, `GET /unread-count` | 既読フロー |
 | 4.1–4.2 | ロール別表示制御 | NewsService | `listForUser(userRole)` | パネル表示フロー |
-| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationSubstance | filter state props | パネル表示フロー |
+| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationForms, InAppNotificationContent, useMergedInAppNotifications | filter state props, フックの戻り値 | パネル表示フロー |
 | 6.1–6.4 | 視覚表示 | NewsItem Component | CSS classes(`fw-bold`, `bg-primary`) | — |
 | 7.1–7.2 | 未読バッジ | PrimaryItemForNotification | `useSWRxNewsUnreadCount` | — |
 | 8.1–8.4 | 多言語対応 | NewsItem Component, locales | locale fallback logic | — |
@@ -168,11 +170,13 @@ sequenceDiagram
 
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
 |---|---|---|---|---|
-| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7 | CronService (P0), NewsService (P0) |
+| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7, 9.5, 9.6 | CronService (P0), NewsService (P0), configManager (P0) |
 | NewsItem Model | Server / Data | ニュースアイテムの永続化 | 2.1–2.4 | MongoDB (P0) |
 | NewsReadStatus Model | Server / Data | ユーザー既読状態の永続化 | 3.1–3.3 | MongoDB (P0) |
 | NewsService | Server / Domain | ニュース一覧・既読管理のビジネスロジック | 3.4–3.5, 4.1–4.2 | NewsItem Model (P0), NewsReadStatus Model (P0) |
 | News API | Server / API | HTTP エンドポイント提供 | 3.1–3.5, 4.1–4.2 | NewsService (P0) |
+| News Delivery Config | Server / Config | 配信フラグ `news:isDeliveryEnabled` の登録(DB 主体、defaultValue: true) | 9.1, 9.2 | configManager (P0) |
+| App Settings UI(拡張) | Client / Admin | `/admin/app` UI から配信フラグを切り替える | 9.3, 9.4 | News Delivery Config (P0), 既存 `app-settings` API (P0) |
 
 ---
 
@@ -184,36 +188,41 @@ sequenceDiagram
 | Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 |
 
 **Responsibilities & Constraints**
-- 毎日 AM 1:00 に実行(`'0 1 * * *'`)
-- `NEWS_FEED_URL` 未設定時はスキップ(エラーなし)
+- 毎日 0 時に発火し、ランダムスリープで実取得時刻を 0–5 時に分散させる(cron 起動 `'0 0 * * *'` + `randomSleep(0–5h)`)
+- **配信フラグ判定**:cron 発火ごとに `configManager.getConfig('news:isDeliveryEnabled')` を読み、`false` ならフィード取得をスキップ(再起動不要、次回 tick から即時反映)
+- **配信元 URL はコードにハードコード**(`https://growilabs.github.io/growi-news-feed/feed.json`)。env による上書き経路は持たず、ユーザー(admin 含む)・運用者ともに変更不可
 - 取得失敗時は既存 DB データを維持
 - `growiVersionRegExps` の照合はここで実施(DB には合致アイテムのみ保存)
-- ランダムスリープ(0–5分)で複数インスタンスのリクエストを分散
+
+**配信先への分散戦略**:
+全 GROWI インスタンスが同じ時間帯にフィードへアクセスするため、CDN ミス時に origin(GitHub Pages)へ集中する thundering herd を避ける必要がある。`'0 1 * * *'` + 5 分窓では実用上の希釈が小さいため、**5 時間ウィンドウ + 60 倍希釈**(対 5 分窓比)に拡張した。GitHub Pages の月間 100GB 帯域クォータと CDN キャッシュ TTL 10 分という外部条件を踏まえ、夜間帯 0–5 時に均等分散する設計。
 
 **Dependencies**
 - Inbound: node-cron — スケジュール実行(P0)
 - Outbound: NewsService — upsert/delete(P0)
-- External: `NEWS_FEED_URL` の HTTP エンドポイント — feed.json 取得(P0)
+- External: 弊社管理の HTTP エンドポイント(コードに内蔵された URL) — feed.json 取得(P0)
 
 **Contracts**: Batch [x]
 
 ##### Batch / Job Contract
-- Trigger: `node-cron` スケジュール `'0 1 * * *'`
-- Input: `NEWS_FEED_URL` 環境変数、GROWI バージョン文字列
+- Trigger: `node-cron` スケジュール `'0 0 * * *'`(実取得は randomSleep を経て 0–5 時に分散)
+- Input: GROWI バージョン文字列(配信元 URL はコードに内蔵)
 - Output: MongoDB の NewsItem コレクションを最新フィードと同期
 - Idempotency: `externalId` ユニークインデックスにより冪等。再実行しても重複なし
 
 ##### Service Interface
 ```typescript
 class NewsCronService extends CronService {
-  getCronSchedule(): string;  // '0 1 * * *'
+  getCronSchedule(): string;  // '0 0 * * *'
   executeJob(): Promise<void>;
 }
+
+const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;  // 5 hours
 ```
 
 **Implementation Notes**
 - Integration: `server/service/cron.ts` の `CronService` を継承。`startCron()` をアプリ起動時に呼ぶ
-- Validation: `NEWS_FEED_URL` が `https://` で始まることを確認。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
+- Validation: 配信元 URL はコードにハードコードされており、ランタイムの URL 検証は不要(外部入力経路がない)。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
 - Risks: フィード取得タイムアウト(10秒推奨)。外部依存のため失敗を前提に設計する
 
 ---
@@ -313,6 +322,29 @@ interface INewsItemWithReadStatus extends INewsItem {
 - Postconditions: `listForUser` の結果は `publishedAt` 降順。各アイテムに `isRead` が付与される
 - ロールフィルタ: `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返す
 
+**`upsertNewsItems` の実装制約**:
+
+配信側(`tmp/news-feed-delivery-spec.md`)でフィードアイテム数の上限は規定されない(運用の柔軟性を優先)。受信側 NewsItem の TTL(90 日)はフィードに残り続けるアイテムの `fetchedAt` が毎回更新されるため実質発火しない。よって items 配列は理論上無制限に成長しうる。実運用想定は 5 年で ~150–250 件だが上限保証は無い。
+
+`Promise.all(items.map(NewsItem.updateMany))` での並列 fan-out は項目数増加時に DB コネクションプール圧迫・IO 飽和を招くため、**`NewsItem.bulkWrite([...], { ordered: false })` で 1 DB コマンドにバッチ化**する。`markAllRead` の `insertMany({ ordered: false })` と一貫したスタイル。
+
+```typescript
+async upsertNewsItems(items: INewsItemInput[]): Promise<void> {
+  if (items.length === 0) return;
+  const now = new Date();
+  await NewsItem.bulkWrite(
+    items.map(item => ({
+      updateOne: {
+        filter: { externalId: item.id },
+        update: { $set: { ... fetchedAt: now } },
+        upsert: true,
+      },
+    })),
+    { ordered: false },
+  );
+}
+```
+
 ---
 
 #### News API
@@ -335,13 +367,76 @@ interface INewsItemWithReadStatus extends INewsItem {
 
 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する。
 
+**Scope 設計**:
+
+GROWI の scope 階層は以下の意味論で運用する:
+
+| 階層 | 意味 | 例 |
+|---|---|---|
+| `user_settings.X` | ユーザーの **X 機能に関する設定値** の CRUD | `/personal-setting/in-app-notification-settings`(通知設定) |
+| `features.X` | **X 機能のデータ自体** へのアクセス | `/pages/list`(ページデータ), `/news/list`(ニュースデータ) |
+
+通知データ取得は機能データへのアクセスに該当するため `features.in_app_notification` を新設し、News API 4 エンドポイントを移行する。あわせて既存 `/in-app-notification/*` の 4 エンドポイント(`list` / `status` / `open` / `all-statuses-open`)も同スコープへ移行(既存は `user_settings.in_app_notification` を誤用していた)。`/personal-setting/in-app-notification-settings` GET/POST は通知設定 CRUD なので `user_settings.in_app_notification` のまま維持する。
+
+| Method | Endpoint | Scope |
+|---|---|---|
+| GET | `/apiv3/news/list` | `read:features:in_app_notification` |
+| GET | `/apiv3/news/unread-count` | `read:features:in_app_notification` |
+| POST | `/apiv3/news/mark-read` | `write:features:in_app_notification` |
+| POST | `/apiv3/news/mark-all-read` | `write:features:in_app_notification` |
+
+`@growi/core` の `SCOPE_SEED_USER.features.in_app_notification` 追加と、`accesstoken_scopes_desc` i18n(`en_US` / `ja_JP` / `zh_CN` / `fr_FR`)の更新が必要。
+
 **Implementation Notes**
-- Integration: `apps/app/src/server/routes/apiv3/news.ts` に新規作成
+- Integration: `apps/app/src/features/news/server/routes/news.ts` に新規作成。`createNewsRouter(crowi?: Crowi)` をエクスポートし、optional `Crowi` で受けてテスト時にミドルウェアを pass-through できる構造(型アサーションは使わない)
 - Validation: `newsItemId` は `mongoose.isValidObjectId()` で検証
 - Risks: ロールフィルタはサーバーサイドで強制。クライアントから `targetRoles` を受け取らない
 
 ---
 
+#### News Delivery Config
+
+| Field | Detail |
+|---|---|
+| Intent | `news:isDeliveryEnabled` を configManager に登録し、cron/API/UI から共通で参照できるようにする |
+| Requirements | 9.1, 9.2 |
+
+**Responsibilities & Constraints**
+- `apps/app/src/server/service/config-manager/config-definition.ts` に CONFIG_KEYS と `defineConfig` の 2 箇所を追加
+- `defineConfig` パターンを踏襲しつつ、**`envVarName` を意図的に持たせない**(`defaultValue: true` のみ)。これにより env からの上書きを禁じ、admin UI 経由の DB 操作のみが ON/OFF を変えられる経路となる
+- `defaultValue: true` をコードに内蔵 → DB に値が無い状態で全顧客が ON
+- 値の優先順は configManager の既存仕様(DB > env > defaultValue)に従う
+
+**Dependencies**
+- Inbound: NewsCronService, App Settings UI
+- Outbound: configManager(既存)
+
+**Implementation Notes**
+- env 変数として一切暴露しないため `/admin` 環境変数一覧には決して現れない(DB 単独運用)
+- 開発時に強制的に値を変更したい場合は、ローカルで DB レコードを直接書き換えるか、コードを一時編集する
+- 設定変更時は configManager の `updateConfigs` がメモリキャッシュ更新と pubsub 通知(multi-pod 反映)を行う
+
+---
+
+#### App Settings UI(拡張)
+
+| Field | Detail |
+|---|---|
+| Intent | `/admin/app` 画面に「ニュース配信」ON/OFF トグルを追加する |
+| Requirements | 9.3, 9.4 |
+
+**Responsibilities & Constraints**
+- 既存 `app-settings` 画面・API(`PUT /apiv3/app-setting`)に項目を追加するパターンを踏襲
+- 認可:`accessTokenParser([SCOPE.WRITE.ADMIN.APP])` + `adminRequired` で admin のみに制限
+- トグル値の永続化先:`configManager.updateConfigs({ 'news:isDeliveryEnabled': boolean })`
+- UI 文言は i18n 対応(`ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`)
+
+**Dependencies**
+- Inbound: 管理画面(admin user)
+- Outbound: News Delivery Config(configManager 経由)
+
+---
+
 ### クライアントサイド
 
 | コンポーネント | 層 | Intent | 要件 | 主要依存 |
@@ -349,9 +444,11 @@ interface INewsItemWithReadStatus extends INewsItem {
 | useSWRINFxNews | Client / Hooks | ニュースアイテムの無限スクロール取得 | 5.4 | News API (P0) |
 | useSWRxNewsUnreadCount | Client / Hooks | ニュース未読カウント取得 | 7.1 | News API (P0) |
 | useSWRINFxInAppNotifications | Client / Hooks | 通知の無限スクロール取得(既存 hook を拡張) | 5.4 | InAppNotification API (P0) |
+| useMergedInAppNotifications | Client / Hooks | パネルのデータ層(2 SWR + 終端判定 + 合成 response + マージ + 既読 mutation handlers) | 5.1–5.5 | useSWRINFxNews (P0), useSWRINFxInAppNotifications (P0) |
 | InAppNotification.tsx(変更) | Client / UI | フィルタ state を追加管理 | 5.2, 5.3 | useState (P0) |
-| InAppNotificationSubstance.tsx(変更) | Client / UI | フィルタタブ + InfiniteScroll | 5.1–5.5 | useSWRINFxNews (P0), InfiniteScroll (P0) |
-| NewsItem Component | Client / UI | ニュースアイテム1件の表示 | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
+| InAppNotificationForms.tsx(新設) | Client / UI | フィルタタブ + 未読トグル UI | 5.2, 5.3 | — |
+| InAppNotificationContent.tsx(新設) | Client / UI | 3 分岐レンダラー(all/news/notifications) + InfiniteScroll | 5.1, 5.4, 5.5 | useMergedInAppNotifications (P0), InfiniteScroll (P0) |
+| NewsItem Component | Client / UI | ニュースアイテム1件の表示(`React.memo` で wrap) | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
 | PrimaryItemForNotification(変更) | Client / UI | 未読バッジに NewsItem の未読数を合算 | 7.1, 7.2 | useSWRxNewsUnreadCount (P0) |
 
 ---
@@ -399,27 +496,87 @@ type FilterType = 'all' | 'news' | 'notifications';
 
 ---
 
-#### InAppNotificationSubstance.tsx(変更)
+#### InAppNotificationElm.tsx(既存・修正あり)
+
+**実装後に判明した落とし穴**: 未読ドットに使われていた CSS クラス `grw-unopend-notification` はコードベースに定義が存在せず、ドットが不可視だった。`bg-primary rounded-circle` + インラインスタイル(`width/height: 8px, display: inline-block`)に置き換えて修正済み。このコンポーネントを今後変更する場合、同クラスを再導入しないこと。
+
+---
+
+#### Panel modules: Forms + Content + data hook
 
 | Field | Detail |
 |---|---|
-| Intent | フィルタタブ UI の追加と、InfiniteScroll を用いた統合リスト表示 |
+| Intent | フィルタタブ UI とリスト描画を独立に保ち、データ層はカスタムフックに集約する |
 | Requirements | 5.1, 5.2, 5.3, 5.4, 5.5 |
 
 **Contracts**: State [x]
 
-**InAppNotificationForms への追加**:
+**ファイル構成**:
+
+```
+client/components/Sidebar/InAppNotification/
+├── InAppNotification.tsx                  (フィルタ state を管理し props で配布)
+├── InAppNotificationForms.tsx             (Forms UI のみ)
+├── InAppNotificationContent.tsx           (3 分岐レンダラー)
+└── hooks/
+    └── useMergedInAppNotifications.ts     (データ層)
+```
+
+**設計原則**:
+- **データ層と表示層を分離する**。`useMergedInAppNotifications` フックがニュース・通知の両 `useSWRInfinite` 呼び出し、ページ終端判定、合成 SWRInfiniteResponse の構築、マージ、既読 mutation handlers を一手に引き受ける。これにより `InAppNotificationContent` はフックの戻り値を受け取って `activeFilter` で 3 分岐するだけの薄い renderer になる
+- **Forms はプレゼンテーションのみ**。データ層に触れない
+- 単一ファイルで 7 責務(スクロール戦略・SWR 2 本・終端判定・合成 response・マージ・mutation 2 種・3 分岐 render)を抱えていた v1 の `InAppNotificationSubstance.tsx`(339 行)は廃止し、上記 3 モジュールに分割する
+
+**InAppNotificationForms**:
 - フィルタボタン(「すべて」「通知」「お知らせ」)を Bootstrap `btn-group` で実装
-- 既存「未読のみ」トグルは維持
-
-**InAppNotificationContent の変更**:
-- `activeFilter` に応じて3パターンに分岐
-  - `'all'`: `useSWRINFxNews` + `useSWRINFxInAppNotifications` の結果を `publishedAt/createdAt` 降順でマージ
-  - `'news'`: `useSWRINFxNews` のみ。`NewsList` に渡す
-  - `'notifications'`: `useSWRINFxInAppNotifications` のみ。既存 `InAppNotificationList` に渡す
-- 既存 `InfiniteScroll` コンポーネントを使用(`client/components/InfiniteScroll.tsx`)
+- 既存「未読のみ」トグルを維持
+
+**InAppNotificationContent (3 分岐)**:
+- `'all'`: `useMergedInAppNotifications.allModeSWRResponse` + `mergedItems` を `InfiniteScroll` に渡し、両ストリームをマージ表示
+- `'news'`: `newsResponse` + `allNewsItems` のみ
+- `'notifications'`: `notificationResponse` + `allNotificationItems` のみ
+- 既存 `InfiniteScroll` コンポーネント(`client/components/InfiniteScroll.tsx`)を再利用
 - 既存 `// TODO: Infinite scroll implemented` コメントを解消
 
+**useMergedInAppNotifications フック**:
+
+戻り値:
+```typescript
+{
+  newsResponse, allNewsItems, newsExhausted,
+  notificationResponse, allNotificationItems, notifExhausted,
+  allModeSWRResponse, mergedItems,
+  handleReadMutate, handleNotificationRead,
+}
+```
+
+- `'all'` モード用の合成 `SWRInfiniteResponse`: `setSize` は終端に達していないストリームをインクリメント(両方未終端なら両方)、`isValidating` はいずれかが true なら true、両ストリーム終端時に `isReachingEnd = true`
+- `mergedItems` は両ストリームの `flatMap → publishedAt/createdAt 降順 sort`
+- ハンドラは `useCallback` で参照を安定化する。SWR の `mutate` は cache key 単位で stable なので、`{ mutate: mutateNews } = newsResponse` のように destructure して deps に含める(biome のルール対応)
+
+**サイドバーモード別スクロール戦略**:
+
+サイドバーには2種類のモードがあり、スクロール担当コンテナが異なる。
+
+| モード | UI | スクロール担当 | コンテンツエリアの制約 |
+|---|---|---|---|
+| collapsed(ホバーパネル ①) | ベルアイコンにホバー時の小パネル | `InAppNotificationContent` 内の `overflow-auto` div | `maxHeight: 60vh` で高さを制限 |
+| dock / drawer(全面サイドバー ②) | 展開した全面パネル | 外側の `SimpleBar`(`h-100`) | 制約なし。コンテンツが自然に伸長 |
+
+collapsed モードで `overflow-auto + maxHeight` を使い、dock/drawer モードでは外していない場合、**二重スクロールコンテナ**が発生する。具体的には:
+- `overflow-auto` div がサイドバーと同高の scroll context を作る
+- スクロールバーがコンテンツ高さとほぼ同じ縦幅で出現し、わずかな余白でしか動かせなくなる(振動挙動)
+
+対策として `InAppNotificationContent` 内で `useSidebarMode()` を呼び、`isCollapsedMode()` が true のときのみ `overflow-auto` クラスと `maxHeight: 60vh` を付与する。dock/drawer モードでは div に何も付与せず、SimpleBar にスクロールを委ねる。
+
+**通知ドット即時消去: SWR mutate による楽観的更新**:
+
+`InAppNotificationElm` はクリック時に `apiv3Post('/in-app-notification/open')` でサーバーへ書き込みを行うが、UI への反映は SWR キャッシュの即時書き換えで行う。`useMergedInAppNotifications.handleNotificationRead` 内で `mutateNotifications(updater, { revalidate: false })` を用い、`useSWRInfinite` のページごとに該当 `doc.status` を `STATUS_OPENED` へ書き換える。
+
+`useSWRInfinite` のキャッシュは `SWRConfig` プロバイダの Map に保持されるため、同一 React tree のアンマウント/リマウントを跨いで状態が維持され、リマウント後もドットは消えたままとなる。ローカル `useState` を持たずに SWR の標準機能のみで完結させることで、キャッシュ・再検証制御・キー共有といった SWR の利点をそのまま活かせる。
+
+品質改善の経緯: PR #10986 のレビュー FB を受け、当初採用した `useState<Set<string>>` 戦略を SWR `mutate` + `revalidate: false` に差し替えた。さらに PR #11050 で Substance 単一ファイル構造を Forms / Content / data hook の 3 モジュールに分割(凝集度向上)し、ハンドラを `useCallback` 化した。
+
 ---
 
 #### NewsItem Component
@@ -431,11 +588,16 @@ type FilterType = 'all' | 'news' | 'notifications';
 
 **Implementation Notes**
 - 配置: `features/news/client/components/NewsItem.tsx`
-- ロケールフォールバック: `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
-- 未読: `fw-bold` + 左端に `bg-primary` 8px 丸ドット
-- 既読: `fw-normal` + 同幅の透明スペーサー
-- `emoji` 未設定時は `📢` をフォールバック
+- **レイアウト**: 既存の `InAppNotificationElm` と同一カラム構成に揃える
+  - 左端: 未読ドット(`bg-primary` 8px 丸)または同幅の透明スペーサー
+  - アバター位置: `emoji` を表示(`UserPicture` が占める位置と同等)。未設定時は `📢` をフォールバック
+  - コンテンツ列: タイトル(未読時 `fw-bold`、既読時 `fw-normal`)+ 公開日時
+- ロケールフォールバック: `i18n.language → ja_JP → en_US → 最初に利用可能なキー`(`useTranslation()` から取得)
+- 日付フォーマット: `date-fns` の `format` と `getLocale(i18n.language)` を用い、`ActivityListItem` と同じロケールパターンに統一
+- Bootstrap クラス: `w-100 text-start bg-transparent fs-5 lh-1` などを利用し、インラインスタイルを最小化
+- 未読ドット: `InAppNotificationElm` と共有の `UnreadDot.module.scss` を使用し、両者の見た目を完全に揃える
 - クリック時: `POST /mark-read` + SWR mutate + `url` があれば新タブで開く
+- **再レンダ最適化**: `export const NewsItem = memo(NewsItemInner)` で `React.memo` ラップ。親 `InAppNotificationContent` の再レンダ時、SWR が同一参照を返している `item` props と `useCallback` で参照安定化された `onReadMutate` props により、変化のないアイテムは再レンダされない。`<InAppNotificationElm>`(legacy 経路 `InAppNotificationDropdown` / `InAppNotificationPage` から共有される)の memo 化は本機能のスコープ外として将来 PR で対応
 
 ---
 
@@ -522,13 +684,7 @@ interface INewsItemWithReadStatus {
   isRead: boolean;
 }
 
-interface PaginateResult<T> {
-  docs: T[];
-  totalDocs: number;
-  limit: number;
-  offset: number;
-  hasNextPage: boolean;
-}
+// PaginateResult<T> は ~/interfaces/in-app-notification の既存型を再利用する(再定義不要)
 ```
 
 ---
@@ -544,7 +700,7 @@ interface PaginateResult<T> {
 | カテゴリ | エラー | 対応 |
 |---|---|---|
 | Cron / External | フィード取得失敗(ネットワーク、タイムアウト) | `logger.error` + 既存 DB データ維持。次回 cron で再試行 |
-| Cron / Config | `NEWS_FEED_URL` 未設定 | スキップ(ログなし)。設定されるまで無害に動作 |
+| Cron / Config | `news:isDeliveryEnabled` が `false` | スキップ(debug ログ)。admin が再度 ON にするまで無害に停止 |
 | Cron / Validation | `growiVersionRegExps` に不正 regex | try-catch で該当アイテムをスキップ、`logger.warn` |
 | API / Auth | 未認証リクエスト | 401(`loginRequiredStrictly` が処理) |
 | API / Validation | 不正な `newsItemId` フォーマット | 400(`mongoose.isValidObjectId()` チェック) |
@@ -561,7 +717,7 @@ interface PaginateResult<T> {
 
 ### Unit Tests
 
-- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`NEWS_FEED_URL` 未設定 → スキップ
+- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`news:isDeliveryEnabled` が `false` → スキップ
 - `NewsCronService.executeJob()`: `growiVersionRegExps` 一致 → 保存、不一致 → 除外
 - `NewsService.listForUser()`: `targetRoles` フィルタ(admin のみ、general 除外)
 - `NewsService.listForUser()`: `onlyUnread=true` で未読のみ返す
@@ -586,13 +742,31 @@ interface PaginateResult<T> {
 ## Security Considerations
 
 - すべての `/apiv3/news/*` エンドポイントに `loginRequiredStrictly` を適用する
+- アクセストークン用 scope は **`features.in_app_notification`** を使用する(read / write)。設定 CRUD 用の `user_settings.in_app_notification` とはセマンティクスが異なるため流用しない。アクセストークン発行時にユーザーが意図した粒度でアクセスを許可できるようにする
 - `conditions.targetRoles` のフィルタリングはサーバーサイドの `NewsService.listForUser()` で強制する。クライアントから `targetRoles` パラメータを受け付けない
-- `NEWS_FEED_URL` は `https://` のみ許可(HTTP 不可)
+- 配信元 URL はコードにハードコードされており、ランタイムで変更できる経路を持たない。env 変数による上書きもサポートしない
 - フィードから取得したデータはそのまま DB に保存し、クライアントへのレスポンス時に Mongoose スキーマで型安全に扱う
 
 ## Performance & Scalability
 
+**データ量とインデックス**:
 - NewsItem は全ユーザーで1件共有のため、ユーザー数に比例してドキュメントが増えない
 - `publishedAt` インデックスにより降順ソートが効率的
-- `fetchedAt` TTL インデックス(90日)で古いデータを自動削除し、コレクションサイズを制限
+- `fetchedAt` TTL インデックス(90日)は **フィードから外れたアイテムにのみ実質発火** する(フィードに残り続けるアイテムは毎回 `fetchedAt` が更新されるため発火しない)。よってコレクションサイズの上限は配信側のキュレーションに依存する
 - `NewsReadStatus` の compound unique index により `listForUser` の LEFT JOIN 相当クエリが効率的
+
+**フィードアイテム規模の前提**:
+- 配信側スキーマ(`tmp/news-feed-delivery-spec.md`)でフィードアイテム数の上限規定は設けない(運用の柔軟性を優先)
+- 想定ペース: release 12–24 件/年、security/tips/maintenance/announcement 合わせて 30–50 件/年
+- 5 年運用で **150–250 件程度** の見込み。ただし上限保証はないため、実装は無制限成長に耐える形で設計する
+
+**書き込み戦略**:
+- `NewsCronService.executeJob()` 内の upsert は `NewsItem.bulkWrite([...], { ordered: false })` で 1 DB コマンドにバッチ化。`Promise.all(items.map(updateMany))` の並列 fan-out は項目数増加時に DB コネクションプール圧迫・IO 飽和を招くため採用しない
+
+**配信先への分散**:
+- cron を `'0 0 * * *'` + `randomSleep(0–5 時間)` に設定し、複数 GROWI インスタンスのリクエストを夜間 5 時間ウィンドウに均等分散する
+- `'0 1 * * *'` + 5 分窓と比較して **約 60 倍の希釈**。GitHub Pages の月間 100GB 帯域クォータ・10 分 CDN キャッシュ TTL に対して thundering herd を回避できる
+- 即時性は不要(日次配信)であり、5 時間ウィンドウは UX への影響なし
+
+**フロントエンド再レンダ**:
+- `<NewsItem>` は `React.memo` ラップ。`useMergedInAppNotifications` のハンドラ群は `useCallback` で参照安定化されており、SWR が返す `item` 参照と組み合わせて、変化のないリスト項目は再レンダをスキップする

+ 19 - 3
.kiro/specs/news-inappnotification/requirements.md

@@ -14,12 +14,12 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 
 #### Acceptance Criteria
 
-1. When cron スケジュールの実行時刻に達した場合, the News Cron Service shall 設定された URL から JSON フィードを HTTP GET で取得する
+1. When cron スケジュールの実行時刻に達した場合, the News Cron Service shall コードに内蔵された配信元 URL から JSON フィードを HTTP GET で取得する
 2. When フィードの取得に成功した場合, the News Cron Service shall 取得したニュースアイテムをローカル MongoDB に upsert(`externalId` で重複排除)する
 3. When フィードに含まれなくなったニュースアイテムがある場合, the News Cron Service shall 該当アイテムをローカル DB から削除する
 4. When 複数の GROWI インスタンスが同時に取得を試みる場合, the News Cron Service shall ランダムスリープにより配信元へのリクエストを時間分散する
 5. If フィードの取得に失敗した場合, then the News Cron Service shall エラーをログに記録し、既存のキャッシュデータを維持する
-6. Where `NEWS_FEED_URL` が未設定または空の場合, the News Cron Service shall フィード取得をスキップしエラーなく動作する
+6. Where ニュース配信が無効化されている場合(`news:isDeliveryEnabled` が `false` の場合), the News Cron Service shall フィード取得をスキップしエラーなく動作する
 7. When ニュースアイテムに `growiVersionRegExps` 条件が設定されている場合, the News Cron Service shall 現在の GROWI バージョンと照合し、一致しないアイテムを除外する
 
 ### Requirement 2: ニュースアイテムのローカルキャッシュ
@@ -71,7 +71,7 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 1. The InAppNotificationパネル shall 通知とニュースを公開日時/作成日時の降順で混合した1つのリストとして表示する
 2. The InAppNotificationパネル shall 上部にフィルタボタン(「すべて」「通知」「お知らせ」)を配置し、デフォルトは「すべて」とする。「お知らせ」選択時はニュースのみ、「通知」選択時はニュース以外のすべての通知を表示する
 3. The InAppNotificationパネル shall 既存の「未読のみ」トグルスイッチを維持し、種別フィルタと組み合わせた2重フィルタリングを提供する。種別フィルタ(すべて/通知/お知らせ)で表示対象を絞り込んだ上で、トグルON時は未読アイテムのみをさらに絞り込む
-4. The InAppNotificationパネル shall リスト領域に最大高さを設定し、超過分はスクロールで表示する。スクロールが末端に達した場合は次のページを自動で読み込む無限スクロールとする
+4. The InAppNotificationパネル shall リスト領域のスクロールを提供し、末端に達した場合は次のページを自動で読み込む無限スクロールとする。スクロールの実現方法はサイドバーモードに依存する:collapsed モード(ホバーパネル)では最大高さ(`60vh`)を設定した内部スクロールコンテナを使用し、dock/drawer モード(全面サイドバー)では外側の SimpleBar コンテナにスクロールを委ねることで二重スクロールコンテナを回避する
 5. The InAppNotificationパネル shall ニュースアイテムの `emoji` フィールドをタイトル前に表示する。`emoji` 未設定の場合は 📢 をフォールバックとして使用する
 6. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall ニュースの詳細 URL を新しいタブで開く
 7. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall 該当ニュースを既読としてマークし、未読インジケータを更新する
@@ -106,3 +106,19 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 2. If ブラウザの言語に対応するテキストが存在しない場合, then the NewsItem コンポーネント shall `ja_JP` → `en_US` の順にフォールバックする
 3. The UI ラベル(「ニュース」「ニュースはありません。」等)shall `ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR` の i18n ロケールファイルで提供する
 4. The フィルタボタン用ラベル(「通知」「お知らせ」)shall 全対応言語のロケールファイルに追加する
+
+### Requirement 9: ニュース配信のオンオフ切替
+
+**Objective:** As a GROWI 管理者, I want ニュース配信のオンオフを管理画面から切り替えたい, so that 環境変数の編集や再起動なしにインスタンス単位で配信を停止/再開できる
+
+**Note:** 配信フラグは DB(`Config` コレクション)で管理し、admin が `/admin/app` UI から操作する。configManager + `defineConfig` + `defaultValue` の既存パターンを踏襲するが、**env からの上書き経路は意図的に持たない**(`envVarName` を設定しない)。これにより「インフラ側の env 注入」を不要にし、ニュース配信の意思は **DB のみ**で表現される。`defaultValue: true` をコードに内蔵することで、新規・既存インスタンスとも DB に値が無い状態で**デフォルト ON**が成立する。配信元 URL もコードにハードコードされており、ユーザー(admin 含む)・運用者ともに変更できない。pod 再起動は不要。
+
+#### Acceptance Criteria
+
+1. The configuration `news:isDeliveryEnabled` shall `defaultValue: true` を持ち、DB に値が無い場合は ON として扱われる
+2. The 設定値 shall configManager 経由で読み出される。env からの上書きは意図的にサポートしない(`envVarName` を設定しない)ため、優先順位は **DB > defaultValue** のみとなる
+3. When 管理者が `/admin/app` の UI からトグルを切り替えた場合, the GROWI shall `Config` コレクションの該当キーを更新し、再起動なしで設定値を反映する
+4. The 切替操作 shall admin 権限を持つユーザーのみに許可される
+5. When `news:isDeliveryEnabled` が `false` の場合, the News Cron Service shall 次回 cron 発火時にフィード取得をスキップする(既に取得済みの DB キャッシュは維持する)
+6. When `news:isDeliveryEnabled` が `true` に戻された場合, the News Cron Service shall 次回 cron 発火時に通常どおりフィード取得を再開する
+7. The 設定値 shall 環境変数として暴露されないため、`/admin` トップの「サーバー側で設定されている環境変数一覧」には決して現れない

+ 2 - 2
.kiro/specs/news-inappnotification/spec.json

@@ -15,8 +15,8 @@
     },
     "tasks": {
       "generated": true,
-      "approved": false
+      "approved": true
     }
   },
-  "ready_for_implementation": false
+  "ready_for_implementation": true
 }

+ 71 - 34
.kiro/specs/news-inappnotification/tasks.md

@@ -1,150 +1,187 @@
 # Implementation Plan
 
-- [ ] 0. 動作確認用ローカルフィードサーバーをセットアップする
+- [x] 0. 動作確認用ローカルフィードサーバーをセットアップする
   - `/tmp/feed.json` にサンプルフィードファイルを作成する。`emoji` あり・なし(未設定時は 📢 フォールバック確認)、`title`/`body` の多言語フィールド(`ja_JP`, `en_US`)、`url` あり・なし、`conditions.targetRoles`(admin のみ、全ユーザー)の両パターンを含む複数アイテムで構成する
   - devcontainer 内で `cd /tmp && python3 -m http.server 8099` を起動し、`http://localhost:8099/feed.json` でアクセスできることを確認する
   - `.env` に `NEWS_FEED_URL=http://localhost:8099/feed.json` を追加する
   - 以降のタスクで cron 動作確認が必要な場合はこのサーバーを使用する
   - _Requirements: 1.1, 1.6_
 
-- [ ] 1. データモデルを実装する
-- [ ] 1.1 (P) NewsItem モデルを実装する
+- [x] 1. データモデルを実装する
+- [x] 1.1 (P) NewsItem モデルを実装する
   - `externalId`(ユニークインデックス)、多言語 `title`/`body`(Map of String)、`emoji`、`url`、`publishedAt`(インデックス)、`fetchedAt`(TTL 90日インデックス)、`conditions.targetRoles` を持つ Mongoose スキーマを定義する
   - 型インターフェース `INewsItem` と `INewsItemHasId` を定義する
   - _Requirements: 2.1, 2.2, 2.3, 2.4_
 
-- [ ] 1.2 (P) NewsReadStatus モデルを実装する
+- [x] 1.2 (P) NewsReadStatus モデルを実装する
   - `userId`・`newsItemId` の複合ユニークインデックス、`readAt` を持つ Mongoose スキーマを定義する
   - 型インターフェース `INewsReadStatus` を定義する
   - _Requirements: 3.3_
 
-- [ ] 2. ニュースサービス層を実装する
-- [ ] 2.1 ニュース一覧取得ロジックを実装する
+- [x] 2. ニュースサービス層を実装する
+- [x] 2.1 ニュース一覧取得ロジックを実装する
   - `listForUser(userId, userRoles, { limit, offset, onlyUnread })` を実装する
   - `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返すロール別フィルタを適用する
   - NewsReadStatus との突き合わせにより各アイテムに `isRead: boolean` を付与する
   - 結果は `publishedAt` 降順で返す
   - _Requirements: 3.4, 4.1, 4.2_
 
-- [ ] 2.2 既読管理ロジックを実装する
+- [x] 2.2 既読管理ロジックを実装する
   - `markRead(userId, newsItemId)` を実装する。NewsReadStatus を upsert することで冪等性を保証する
   - `markAllRead(userId, userRoles)` を実装する。ロール別フィルタに合致する全未読アイテムを一括既読にする
   - `getUnreadCount(userId, userRoles)` を実装する
   - _Requirements: 3.1, 3.2, 3.5_
 
-- [ ] 2.3 フィード同期ロジックを実装する
+- [x] 2.3 フィード同期ロジックを実装する
   - `upsertNewsItems(items)` を実装する。`externalId` をキーに upsert し、`fetchedAt` を更新する
   - `deleteNewsItemsByExternalIds(externalIds)` を実装する
   - _Requirements: 1.2, 1.3_
 
-- [ ] 3. News API エンドポイントを実装する
-- [ ] 3.1 (P) ニュース取得エンドポイントを実装する
+- [x] 3. News API エンドポイントを実装する
+- [x] 3.1 (P) ニュース取得エンドポイントを実装する
   - `GET /apiv3/news/list`(`limit`, `offset`, `onlyUnread` クエリパラメータ)を実装する
   - `GET /apiv3/news/unread-count` を実装する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - _Requirements: 3.4, 3.5, 4.1, 4.2_
 
-- [ ] 3.2 (P) ニュース既読操作エンドポイントを実装する
+- [x] 3.2 (P) ニュース既読操作エンドポイントを実装する
   - `POST /apiv3/news/mark-read`(`newsItemId` を受け取る)を実装する。`newsItemId` を `mongoose.isValidObjectId()` で検証する
   - `POST /apiv3/news/mark-all-read` を実装する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - _Requirements: 3.1, 3.2_
 
-- [ ] 3.3 News API ルートをアプリに登録する
+- [x] 3.3 News API ルートをアプリに登録する
   - Express アプリの apiv3 ルーター定義に `news.ts` を追加する
   - _Requirements: 3.1, 3.4_
 
-- [ ] 4. NewsCronService を実装する
-- [ ] 4.1 (P) フィード取得・DB 同期処理を実装する
+- [x] 4. NewsCronService を実装する
+- [x] 4.1 (P) フィード取得・DB 同期処理を実装する
   - `CronService` を継承し `getCronSchedule()` で `'0 1 * * *'` を返す
   - `executeJob()` を実装する:`NEWS_FEED_URL` 未設定時はスキップ、HTTP GET、取得失敗時はログ記録のみ(既存データ維持)
   - 取得した各アイテムの `growiVersionRegExps` と現バージョンを照合し、不一致アイテムを除外する。不正 regex は try-catch でスキップしてログ警告する
   - フィード外のアイテムを DB から削除し、ランダムスリープ(0–5分)でリクエストを分散する
   - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
 
-- [ ] 4.2 cron をアプリ起動時に登録する
+- [x] 4.2 cron をアプリ起動時に登録する
   - アプリの初期化処理で `NewsCronService.startCron()` を呼ぶ
   - _Requirements: 1.1_
 
-- [ ] 5. フロントエンド SWR フックを実装する
-- [ ] 5.1 (P) ニュース用 SWR フックを新設する
+- [x] 5. フロントエンド SWR フックを実装する
+- [x] 5.1 (P) ニュース用 SWR フックを新設する
   - `useSWRINFxNews(limit, options)` を `useSWRInfinite` ベースで実装する。キーに `limit`, `pageIndex`, `onlyUnread` を含める
   - `useSWRxNewsUnreadCount()` を実装する
   - _Requirements: 5.4, 7.1_
 
-- [ ] 5.2 (P) InAppNotification 用の無限スクロール対応フックを追加する
+- [x] 5.2 (P) InAppNotification 用の無限スクロール対応フックを追加する
   - 既存 `useSWRxInAppNotifications`(`useSWR` ベース)に加えて `useSWRINFxInAppNotifications(limit, options)` を `useSWRInfinite` ベースで新設する
   - 既存フックは `InAppNotificationPage.tsx` での利用のため維持する
   - _Requirements: 5.4_
 
-- [ ] 6. InAppNotification パネルを改修する
-- [ ] 6.1 フィルタタブを追加する
+- [x] 6. InAppNotification パネルを改修する
+- [x] 6.1 フィルタタブを追加する
   - `InAppNotification.tsx` に `activeFilter: 'all' | 'news' | 'notifications'` の state(デフォルト `'all'`)を追加し、`InAppNotificationForms` と `InAppNotificationContent` へ prop として渡す
   - `InAppNotificationForms` に Bootstrap `btn-group` でフィルタボタン(「すべて」「通知」「お知らせ」)を追加する。既存「未読のみ」トグルは維持する
   - _Requirements: 5.2, 5.3_
 
-- [ ] 6.2 無限スクロールを導入する
+- [x] 6.2 無限スクロールを導入する
   - `InAppNotificationContent` で `useSWRINFxNews` と `useSWRINFxInAppNotifications` を使用するよう変更する
   - 既存の `InfiniteScroll` コンポーネントをラップしてリストを表示する
   - 既存の `// TODO: Infinite scroll implemented` コメントを解消する
   - _Requirements: 5.4_
 
-- [ ] 6.3 「すべて」フィルタ時のクライアントサイドマージを実装する
+- [x] 6.3 「すべて」フィルタ時のクライアントサイドマージを実装する
   - `activeFilter === 'all'` の場合、通知(`createdAt`)とニュース(`publishedAt`)を日時降順でマージして表示する
   - `activeFilter === 'news'` の場合は NewsItem のみ、`activeFilter === 'notifications'` の場合は InAppNotification のみ表示する
   - _Requirements: 5.1, 5.2_
 
-- [ ] 7. NewsItem コンポーネントを実装する
-- [ ] 7.1 (P) ニュースアイテムの表示コンポーネントを実装する
+- [x] 7. NewsItem コンポーネントを実装する
+- [x] 7.1 (P) ニュースアイテムの表示コンポーネントを実装する
   - `emoji` フィールドをタイトル前に表示する。未設定時は 📢 をフォールバックとする
   - 多言語タイトルをブラウザ言語で解決する。フォールバック順は `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
   - 未読時はタイトルを `fw-bold` + 左端に `bg-primary` 8px 丸ドット、既読時は `fw-normal` + 同幅の透明スペーサーで表示する
   - _Requirements: 5.5, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
 
-- [ ] 7.2 (P) ニュースアイテムのクリック処理を実装する
+- [x] 7.2 (P) ニュースアイテムのクリック処理を実装する
   - クリック時に `POST /apiv3/news/mark-read` を呼び、SWR キャッシュを mutate して未読インジケータを更新する
   - `url` が設定されている場合は新しいタブで開く
   - _Requirements: 5.6, 5.7_
 
-- [ ] 8. (P) 未読バッジにニュース未読数を合算する
+- [x] 8. (P) 未読バッジにニュース未読数を合算する
   - `PrimaryItemForNotification` で `useSWRxNewsUnreadCount` を呼び、既存の InAppNotification 未読カウントと合算してバッジに表示する
   - 全ニュースが既読の場合はニュース分のカウントを含めない
   - _Requirements: 7.1, 7.2_
 
-- [ ] 9. (P) i18n ロケールファイルを更新する
+- [x] 9. (P) i18n ロケールファイルを更新する
   - `commons.json` の `in_app_notification` 名前空間に以下のキーを全ロケール(`ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`)に追加する:`news`(お知らせ)、`notifications`(通知)、`all`(すべて)、`no_news`(ニュースはありません)
   - _Requirements: 8.3, 8.4_
 
-- [ ] 10. サーバーサイドテストを実装する
-- [ ] 10.1 NewsCronService のテストを実装する
+- [x] 10. サーバーサイドテストを実装する
+- [x] 10.1 NewsCronService のテストを実装する
   - `executeJob()` が正常取得時に upsert・削除を行うことを確認する
   - `NEWS_FEED_URL` 未設定時にスキップすることを確認する
   - フィード取得失敗時に DB データが変更されないことを確認する
   - `growiVersionRegExps` の一致・不一致・不正 regex の各ケースをテストする
   - _Requirements: 1.1, 1.2, 1.3, 1.5, 1.6, 1.7_
 
-- [ ] 10.2 NewsService のテストを実装する
+- [x] 10.2 NewsService のテストを実装する
   - `listForUser()` がロール別フィルタを正しく適用し `isRead` を付与することを確認する
   - `onlyUnread=true` で未読のみ返ることを確認する
   - `markRead()` の冪等性(2回呼んでもエラーなし)を確認する
   - `getUnreadCount()` が `markAllRead()` 後に 0 を返すことを確認する
   - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1, 4.2_
 
-- [ ] 10.3 News API 統合テストを実装する
+- [x] 10.3 News API 統合テストを実装する
   - `GET /apiv3/news/list` がロール別フィルタを強制することを確認する
   - `POST /apiv3/news/mark-read` が冪等であることを確認する
   - 未認証リクエストが 401 を返すことを確認する
   - _Requirements: 3.1, 3.4, 4.1_
 
-- [ ] 11. フロントエンドテストを実装する
-- [ ] 11.1 NewsItem コンポーネントのテストを実装する
+- [x] 11. フロントエンドテストを実装する
+- [x] 11.1 NewsItem コンポーネントのテストを実装する
   - `emoji` 未設定時に 📢 が表示されることをテストする
   - タイトルのロケールフォールバック(`browserLocale → ja_JP → en_US`)をテストする
   - 未読・既読の視覚表示(`fw-bold`、青ドット、スペーサー)をテストする
   - クリック時に `mark-read` が呼ばれ、`url` がある場合に新タブで開くことをテストする
   - _Requirements: 5.5, 5.6, 5.7, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
 
-- [ ]* 11.2 InAppNotification パネルのフィルタ動作をテストする
+- [x]* 11.2 InAppNotification パネルのフィルタ動作をテストする
   - フィルタタブ切り替えで表示対象が変わることを確認する(5.2 の AC カバレッジ)
   - 「未読のみ」トグルとの組み合わせで2重フィルタリングが機能することを確認する(5.3 の AC カバレッジ)
   - _Requirements: 5.2, 5.3_
+
+- [x] 12. 既存コードの不具合修正(実装後検証で発覚)
+- [x] 12.1 既存通知の未読ドットを修正する
+  - `InAppNotificationElm.tsx` の `grw-unopend-notification` クラスに対応する CSS 定義がコードベースに存在しないため、未読ドットが表示されない
+  - NewsItem と同様に `width/height/display: inline-block` のインラインスタイルを追加する
+  - _Requirements: 6.1_
+
+- [x] 12.2 全面サイドバー(② dock/drawer モード)での通知表示エリアを拡張する
+  - `InAppNotificationSubstance.tsx` の各フィルタ表示エリアに `style={{ maxHeight: '60vh' }}` が固定されており、② dock/drawer モードでもホバーパネル(①)サイズに制限される
+  - `useSidebarMode()` で collapsed モードを判定し、collapsed 時のみ `maxHeight: '60vh'` を適用する。dock/drawer モードでは制約を外し、外側の SimpleBar コンテナによるスクロールに委ねる
+  - _Requirements: 5.1_
+
+- [x] 12.3 アプリ内通知の未読ドットをクリック時に即時消去する
+  - `InAppNotificationSubstance.tsx` の `handleNotificationRead` で `useSWRInfinite` の `mutate(updater, { revalidate: false })` を使って既読状態をキャッシュに書き込もうとしていたが、ナビゲーション(`<a href>`)によってコンポーネントがアンマウントされた後に `useSWRInfinite` のページ単位キャッシュが古い状態に戻るため、ドットが再表示される
+  - `useState<Set<string>>` でローカルに開封済み通知 ID を管理し、各 `InAppNotificationElm` のレンダリング時に `status` をその場でオーバーライドすることで、SWR キャッシュに依存せず即時反映を実現する
+  - _Requirements: 6.1, 6.2_
+
+- [x] 13. PR レビュー FB 対応によるコード品質改善
+- [x] 13.1 型アサーションを排除する(FB ①)
+  - `interfaces/in-app-notification.ts` に `IInAppNotificationHasId = IInAppNotification & HasObjectId` を追加
+  - `stores/in-app-notification.ts` で `apiv3Get<InAppNotificationPaginateResult>()` にジェネリクスを注入し、`response.data as ...` を削除
+  - `InAppNotificationSubstance.tsx` の `allModeSWRResponse` を `SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>` として明示的に宣言し、`as unknown as Parameters<typeof InfiniteScroll>[0]...` を撤去
+  - `notificationResponse.data.flatMap(...) as (IInAppNotification & HasObjectId)[]` の cast を削除(型情報が自然に流れる)
+  - _Requirements: 品質改善_
+- [x] 13.2 SWR state-less による未読ドット即時消去へ差し替える(FB ②)
+  - 12.3 で採用した `useState<Set<string>>` を撤去し、`notificationResponse.mutate((pages) => ..., { revalidate: false })` による SWR ネイティブの楽観更新に置換
+  - `SWRConfig` プロバイダのキャッシュ Map がアンマウント/リマウントを跨いで保持されるため、再マウント時もドットは消えたまま(実機検証済み)
+  - SWR のキャッシュ・hook の利点を損なわない実装とする
+  - _Requirements: 品質改善, 6.1, 6.2_
+- [x] 13.3 NewsItem の言語ユーティリティと Bootstrap クラスを既存パターンに統一する(FB ③)
+  - `navigator.language` の独自ロジックを撤去し、`useTranslation()` の `i18n.language` を使用(`ActivityListItem` と同パターン)
+  - 日付表示を `date-fns` `format` + `getLocale(i18n.language)` に統一
+  - button のインラインスタイル(`cursor/width/textAlign/background`)を Bootstrap クラス `w-100 text-start bg-transparent` に置換
+  - emoji span の `fontSize/lineHeight` を `fs-5 lh-1` に置換
+  - 未読ドットのインラインスタイルを `UnreadDot.module.scss` の共通 CSS Module に抽出し、`NewsItem.tsx` と `InAppNotificationElm.tsx` の両者から参照して見た目を統一
+  - `browserLanguage` prop を廃止し、テストも i18n モックへ合わせて更新
+  - _Requirements: 品質改善_

+ 7 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "Your upgrade has been successfully completed! Exit maintenance mode and GROWI can be used.",
     "migration_failed": "Upgrade failed. Please refer to the GROWI docs for information on what to do in the event of failure."
   },
+  "news_delivery": {
+    "section_title": "News delivery",
+    "label": "News delivery",
+    "enable": "Enable news delivery",
+    "description": "Controls whether the cron job pulls the news feed and updates the local cache. Existing cached items remain visible while delivery is disabled.",
+    "update_succeeded": "News delivery setting updated"
+  },
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
     "under_maintenance_mode": "Under Maintenance Mode",

+ 9 - 3
apps/app/public/static/locales/en_US/commons.json

@@ -58,7 +58,11 @@
     "unopend": "Unread",
     "mark_all_as_read": "Mark all as read",
     "no_unread_messages": "no_unread_messages",
-    "only_unread": "Only unread"
+    "only_unread": "Only unread",
+    "news": "News",
+    "notifications": "Notifications",
+    "filter_all": "All",
+    "no_news": "No news available"
   },
   "personal_dropdown": {
     "home": "Home",
@@ -167,7 +171,8 @@
         "share_link": "Grants permission to view share link features.",
         "bookmark": "Grants permission to view bookmark features.",
         "attachment": "Grants permission to view attachment features.",
-        "page_bulk_export": "Grants permission to view page bulk export features."
+        "page_bulk_export": "Grants permission to view page bulk export features.",
+        "in_app_notification": "Grants permission to view in-app notification features."
       }
     },
     "write": {
@@ -212,7 +217,8 @@
         "share_link": "Grants permission to edit share link features.",
         "bookmark": "Grants permission to edit bookmark features.",
         "attachment": "Grants permission to edit attachment features.",
-        "page_bulk_export": "Grants permission to edit page bulk export features."
+        "page_bulk_export": "Grants permission to edit page bulk export features.",
+        "in_app_notification": "Grants permission to edit in-app notification features."
       }
     }
   }

+ 7 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "Conversion réussie! Le mode maintenance peut être désactivée et GROWI utilisé.",
     "migration_failed": "Conversion échouée. Lire la documentation GROWI pour des informations supplémentaires."
   },
+  "news_delivery": {
+    "section_title": "Diffusion des actualités",
+    "label": "Diffusion des actualités",
+    "enable": "Activer la diffusion des actualités",
+    "description": "Contrôle si la tâche cron récupère le flux d'actualités et met à jour le cache local. Les actualités déjà en cache restent visibles lorsque la diffusion est désactivée.",
+    "update_succeeded": "Paramètre de diffusion des actualités mis à jour"
+  },
   "maintenance_mode": {
     "maintenance_mode": "Mode maintenance",
     "under_maintenance_mode": "Mode maintenance activé",

+ 9 - 3
apps/app/public/static/locales/fr_FR/commons.json

@@ -59,7 +59,11 @@
     "all": "Toutes",
     "unopend": "Non-lues",
     "mark_all_as_read": "Tout marquer comme lu",
-    "no_unread_messages": "aucun message non lu"
+    "no_unread_messages": "aucun message non lu",
+    "news": "Actualités",
+    "notifications": "Notifications",
+    "filter_all": "Tout",
+    "no_news": "Aucune actualité disponible"
   },
   "personal_dropdown": {
     "home": "Accueil",
@@ -168,7 +172,8 @@
         "share_link": "Accorde la permission de voir les fonctionnalités de lien de partage.",
         "bookmark": "Accorde la permission de voir les fonctionnalités de signet.",
         "attachment": "Accorde la permission de voir les fonctionnalités de pièce jointe.",
-        "page_bulk_export": "Accorde la permission de voir les fonctionnalités d'exportation en masse de pages."
+        "page_bulk_export": "Accorde la permission de voir les fonctionnalités d'exportation en masse de pages.",
+        "in_app_notification": "Accorde la permission de voir les fonctionnalités de notification intégrée à l'application."
       }
     },
     "write": {
@@ -213,7 +218,8 @@
         "share_link": "Accorde la permission de modifier les fonctionnalités de lien de partage.",
         "bookmark": "Accorde la permission de modifier les fonctionnalités de signet.",
         "attachment": "Accorde la permission de modifier les fonctionnalités de pièce jointe.",
-        "page_bulk_export": "Accorde la permission de modifier les fonctionnalités d'exportation en masse de pages."
+        "page_bulk_export": "Accorde la permission de modifier les fonctionnalités d'exportation en masse de pages.",
+        "in_app_notification": "Accorde la permission de modifier les fonctionnalités de notification intégrée à l'application."
       }
     }
   }

+ 7 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -341,6 +341,13 @@
     "migration_succeeded": "アップグレードが正常に完了しました!メンテナンスモードを終了して、GROWI を使用することができます。",
     "migration_failed": "アップグレードが失敗しました。失敗した場合の対処法は GROWI docs を参照してください。"
   },
+  "news_delivery": {
+    "section_title": "ニュース配信",
+    "label": "ニュース配信",
+    "enable": "ニュース配信を有効にする",
+    "description": "外部フィードからニュースを取得してローカルにキャッシュする cron ジョブの動作を制御します。無効にしてもキャッシュ済みのニュースは引き続き表示されます。",
+    "update_succeeded": "ニュース配信設定を更新しました"
+  },
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
     "under_maintenance_mode": "メンテナンスモード中",

+ 9 - 3
apps/app/public/static/locales/ja_JP/commons.json

@@ -61,7 +61,11 @@
     "unopend": "未読",
     "mark_all_as_read": "全て既読にする",
     "no_unread_messages": "未読はありません",
-    "only_unread": "未読のみ"
+    "only_unread": "未読のみ",
+    "news": "お知らせ",
+    "notifications": "通知",
+    "filter_all": "すべて",
+    "no_news": "ニュースはありません"
   },
   "personal_dropdown": {
     "home": "ホーム",
@@ -171,7 +175,8 @@
         "share_link": "共有リンク機能の閲覧権限を付与できます。",
         "bookmark": "ブックマーク機能の閲覧権限を付与できます。",
         "attachment": "添付ファイル機能の閲覧権限を付与できます。",
-        "page_bulk_export": "ページの一括エクスポート機能の閲覧権限を付与できます。"
+        "page_bulk_export": "ページの一括エクスポート機能の閲覧権限を付与できます。",
+        "in_app_notification": "アプリ内通知機能の閲覧権限を付与できます。"
       }
     },
     "write": {
@@ -216,7 +221,8 @@
         "share_link": "共有リンク機能の編集権限を付与できます。",
         "bookmark": "ブックマーク機能の編集権限を付与できます。",
         "attachment": "添付ファイル機能の編集権限を付与できます。",
-        "page_bulk_export": "ページの一括エクスポート機能の編集権限を付与できます。"
+        "page_bulk_export": "ページの一括エクスポート機能の編集権限を付与できます。",
+        "in_app_notification": "アプリ内通知機能の編集権限を付与できます。"
       }
     }
   }

+ 7 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -332,6 +332,13 @@
     "migration_succeeded": "업그레이드가 성공적으로 완료되었습니다! 유지 보수 모드를 종료하면 GROWI를 사용할 수 있습니다.",
     "migration_failed": "업그레이드 실패. 실패 시 수행할 작업에 대한 정보는 GROWI 문서를 참조하십시오."
   },
+  "news_delivery": {
+    "section_title": "뉴스 배포",
+    "label": "뉴스 배포",
+    "enable": "뉴스 배포 활성화",
+    "description": "외부 피드에서 뉴스를 가져와 로컬 캐시를 업데이트하는 cron 작업의 동작을 제어합니다. 비활성화해도 캐시된 뉴스는 계속 표시됩니다.",
+    "update_succeeded": "뉴스 배포 설정이 업데이트되었습니다"
+  },
   "maintenance_mode": {
     "maintenance_mode": "유지 보수 모드",
     "under_maintenance_mode": "유지 보수 모드 중",

+ 5 - 1
apps/app/public/static/locales/ko_KR/commons.json

@@ -58,7 +58,11 @@
     "unopend": "읽지 않음",
     "mark_all_as_read": "모두 읽음으로 표시",
     "no_unread_messages": "읽지 않은 메시지 없음",
-    "only_unread": "읽지 않은 메시지만"
+    "only_unread": "읽지 않은 메시지만",
+    "news": "공지사항",
+    "notifications": "알림",
+    "filter_all": "전체",
+    "no_news": "공지사항이 없습니다"
   },
   "personal_dropdown": {
     "home": "홈",

+ 7 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -341,6 +341,13 @@
     "migration_succeeded": "您的升级已经成功完成! 退出维护模式,可以使用GROWI。",
     "migration_failed": "升级失败。请参考GROWI的文档,了解在失败情况下该如何处理。"
   },
+  "news_delivery": {
+    "section_title": "新闻推送",
+    "label": "新闻推送",
+    "enable": "启用新闻推送",
+    "description": "控制 cron 任务是否拉取新闻订阅源并更新本地缓存。停用期间,已缓存的新闻仍可显示。",
+    "update_succeeded": "新闻推送设置已更新"
+  },
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
     "under_maintenance_mode": "在维护模式下",

+ 9 - 3
apps/app/public/static/locales/zh_CN/commons.json

@@ -61,7 +61,11 @@
     "unopend": "未读",
     "mark_all_as_read": "标记为已读",
     "no_unread_messages": "no_unread_messages",
-    "only_unread": "Only unread"
+    "only_unread": "Only unread",
+    "news": "公告",
+    "notifications": "通知",
+    "filter_all": "全部",
+    "no_news": "暂无公告"
   },
   "personal_dropdown": {
     "home": "家",
@@ -170,7 +174,8 @@
         "share_link": "授予查看共享链接功能的权限。",
         "bookmark": "授予查看书签功能的权限。",
         "attachment": "授予查看附件功能的权限。",
-        "page_bulk_export": "授予查看页面批量导出功能的权限。"
+        "page_bulk_export": "授予查看页面批量导出功能的权限。",
+        "in_app_notification": "授予查看应用内通知功能的权限。"
       }
     },
     "write": {
@@ -215,7 +220,8 @@
         "share_link": "授予编辑共享链接功能的权限。",
         "bookmark": "授予编辑书签功能的权限。",
         "attachment": "授予编辑附件功能的权限。",
-        "page_bulk_export": "授予编辑页面批量导出功能的权限。"
+        "page_bulk_export": "授予编辑页面批量导出功能的权限。",
+        "in_app_notification": "授予编辑应用内通知功能的权限。"
       }
     }
   }

+ 12 - 0
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/toastr';
+import { NewsDeliverySetting } from '~/features/news/client/components/admin/NewsDeliverySetting';
 import { useIsMaintenanceMode } from '~/states/global';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 import { toArrayIfNot } from '~/utils/array-utils';
@@ -133,6 +134,17 @@ const AppSettingsPageContents = (props: Props) => {
         </div>
       </div>
 
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header" id="news-delivery">
+            {t('admin:news_delivery.section_title', {
+              defaultValue: 'News delivery',
+            })}
+          </h2>
+          <NewsDeliverySetting />
+        </div>
+      </div>
+
       <div className="row">
         <div className="col-lg-12">
           <h2 className="admin-setting-header" id="maintenance-mode">

+ 6 - 4
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -10,6 +10,8 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 import { useModelNotification } from './ModelNotification';
 
+import unreadDotStyles from './UnreadDot.module.scss';
+
 interface Props {
   notification: IInAppNotification & HasObjectId;
   onUnopenedNotificationOpend?: () => void;
@@ -77,10 +79,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
           <span
             className={`${
               notification.status === InAppNotificationStatuses.STATUS_UNOPENED
-                ? 'grw-unopend-notification'
-                : 'ms-2'
-            } rounded-circle me-3`}
-          ></span>
+                ? 'bg-primary'
+                : ''
+            } rounded-circle me-3 ${unreadDotStyles['unread-dot']}`}
+          />
 
           {renderActionUserPictures()}
 

+ 6 - 0
apps/app/src/client/components/InAppNotification/UnreadDot.module.scss

@@ -0,0 +1,6 @@
+.unread-dot {
+  display: inline-block;
+  width: 8px;
+  min-width: 8px;
+  height: 8px;
+}

+ 9 - 3
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -1,13 +1,15 @@
-import React, { type JSX, Suspense, useState } from 'react';
+import { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
-import { InAppNotificationForms } from './InAppNotificationSubstance';
+import { InAppNotificationForms } from './InAppNotificationForms';
+
+export type FilterType = 'all' | 'news' | 'notifications';
 
 const InAppNotificationContent = dynamic(
   () =>
-    import('./InAppNotificationSubstance').then(
+    import('./InAppNotificationContent').then(
       (mod) => mod.InAppNotificationContent,
     ),
   { ssr: false },
@@ -18,6 +20,7 @@ export const InAppNotification = (): JSX.Element => {
 
   const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] =
     useState(false);
+  const [activeFilter, setActiveFilter] = useState<FilterType>('all');
 
   return (
     <div className="px-3">
@@ -30,11 +33,14 @@ export const InAppNotification = (): JSX.Element => {
         onChangeUnopendNotificationsVisible={() => {
           setUnopendNotificationsVisible(!isUnopendNotificationsVisible);
         }}
+        activeFilter={activeFilter}
+        onChangeFilter={setActiveFilter}
       />
 
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
         <InAppNotificationContent
           isUnopendNotificationsVisible={isUnopendNotificationsVisible}
+          activeFilter={activeFilter}
         />
       </Suspense>
     </div>

+ 138 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationContent.tsx

@@ -0,0 +1,138 @@
+import type { JSX } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import InAppNotificationElm from '~/client/components/InAppNotification/InAppNotificationElm';
+import InfiniteScroll from '~/client/components/InfiniteScroll';
+import { NewsItem } from '~/features/news/client/components/NewsItem';
+import { useSidebarMode } from '~/states/ui/sidebar';
+
+import { useMergedInAppNotifications } from './hooks/useMergedInAppNotifications';
+import type { FilterType } from './InAppNotification';
+
+type InAppNotificationContentProps = {
+  isUnopendNotificationsVisible: boolean;
+  activeFilter: FilterType;
+};
+
+export const InAppNotificationContent = (
+  props: InAppNotificationContentProps,
+): JSX.Element => {
+  const { isUnopendNotificationsVisible, activeFilter } = props;
+  const { t } = useTranslation('commons');
+  const { isCollapsedMode } = useSidebarMode();
+
+  // In collapsed mode (hover panel): constrain height + own scrollbar.
+  // In dock/drawer mode: no constraints — outer SimpleBar handles all scrolling.
+  const collapsed = isCollapsedMode();
+  const scrollAreaClassName = collapsed ? 'overflow-auto' : undefined;
+  const scrollAreaStyle = collapsed ? { maxHeight: '60vh' } : undefined;
+
+  const {
+    newsResponse,
+    allNewsItems,
+    newsExhausted,
+    notificationResponse,
+    allNotificationItems,
+    notifExhausted,
+    allModeSWRResponse,
+    mergedItems,
+    handleReadMutate,
+    handleNotificationRead,
+  } = useMergedInAppNotifications(isUnopendNotificationsVisible);
+
+  if (activeFilter === 'news') {
+    if (allNewsItems.length === 0 && !newsResponse.isValidating) {
+      return <>{t('in_app_notification.no_news')}</>;
+    }
+
+    return (
+      <div className={scrollAreaClassName} style={scrollAreaStyle}>
+        <InfiniteScroll
+          swrInifiniteResponse={newsResponse}
+          isReachingEnd={newsExhausted}
+        >
+          <div className="list-group">
+            {allNewsItems.map((item) => (
+              <NewsItem
+                key={item._id.toString()}
+                item={item}
+                onReadMutate={handleReadMutate}
+              />
+            ))}
+          </div>
+        </InfiniteScroll>
+      </div>
+    );
+  }
+
+  if (activeFilter === 'notifications') {
+    if (
+      allNotificationItems.length === 0 &&
+      !notificationResponse.isValidating
+    ) {
+      return <>{t('in_app_notification.no_notification')}</>;
+    }
+
+    return (
+      <div className={scrollAreaClassName} style={scrollAreaStyle}>
+        <InfiniteScroll
+          swrInifiniteResponse={notificationResponse}
+          isReachingEnd={notifExhausted}
+        >
+          <div className="list-group">
+            {allNotificationItems.map((notification) => {
+              const id = notification._id.toString();
+              return (
+                <InAppNotificationElm
+                  key={id}
+                  notification={notification}
+                  onUnopenedNotificationOpend={() => handleNotificationRead(id)}
+                />
+              );
+            })}
+          </div>
+        </InfiniteScroll>
+      </div>
+    );
+  }
+
+  // 'all' filter: merged view
+  if (
+    mergedItems.length === 0 &&
+    !newsResponse.isValidating &&
+    !notificationResponse.isValidating
+  ) {
+    return <>{t('in_app_notification.no_notification')}</>;
+  }
+
+  return (
+    <div className={scrollAreaClassName} style={scrollAreaStyle}>
+      <InfiniteScroll
+        swrInifiniteResponse={allModeSWRResponse}
+        isReachingEnd={newsExhausted && notifExhausted}
+      >
+        <div className="list-group">
+          {mergedItems.map((entry) => {
+            if (entry.type === 'news') {
+              return (
+                <NewsItem
+                  key={`news-${entry.item._id.toString()}`}
+                  item={entry.item}
+                  onReadMutate={handleReadMutate}
+                />
+              );
+            }
+            const id = entry.item._id.toString();
+            return (
+              <InAppNotificationElm
+                key={`notif-${id}`}
+                notification={entry.item}
+                onUnopenedNotificationOpend={() => handleNotificationRead(id)}
+              />
+            );
+          })}
+        </div>
+      </InfiniteScroll>
+    </div>
+  );
+};

+ 97 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.spec.tsx

@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}));
+
+import { InAppNotificationForms } from './InAppNotificationForms';
+
+describe('InAppNotificationForms', () => {
+  const defaultProps = {
+    isUnopendNotificationsVisible: false,
+    onChangeUnopendNotificationsVisible: vi.fn(),
+    activeFilter: 'all' as const,
+    onChangeFilter: vi.fn(),
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  test('should render three filter buttons', () => {
+    render(<InAppNotificationForms {...defaultProps} />);
+    expect(screen.getByText('in_app_notification.filter_all')).toBeTruthy();
+    expect(screen.getByText('in_app_notification.notifications')).toBeTruthy();
+    expect(screen.getByText('in_app_notification.news')).toBeTruthy();
+  });
+
+  test('should call onChangeFilter with "news" when news button clicked', () => {
+    const onChangeFilter = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        onChangeFilter={onChangeFilter}
+      />,
+    );
+    fireEvent.click(screen.getByText('in_app_notification.news'));
+    expect(onChangeFilter).toHaveBeenCalledWith('news');
+  });
+
+  test('should call onChangeFilter with "notifications" when notifications button clicked', () => {
+    const onChangeFilter = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        onChangeFilter={onChangeFilter}
+      />,
+    );
+    fireEvent.click(screen.getByText('in_app_notification.notifications'));
+    expect(onChangeFilter).toHaveBeenCalledWith('notifications');
+  });
+
+  test('should call onChangeFilter with "all" when all button clicked', () => {
+    const onChangeFilter = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        activeFilter="news"
+        onChangeFilter={onChangeFilter}
+      />,
+    );
+    fireEvent.click(screen.getByText('in_app_notification.filter_all'));
+    expect(onChangeFilter).toHaveBeenCalledWith('all');
+  });
+
+  test('should render unread toggle', () => {
+    render(<InAppNotificationForms {...defaultProps} />);
+    const toggle = screen.getByRole('switch');
+    expect(toggle).toBeTruthy();
+  });
+
+  test('should call onChangeUnopendNotificationsVisible when toggle changes', () => {
+    const onChange = vi.fn();
+    render(
+      <InAppNotificationForms
+        {...defaultProps}
+        onChangeUnopendNotificationsVisible={onChange}
+      />,
+    );
+    const toggle = screen.getByRole('switch');
+    fireEvent.click(toggle);
+    expect(onChange).toHaveBeenCalled();
+  });
+
+  test('active filter button should have btn-primary class', () => {
+    render(<InAppNotificationForms {...defaultProps} activeFilter="news" />);
+    const newsBtn = screen
+      .getByText('in_app_notification.news')
+      .closest('button');
+    expect(newsBtn?.classList.contains('btn-primary')).toBe(true);
+    const allBtn = screen
+      .getByText('in_app_notification.filter_all')
+      .closest('button');
+    expect(allBtn?.classList.contains('btn-outline-secondary')).toBe(true);
+  });
+});

+ 69 - 0
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.tsx

@@ -0,0 +1,69 @@
+import { type JSX, useId } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import type { FilterType } from './InAppNotification';
+
+type InAppNotificationFormsProps = {
+  isUnopendNotificationsVisible: boolean;
+  onChangeUnopendNotificationsVisible: () => void;
+  activeFilter: FilterType;
+  onChangeFilter: (filter: FilterType) => void;
+};
+
+export const InAppNotificationForms = (
+  props: InAppNotificationFormsProps,
+): JSX.Element => {
+  const {
+    isUnopendNotificationsVisible,
+    onChangeUnopendNotificationsVisible,
+    activeFilter,
+    onChangeFilter,
+  } = props;
+  const { t } = useTranslation('commons');
+  const toggleId = useId();
+
+  return (
+    <div className="my-2">
+      {/* Filter tabs */}
+      <fieldset className="btn-group w-100 mb-2">
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'all' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('all')}
+        >
+          {t('in_app_notification.filter_all')}
+        </button>
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'notifications' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('notifications')}
+        >
+          {t('in_app_notification.notifications')}
+        </button>
+        <button
+          type="button"
+          className={`btn btn-sm ${activeFilter === 'news' ? 'btn-primary' : 'btn-outline-secondary'}`}
+          onClick={() => onChangeFilter('news')}
+        >
+          {t('in_app_notification.news')}
+        </button>
+      </fieldset>
+
+      {/* Unread-only toggle */}
+      <div className="form-check form-switch">
+        <label className="form-check-label" htmlFor={toggleId}>
+          {t('in_app_notification.only_unread')}
+        </label>
+        <input
+          id={toggleId}
+          className="form-check-input"
+          type="checkbox"
+          role="switch"
+          aria-checked={isUnopendNotificationsVisible}
+          checked={isUnopendNotificationsVisible}
+          onChange={onChangeUnopendNotificationsVisible}
+        />
+      </div>
+    </div>
+  );
+};

+ 0 - 74
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -1,74 +0,0 @@
-import React, { type JSX } from 'react';
-import { useTranslation } from 'next-i18next';
-
-import InAppNotificationList from '~/client/components/InAppNotification/InAppNotificationList';
-import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
-import { useSWRxInAppNotifications } from '~/stores/in-app-notification';
-
-type InAppNotificationFormsProps = {
-  isUnopendNotificationsVisible: boolean;
-  onChangeUnopendNotificationsVisible: () => void;
-};
-export const InAppNotificationForms = (
-  props: InAppNotificationFormsProps,
-): JSX.Element => {
-  const { isUnopendNotificationsVisible, onChangeUnopendNotificationsVisible } =
-    props;
-  const { t } = useTranslation('commons');
-
-  return (
-    <div className="my-2">
-      <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">
-          {t('in_app_notification.only_unread')}
-        </label>
-        <input
-          id="flexSwitchCheckDefault"
-          className="form-check-input"
-          type="checkbox"
-          role="switch"
-          aria-checked={isUnopendNotificationsVisible}
-          checked={isUnopendNotificationsVisible}
-          onChange={onChangeUnopendNotificationsVisible}
-        />
-      </div>
-    </div>
-  );
-};
-
-type InAppNotificationContentProps = {
-  isUnopendNotificationsVisible: boolean;
-};
-export const InAppNotificationContent = (
-  props: InAppNotificationContentProps,
-): JSX.Element => {
-  const { isUnopendNotificationsVisible } = props;
-  const { t } = useTranslation('commons');
-
-  // TODO: Infinite scroll implemented (https://redmine.weseek.co.jp/issues/138057)
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } =
-    useSWRxInAppNotifications(
-      6,
-      undefined,
-      isUnopendNotificationsVisible
-        ? InAppNotificationStatuses.STATUS_UNOPENED
-        : undefined,
-      { keepPreviousData: true },
-    );
-
-  return (
-    <>
-      {inAppNotificationData != null &&
-      inAppNotificationData.docs.length === 0 ? (
-        // no items
-        t('in_app_notification.no_notification')
-      ) : (
-        // render list-group
-        <InAppNotificationList
-          inAppNotificationData={inAppNotificationData}
-          onUnopenedNotificationOpend={mutateInAppNotificationData}
-        />
-      )}
-    </>
-  );
-};

+ 5 - 4
apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx

@@ -1,5 +1,6 @@
 import { memo, useCallback, useEffect } from 'react';
 
+import { useSWRxNewsUnreadCount } from '~/features/news/client/hooks/use-news';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useGlobalSocket } from '~/states/socket-io';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
@@ -20,10 +21,10 @@ export const PrimaryItemForNotification = memo(
     const { data: notificationCount, mutate: mutateNotificationCount } =
       useSWRxInAppNotificationStatus();
 
-    const badgeContents =
-      notificationCount != null && notificationCount > 0
-        ? notificationCount
-        : undefined;
+    const { data: newsUnreadCount } = useSWRxNewsUnreadCount();
+
+    const totalUnread = (notificationCount ?? 0) + (newsUnreadCount ?? 0);
+    const badgeContents = totalUnread > 0 ? totalUnread : undefined;
 
     const itemHoverHandler = useCallback(
       (contents: SidebarContentsType) => {

+ 201 - 0
apps/app/src/client/components/Sidebar/InAppNotification/hooks/useMergedInAppNotifications.ts

@@ -0,0 +1,201 @@
+import { useCallback, useMemo } from 'react';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+
+import {
+  useSWRINFxNews,
+  useSWRxNewsUnreadCount,
+} from '~/features/news/client/hooks/use-news';
+import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
+import type {
+  IInAppNotificationHasId,
+  PaginateResult,
+} from '~/interfaces/in-app-notification';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { useSWRINFxInAppNotifications } from '~/stores/in-app-notification';
+
+const PER_PAGE = 10;
+
+export type MergedItem =
+  | { type: 'news'; item: INewsItemWithReadStatus; sortKey: Date }
+  | { type: 'notification'; item: IInAppNotificationHasId; sortKey: Date };
+
+export type UseMergedInAppNotificationsResult = {
+  newsResponse: SWRInfiniteResponse<
+    PaginateResult<INewsItemWithReadStatus>,
+    Error
+  >;
+  allNewsItems: INewsItemWithReadStatus[];
+  newsExhausted: boolean;
+
+  notificationResponse: SWRInfiniteResponse<
+    PaginateResult<IInAppNotificationHasId>,
+    Error
+  >;
+  allNotificationItems: IInAppNotificationHasId[];
+  notifExhausted: boolean;
+
+  allModeSWRResponse: SWRInfiniteResponse<
+    PaginateResult<INewsItemWithReadStatus>,
+    Error
+  >;
+  mergedItems: MergedItem[];
+
+  handleReadMutate: () => void;
+  handleNotificationRead: (notificationId: string) => void;
+};
+
+/**
+ * Encapsulates the data layer for the InAppNotification sidebar panel:
+ * - Two SWRInfinite streams (news + notifications)
+ * - Pagination exhaustion detection
+ * - A synthetic SWRInfiniteResponse for the merged "all" view
+ * - Client-side merge + sort by time
+ * - Read-state mutation handlers (SWR-native optimistic update)
+ */
+export const useMergedInAppNotifications = (
+  isUnopendNotificationsVisible: boolean,
+): UseMergedInAppNotificationsResult => {
+  const notificationStatus = isUnopendNotificationsVisible
+    ? InAppNotificationStatuses.STATUS_UNOPENED
+    : undefined;
+
+  const newsResponse = useSWRINFxNews(
+    PER_PAGE,
+    { onlyUnread: isUnopendNotificationsVisible },
+    { keepPreviousData: true },
+  );
+  const { mutate: mutateNewsUnreadCount } = useSWRxNewsUnreadCount();
+
+  const notificationResponse = useSWRINFxInAppNotifications(
+    PER_PAGE,
+    { status: notificationStatus },
+    { keepPreviousData: true },
+  );
+
+  const allNewsItems: INewsItemWithReadStatus[] = useMemo(() => {
+    if (!newsResponse.data) return [];
+    return newsResponse.data.flatMap((page) => page.docs);
+  }, [newsResponse.data]);
+
+  const allNotificationItems: IInAppNotificationHasId[] = useMemo(() => {
+    if (!notificationResponse.data) return [];
+    return notificationResponse.data.flatMap((page) => page.docs);
+  }, [notificationResponse.data]);
+
+  const newsExhausted = useMemo(
+    () =>
+      newsResponse.data != null &&
+      newsResponse.data.length > 0 &&
+      !newsResponse.data[newsResponse.data.length - 1].hasNextPage,
+    [newsResponse.data],
+  );
+
+  const notifExhausted = useMemo(
+    () =>
+      notificationResponse.data != null &&
+      notificationResponse.data.length > 0 &&
+      !notificationResponse.data[notificationResponse.data.length - 1]
+        .hasNextPage,
+    [notificationResponse.data],
+  );
+
+  // Synthetic SWRInfiniteResponse for InfiniteScroll in 'all' mode.
+  // Typed to match newsResponse's shape so InfiniteScroll<E> receives a
+  // well-typed response without `as unknown as` casts.
+  const allModeSWRResponse = useMemo<
+    SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>
+  >(
+    () => ({
+      data: newsResponse.data,
+      error: newsResponse.error ?? notificationResponse.error,
+      isValidating:
+        newsResponse.isValidating || notificationResponse.isValidating,
+      isLoading: newsResponse.isLoading || notificationResponse.isLoading,
+      mutate: newsResponse.mutate,
+      setSize: async (updater) => {
+        const nextNewsSize =
+          typeof updater === 'function' ? updater(newsResponse.size) : updater;
+        const nextNotifSize =
+          typeof updater === 'function'
+            ? updater(notificationResponse.size)
+            : updater;
+        const [newsResult] = await Promise.all([
+          newsExhausted
+            ? Promise.resolve(newsResponse.data)
+            : newsResponse.setSize(nextNewsSize),
+          notifExhausted
+            ? Promise.resolve(notificationResponse.data)
+            : notificationResponse.setSize(nextNotifSize),
+        ]);
+        return newsResult;
+      },
+      size: Math.max(newsResponse.size, notificationResponse.size),
+    }),
+    [newsResponse, notificationResponse, newsExhausted, notifExhausted],
+  );
+
+  const mergedItems: MergedItem[] = useMemo(() => {
+    const newsEntries: MergedItem[] = allNewsItems.map((item) => ({
+      type: 'news',
+      item,
+      sortKey:
+        item.publishedAt instanceof Date
+          ? item.publishedAt
+          : new Date(item.publishedAt),
+    }));
+    const notifEntries: MergedItem[] = allNotificationItems.map((item) => ({
+      type: 'notification',
+      item,
+      sortKey:
+        item.createdAt instanceof Date
+          ? item.createdAt
+          : new Date(item.createdAt),
+    }));
+    return [...newsEntries, ...notifEntries].sort(
+      (a, b) => b.sortKey.getTime() - a.sortKey.getTime(),
+    );
+  }, [allNewsItems, allNotificationItems]);
+
+  // SWR's mutate is stable per cache key — destructure once and depend on it
+  // rather than the whole response object (which may carry unstable identity).
+  const { mutate: mutateNews } = newsResponse;
+  const { mutate: mutateNotifications } = notificationResponse;
+
+  const handleReadMutate = useCallback(() => {
+    mutateNews();
+    mutateNewsUnreadCount();
+  }, [mutateNews, mutateNewsUnreadCount]);
+
+  // SWR-idiomatic optimistic update: rewrite the per-page cache in place and
+  // suppress revalidation so the dot stays removed across unmount/remount.
+  const handleNotificationRead = useCallback(
+    (notificationId: string) => {
+      mutateNotifications(
+        (pages) =>
+          pages?.map((page) => ({
+            ...page,
+            docs: page.docs.map((doc) =>
+              doc._id.toString() === notificationId
+                ? { ...doc, status: InAppNotificationStatuses.STATUS_OPENED }
+                : doc,
+            ),
+          })),
+        { revalidate: false },
+      );
+    },
+    [mutateNotifications],
+  );
+
+  return {
+    newsResponse,
+    allNewsItems,
+    newsExhausted,
+    notificationResponse,
+    allNotificationItems,
+    notifExhausted,
+    allModeSWRResponse,
+    mergedItems,
+    handleReadMutate,
+    handleNotificationRead,
+  };
+};

+ 191 - 0
apps/app/src/features/news/client/components/NewsItem.spec.tsx

@@ -0,0 +1,191 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import mongoose from 'mongoose';
+
+const mocks = vi.hoisted(() => {
+  const apiv3Post = vi.fn().mockResolvedValue({});
+  const mutate = vi.fn();
+  const i18nLanguage = { current: 'ja_JP' };
+  return { apiv3Post, mutate, i18nLanguage };
+});
+
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Post: mocks.apiv3Post,
+}));
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+    i18n: {
+      get language() {
+        return mocks.i18nLanguage.current;
+      },
+    },
+  }),
+}));
+
+// Mock window.open
+const mockOpen = vi.fn();
+vi.stubGlobal('open', mockOpen);
+
+import type { INewsItemWithReadStatus } from '../../interfaces/news-item';
+import { NewsItem } from './NewsItem';
+
+const makeNewsItem = (
+  overrides: Partial<INewsItemWithReadStatus> = {},
+): INewsItemWithReadStatus => ({
+  _id: new mongoose.Types.ObjectId(),
+  externalId: 'test-001',
+  title: { ja_JP: 'テストニュース', en_US: 'Test News' },
+  publishedAt: new Date('2026-01-01T00:00:00Z'),
+  fetchedAt: new Date(),
+  isRead: false,
+  ...overrides,
+});
+
+describe('NewsItem', () => {
+  const onReadMutate = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mocks.i18nLanguage.current = 'ja_JP';
+  });
+
+  describe('emoji display', () => {
+    test('should display emoji when provided', () => {
+      const item = makeNewsItem({ emoji: '🚀' });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('🚀')).toBeTruthy();
+    });
+
+    test('should display 📢 fallback when emoji is not set', () => {
+      const item = makeNewsItem({ emoji: undefined });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('📢')).toBeTruthy();
+    });
+  });
+
+  describe('locale fallback', () => {
+    test('should display ja_JP title when i18n language is ja_JP', () => {
+      mocks.i18nLanguage.current = 'ja_JP';
+      const item = makeNewsItem({
+        title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
+      });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('日本語タイトル')).toBeTruthy();
+    });
+
+    test('should fallback to ja_JP when i18n language has no match', () => {
+      mocks.i18nLanguage.current = 'de_DE';
+      const item = makeNewsItem({
+        title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
+      });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('日本語タイトル')).toBeTruthy();
+    });
+
+    test('should fallback to en_US when ja_JP is not available', () => {
+      mocks.i18nLanguage.current = 'de_DE';
+      const item = makeNewsItem({ title: { en_US: 'English Only' } });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('English Only')).toBeTruthy();
+    });
+
+    test('should fallback to first available key when neither ja_JP nor en_US', () => {
+      mocks.i18nLanguage.current = 'de_DE';
+      const item = makeNewsItem({ title: { fr_FR: 'Titre Français' } });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+      expect(screen.getByText('Titre Français')).toBeTruthy();
+    });
+  });
+
+  describe('unread/read visual styling', () => {
+    test('should apply fw-bold class for unread items', () => {
+      const item = makeNewsItem({ isRead: false });
+      const { container } = render(
+        <NewsItem item={item} onReadMutate={onReadMutate} />,
+      );
+      // Title should have fw-bold
+      const title = container.querySelector('.fw-bold');
+      expect(title).not.toBeNull();
+    });
+
+    test('should apply fw-normal class for read items', () => {
+      const item = makeNewsItem({ isRead: true });
+      const { container } = render(
+        <NewsItem item={item} onReadMutate={onReadMutate} />,
+      );
+      const title = container.querySelector('.fw-normal');
+      expect(title).not.toBeNull();
+    });
+
+    test('should show unread dot for unread items', () => {
+      const item = makeNewsItem({ isRead: false });
+      const { container } = render(
+        <NewsItem item={item} onReadMutate={onReadMutate} />,
+      );
+      const dot = container.querySelector('.bg-primary.rounded-circle');
+      expect(dot).not.toBeNull();
+    });
+  });
+
+  describe('click handling', () => {
+    test('should call mark-read API when clicked', async () => {
+      const item = makeNewsItem({ isRead: false });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      const element = screen.getByRole('button');
+      fireEvent.click(element);
+
+      // Wait for async
+      await vi.waitFor(() => {
+        expect(mocks.apiv3Post).toHaveBeenCalledWith(
+          '/news/mark-read',
+          expect.objectContaining({ newsItemId: item._id.toString() }),
+        );
+      });
+    });
+
+    test('should open URL in new tab when url is set', async () => {
+      const item = makeNewsItem({
+        url: 'https://github.com/growi',
+        isRead: false,
+      });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      const element = screen.getByRole('button');
+      fireEvent.click(element);
+
+      await vi.waitFor(() => {
+        expect(mockOpen).toHaveBeenCalledWith(
+          'https://github.com/growi',
+          '_blank',
+          'noopener,noreferrer',
+        );
+      });
+    });
+
+    test('should NOT open URL when url is not set', async () => {
+      const item = makeNewsItem({ url: undefined, isRead: false });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      const element = screen.getByRole('button');
+      fireEvent.click(element);
+
+      await vi.waitFor(() => {
+        expect(mocks.apiv3Post).toHaveBeenCalled();
+      });
+      expect(mockOpen).not.toHaveBeenCalled();
+    });
+
+    test('should call onReadMutate after marking as read', async () => {
+      const item = makeNewsItem({ isRead: false });
+      render(<NewsItem item={item} onReadMutate={onReadMutate} />);
+
+      fireEvent.click(screen.getByRole('button'));
+
+      await vi.waitFor(() => {
+        expect(onReadMutate).toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 83 - 0
apps/app/src/features/news/client/components/NewsItem.tsx

@@ -0,0 +1,83 @@
+import type { FC } from 'react';
+import { memo } from 'react';
+import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+
+import unreadDotStyles from '~/client/components/InAppNotification/UnreadDot.module.scss';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { getLocale } from '~/utils/locale-utils';
+
+import type { INewsItemWithReadStatus } from '../../interfaces/news-item';
+
+const DEFAULT_EMOJI = '📢';
+
+/**
+ * Resolve the title for the given locale with fallback chain:
+ * browserLocale → ja_JP → en_US → first available key
+ */
+const resolveTitle = (
+  title: Record<string, string>,
+  locale: string,
+): string => {
+  if (title[locale]) return title[locale];
+  if (title.ja_JP) return title.ja_JP;
+  if (title.en_US) return title.en_US;
+  const keys = Object.keys(title);
+  return keys.length > 0 ? title[keys[0]] : '';
+};
+
+type Props = {
+  item: INewsItemWithReadStatus;
+  onReadMutate: () => void;
+};
+
+const NewsItemInner: FC<Props> = ({ item, onReadMutate }) => {
+  const { i18n } = useTranslation();
+  const locale = i18n.language;
+  const title = resolveTitle(item.title, locale);
+  const emoji = item.emoji ?? DEFAULT_EMOJI;
+
+  const publishedDate =
+    item.publishedAt instanceof Date
+      ? item.publishedAt
+      : new Date(item.publishedAt);
+  const formattedDate = format(publishedDate, 'PP', {
+    locale: getLocale(locale),
+  });
+
+  const handleClick = async () => {
+    try {
+      await apiv3Post('/news/mark-read', { newsItemId: item._id.toString() });
+      onReadMutate();
+    } catch {
+      // silently ignore mark-read failures
+    }
+
+    if (item.url) {
+      window.open(item.url, '_blank', 'noopener,noreferrer');
+    }
+  };
+
+  return (
+    <button
+      type="button"
+      className="list-group-item list-group-item-action w-100 text-start bg-transparent"
+      onClick={handleClick}
+    >
+      <div className="d-flex align-items-center">
+        <span
+          className={`${item.isRead ? '' : 'bg-primary'} rounded-circle me-3 ${unreadDotStyles['unread-dot']}`}
+        />
+
+        <span className="me-2 fs-5 lh-1">{emoji}</span>
+
+        <div>
+          <span className={item.isRead ? 'fw-normal' : 'fw-bold'}>{title}</span>
+          <div className="text-muted small">{formattedDate}</div>
+        </div>
+      </div>
+    </button>
+  );
+};
+
+export const NewsItem = memo(NewsItemInner);

+ 77 - 0
apps/app/src/features/news/client/components/admin/NewsDeliverySetting.tsx

@@ -0,0 +1,77 @@
+import type { FC } from 'react';
+import { useCallback } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import loggerFactory from '~/utils/logger';
+
+import {
+  useSWRxNewsDeliverySetting,
+  useUpdateNewsDeliverySetting,
+} from '../../services/news-delivery-setting';
+
+const logger = loggerFactory('growi:feature:news:admin:NewsDeliverySetting');
+
+export const NewsDeliverySetting: FC = () => {
+  const { t } = useTranslation('admin');
+
+  const { data, isLoading } = useSWRxNewsDeliverySetting();
+  const updateDeliverySetting = useUpdateNewsDeliverySetting();
+
+  const isEnabled = data?.isDeliveryEnabled ?? true;
+
+  const onChange = useCallback(
+    async (event: React.ChangeEvent<HTMLInputElement>) => {
+      const next = event.target.checked;
+      try {
+        await updateDeliverySetting(next);
+        toastSuccess(
+          t('admin:news_delivery.update_succeeded', {
+            defaultValue: 'News delivery setting updated',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+        logger.error(err);
+      }
+    },
+    [updateDeliverySetting, t],
+  );
+
+  return (
+    <div className="row mb-3">
+      <label
+        className="text-start text-md-end col-md-3 col-form-label"
+        htmlFor="checkbox-news-is-delivery-enabled"
+      >
+        {t('admin:news_delivery.label', { defaultValue: 'News delivery' })}
+      </label>
+      <div className="col-md-6 py-2">
+        <div className="form-check form-switch">
+          <input
+            type="checkbox"
+            id="checkbox-news-is-delivery-enabled"
+            className="form-check-input"
+            checked={isEnabled}
+            disabled={isLoading}
+            onChange={onChange}
+          />
+          <label
+            className="form-label form-check-label"
+            htmlFor="checkbox-news-is-delivery-enabled"
+          >
+            {t('admin:news_delivery.enable', {
+              defaultValue: 'Enable news delivery',
+            })}
+          </label>
+        </div>
+        <p className="form-text text-muted">
+          {t('admin:news_delivery.description', {
+            defaultValue:
+              'Controls whether the cron job pulls the news feed and updates the local cache. Existing cached items remain visible while delivery is disabled.',
+          })}
+        </p>
+      </div>
+    </div>
+  );
+};

+ 59 - 0
apps/app/src/features/news/client/hooks/use-news.ts

@@ -0,0 +1,59 @@
+import type { SWRConfiguration, SWRResponse } from 'swr';
+import useSWR from 'swr';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+import useSWRInfinite from 'swr/infinite';
+
+import type { PaginateResult } from '~/interfaces/in-app-notification';
+
+import { apiv3Get } from '../../../../client/util/apiv3-client';
+import type { INewsItemWithReadStatus } from '../../interfaces/news-item';
+
+const NEWS_PER_PAGE = 10;
+
+type NewsListKey = [string, number, number, boolean] | null;
+
+/**
+ * SWRInfinite hook for paginated news items
+ */
+export const useSWRINFxNews = (
+  limit: number = NEWS_PER_PAGE,
+  options?: { onlyUnread?: boolean },
+  config?: SWRConfiguration,
+): SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error> => {
+  const onlyUnread = options?.onlyUnread ?? false;
+
+  return useSWRInfinite<PaginateResult<INewsItemWithReadStatus>, Error>(
+    (pageIndex, previousPageData): NewsListKey => {
+      if (previousPageData != null && !previousPageData.hasNextPage)
+        return null;
+      const offset = pageIndex * limit;
+      return ['/news/list', limit, offset, onlyUnread];
+    },
+    ([endpoint, limit, offset, onlyUnread]) =>
+      apiv3Get<PaginateResult<INewsItemWithReadStatus>>(endpoint, {
+        limit,
+        offset,
+        onlyUnread,
+      }).then((response) => response.data),
+    {
+      ...config,
+      revalidateFirstPage: false,
+    },
+  );
+};
+
+/**
+ * SWR hook for news unread count
+ */
+export const useSWRxNewsUnreadCount = (
+  config?: SWRConfiguration,
+): SWRResponse<number, Error> => {
+  return useSWR<number, Error>(
+    '/news/unread-count',
+    (endpoint) =>
+      apiv3Get<{ count: number }>(endpoint).then(
+        (response) => response.data.count,
+      ),
+    config,
+  );
+};

+ 42 - 0
apps/app/src/features/news/client/services/news-delivery-setting.ts

@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+import useSWR, { type SWRResponse } from 'swr';
+
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+
+const ENDPOINT = '/news/admin/delivery-setting';
+
+type DeliverySettingResponse = {
+  isDeliveryEnabled: boolean;
+};
+
+/**
+ * Fetch the current value of `news:isDeliveryEnabled` (admin only).
+ */
+export const useSWRxNewsDeliverySetting = (): SWRResponse<
+  DeliverySettingResponse,
+  Error
+> => {
+  return useSWR(
+    ENDPOINT,
+    async (endpoint) =>
+      (await apiv3Get<DeliverySettingResponse>(endpoint)).data,
+  );
+};
+
+/**
+ * Returns a callback that updates the news delivery flag on the server and
+ * revalidates the SWR cache so the UI reflects the new value.
+ */
+export const useUpdateNewsDeliverySetting = (): ((
+  flag: boolean,
+) => Promise<void>) => {
+  const { mutate } = useSWRxNewsDeliverySetting();
+
+  return useCallback(
+    async (flag: boolean) => {
+      await apiv3Post(ENDPOINT, { flag });
+      await mutate({ isDeliveryEnabled: flag }, { revalidate: false });
+    },
+    [mutate],
+  );
+};

+ 34 - 0
apps/app/src/features/news/interfaces/news-item.ts

@@ -0,0 +1,34 @@
+import type { Types } from 'mongoose';
+
+export interface INewsItem {
+  externalId: string;
+  title: Record<string, string>;
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: Date;
+  fetchedAt: Date;
+  conditions?: {
+    targetRoles?: string[];
+  };
+}
+
+export interface INewsItemHasId extends INewsItem {
+  _id: Types.ObjectId;
+}
+
+export interface INewsItemWithReadStatus extends INewsItemHasId {
+  isRead: boolean;
+}
+
+export interface INewsItemInput {
+  id: string;
+  title: Record<string, string>;
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: string | Date;
+  conditions?: {
+    targetRoles?: string[];
+  };
+}

+ 11 - 0
apps/app/src/features/news/interfaces/news-read-status.ts

@@ -0,0 +1,11 @@
+import type { Types } from 'mongoose';
+
+export interface INewsReadStatus {
+  userId: Types.ObjectId;
+  newsItemId: Types.ObjectId;
+  readAt: Date;
+}
+
+export interface INewsReadStatusHasId extends INewsReadStatus {
+  _id: Types.ObjectId;
+}

+ 56 - 0
apps/app/src/features/news/server/models/news-item.spec.ts

@@ -0,0 +1,56 @@
+import type { INewsItemHasId } from '../../interfaces/news-item';
+import { NewsItem } from './news-item';
+
+describe('NewsItem model', () => {
+  describe('schema structure', () => {
+    test('should have required fields defined in schema', () => {
+      const schemaPaths = NewsItem.schema.paths;
+      expect(schemaPaths.externalId).toBeDefined();
+      expect(schemaPaths.title).toBeDefined();
+      expect(schemaPaths.publishedAt).toBeDefined();
+      expect(schemaPaths.fetchedAt).toBeDefined();
+    });
+
+    test('should have optional fields defined in schema', () => {
+      const schemaPaths = NewsItem.schema.paths;
+      expect(schemaPaths.body).toBeDefined();
+      expect(schemaPaths.emoji).toBeDefined();
+      expect(schemaPaths.url).toBeDefined();
+      expect(schemaPaths['conditions.targetRoles']).toBeDefined();
+    });
+
+    test('externalId should have unique index', () => {
+      const externalIdPath = NewsItem.schema.paths.externalId as unknown as {
+        options: Record<string, unknown>;
+      };
+      expect(externalIdPath.options.unique).toBe(true);
+    });
+
+    test('fetchedAt should have TTL expires option', () => {
+      const schema = NewsItem.schema;
+      // Verify TTL index exists by checking index definitions
+      const indexes = schema.indexes() as unknown as Array<
+        [Record<string, unknown>, Record<string, unknown>]
+      >;
+      const ttlIndex = indexes.find(
+        (indexDef) =>
+          indexDef[0].fetchedAt !== undefined &&
+          indexDef[1].expireAfterSeconds !== undefined,
+      );
+      expect(ttlIndex).toBeDefined();
+    });
+  });
+
+  describe('type compatibility', () => {
+    test('should be assignable to INewsItemHasId', () => {
+      const item = new NewsItem({
+        externalId: 'test-001',
+        title: { ja_JP: 'テスト' },
+        publishedAt: new Date(),
+        fetchedAt: new Date(),
+      });
+      const typed: INewsItemHasId = item as unknown as INewsItemHasId;
+      expect(typed.externalId).toBe('test-001');
+    });
+  });
+});

+ 56 - 0
apps/app/src/features/news/server/models/news-item.ts

@@ -0,0 +1,56 @@
+import type { Document, Model, Types } from 'mongoose';
+import { Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type { INewsItem, INewsItemHasId } from '../../interfaces/news-item';
+
+// 90 days in seconds
+const TTL_90_DAYS = 60 * 60 * 24 * 90;
+
+export interface NewsItemDocument extends INewsItem, Document {
+  _id: Types.ObjectId;
+}
+
+export interface NewsItemModel extends Model<NewsItemDocument> {}
+
+const NewsItemSchema = new Schema<NewsItemDocument, NewsItemModel>({
+  externalId: {
+    type: String,
+    required: true,
+    unique: true,
+  },
+  title: {
+    type: Map,
+    of: String,
+    required: true,
+  },
+  body: {
+    type: Map,
+    of: String,
+  },
+  emoji: {
+    type: String,
+  },
+  url: {
+    type: String,
+  },
+  publishedAt: {
+    type: Date,
+    required: true,
+    index: true,
+  },
+  fetchedAt: {
+    type: Date,
+    required: true,
+    index: { expireAfterSeconds: TTL_90_DAYS },
+  },
+  conditions: {
+    targetRoles: [{ type: String }],
+  },
+});
+
+export const NewsItem = getOrCreateModel<INewsItemHasId, NewsItemModel>(
+  'NewsItem',
+  NewsItemSchema,
+);

+ 29 - 0
apps/app/src/features/news/server/models/news-read-status.spec.ts

@@ -0,0 +1,29 @@
+import { NewsReadStatus } from './news-read-status';
+
+describe('NewsReadStatus model', () => {
+  describe('schema structure', () => {
+    test('should have userId and newsItemId fields', () => {
+      const schemaPaths = NewsReadStatus.schema.paths;
+      expect(schemaPaths.userId).toBeDefined();
+      expect(schemaPaths.newsItemId).toBeDefined();
+    });
+
+    test('should have readAt field', () => {
+      const schemaPaths = NewsReadStatus.schema.paths;
+      expect(schemaPaths.readAt).toBeDefined();
+    });
+
+    test('should have compound unique index on userId + newsItemId', () => {
+      const schema = NewsReadStatus.schema;
+      const indexes = schema.indexes() as unknown as Array<
+        [Record<string, unknown>, Record<string, unknown>]
+      >;
+      const compoundIndex = indexes.find((indexDef) => {
+        const fieldKeys = Object.keys(indexDef[0]);
+        return fieldKeys.includes('userId') && fieldKeys.includes('newsItemId');
+      });
+      expect(compoundIndex).toBeDefined();
+      expect(compoundIndex?.[1].unique).toBe(true);
+    });
+  });
+});

+ 43 - 0
apps/app/src/features/news/server/models/news-read-status.ts

@@ -0,0 +1,43 @@
+import type { Document, Model, Types } from 'mongoose';
+import { Schema } from 'mongoose';
+
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
+
+import type {
+  INewsReadStatus,
+  INewsReadStatusHasId,
+} from '../../interfaces/news-read-status';
+
+export interface NewsReadStatusDocument extends INewsReadStatus, Document {
+  _id: Types.ObjectId;
+}
+
+export interface NewsReadStatusModel extends Model<NewsReadStatusDocument> {}
+
+const NewsReadStatusSchema = new Schema<
+  NewsReadStatusDocument,
+  NewsReadStatusModel
+>({
+  userId: {
+    type: Schema.Types.ObjectId,
+    ref: 'User',
+    required: true,
+  },
+  newsItemId: {
+    type: Schema.Types.ObjectId,
+    ref: 'NewsItem',
+    required: true,
+  },
+  readAt: {
+    type: Date,
+    required: true,
+    default: Date.now,
+  },
+});
+
+NewsReadStatusSchema.index({ userId: 1, newsItemId: 1 }, { unique: true });
+
+export const NewsReadStatus = getOrCreateModel<
+  INewsReadStatusHasId,
+  NewsReadStatusModel
+>('NewsReadStatus', NewsReadStatusSchema);

+ 286 - 0
apps/app/src/features/news/server/routes/news-integration.integ.ts

@@ -0,0 +1,286 @@
+/**
+ * Integration tests for News API
+ * Requires MongoDB connection (app-integration test environment)
+ */
+import type { IUserHasId } from '@growi/core';
+import express from 'express';
+import mongoose from 'mongoose';
+import request from 'supertest';
+
+import { NewsItem } from '../models/news-item';
+import { NewsReadStatus } from '../models/news-read-status';
+import { createNewsRouter } from './news';
+
+const buildApp = (userOverride: Partial<IUserHasId> = {}) => {
+  const userId = new mongoose.Types.ObjectId();
+  const app = express();
+  app.use(express.json());
+  app.use((req: express.Request & { user?: IUserHasId }, _res, next) => {
+    req.user = {
+      _id: userId,
+      admin: false,
+      ...userOverride,
+    } as unknown as IUserHasId;
+    next();
+  });
+  app.use('/apiv3/news', createNewsRouter());
+  return { app, userId };
+};
+
+describe('News API Integration', () => {
+  beforeEach(async () => {
+    await NewsItem.deleteMany({});
+    await NewsReadStatus.deleteMany({});
+  });
+
+  describe('GET /apiv3/news/list', () => {
+    test('should return empty list when no news', async () => {
+      const { app } = buildApp();
+      const res = await request(app).get('/apiv3/news/list');
+      expect(res.status).toBe(200);
+      expect(res.body.docs).toEqual([]);
+      expect(res.body.totalDocs).toBe(0);
+    });
+
+    test('should return news filtered by role', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'admin-only',
+          title: { ja_JP: '管理者向け' },
+          publishedAt: now,
+          fetchedAt: now,
+          conditions: { targetRoles: ['admin'] },
+        },
+        {
+          externalId: 'all-users',
+          title: { ja_JP: '全ユーザー向け' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      // General user should only see all-users item
+      const { app } = buildApp({ admin: false });
+      const res = await request(app).get('/apiv3/news/list');
+      expect(res.status).toBe(200);
+      expect(res.body.docs).toHaveLength(1);
+      expect(res.body.docs[0].externalId).toBe('all-users');
+    });
+
+    test('admin user should see admin-only items', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'admin-only',
+          title: { ja_JP: '管理者向け' },
+          publishedAt: now,
+          fetchedAt: now,
+          conditions: { targetRoles: ['admin'] },
+        },
+        {
+          externalId: 'all-users',
+          title: { ja_JP: '全ユーザー向け' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp({ admin: true });
+      const res = await request(app).get('/apiv3/news/list');
+      expect(res.status).toBe(200);
+      expect(res.body.docs).toHaveLength(2);
+    });
+  });
+
+  describe('POST /apiv3/news/mark-read', () => {
+    test('should mark an item as read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'test-001',
+        title: { ja_JP: 'テスト' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app, userId } = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ ok: true });
+
+      const status = await NewsReadStatus.findOne({
+        userId,
+        newsItemId: item._id,
+      });
+      expect(status).not.toBeNull();
+    });
+
+    test('should be idempotent (second call does not error)', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'test-002',
+        title: { ja_JP: 'テスト2' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+      const res2 = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      expect(res2.status).toBe(200);
+    });
+
+    test('should return 400 for invalid newsItemId', async () => {
+      const { app } = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: 'not-an-objectid' });
+      expect(res.status).toBe(400);
+    });
+  });
+
+  describe('GET /apiv3/news/list - sort order', () => {
+    test('should return items sorted by publishedAt descending', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'oldest',
+          title: { ja_JP: 'Oldest' },
+          publishedAt: new Date('2026-01-01'),
+          fetchedAt: now,
+        },
+        {
+          externalId: 'newest',
+          title: { ja_JP: 'Newest' },
+          publishedAt: new Date('2026-03-01'),
+          fetchedAt: now,
+        },
+        {
+          externalId: 'middle',
+          title: { ja_JP: 'Middle' },
+          publishedAt: new Date('2026-02-01'),
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp();
+      const res = await request(app).get('/apiv3/news/list');
+
+      expect(res.status).toBe(200);
+      const ids = res.body.docs.map(
+        (d: { externalId: string }) => d.externalId,
+      );
+      expect(ids).toEqual(['newest', 'middle', 'oldest']);
+    });
+  });
+
+  describe('markRead → listForUser cross-method consistency', () => {
+    test('should reflect isRead=true after mark-read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'cross-test',
+        title: { ja_JP: 'Cross test' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+
+      // Before mark-read: isRead should be false
+      const before = await request(app).get('/apiv3/news/list');
+      expect(before.body.docs[0].isRead).toBe(false);
+
+      // Mark as read
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      // After mark-read: isRead should be true
+      const after = await request(app).get('/apiv3/news/list');
+      expect(after.body.docs[0].isRead).toBe(true);
+    });
+
+    test('should decrease unread-count after mark-read', async () => {
+      const now = new Date();
+      const item = await NewsItem.create({
+        externalId: 'count-test',
+        title: { ja_JP: 'Count test' },
+        publishedAt: now,
+        fetchedAt: now,
+      });
+
+      const { app } = buildApp();
+
+      const before = await request(app).get('/apiv3/news/unread-count');
+      expect(before.body.count).toBe(1);
+
+      await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: item._id.toString() });
+
+      const after = await request(app).get('/apiv3/news/unread-count');
+      expect(after.body.count).toBe(0);
+    });
+  });
+
+  describe('GET /apiv3/news/unread-count', () => {
+    test('should return 0 after mark-all-read', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'n1',
+          title: { ja_JP: 'item 1' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+        {
+          externalId: 'n2',
+          title: { ja_JP: 'item 2' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp();
+      await request(app).post('/apiv3/news/mark-all-read');
+      const res = await request(app).get('/apiv3/news/unread-count');
+
+      expect(res.status).toBe(200);
+      expect(res.body.count).toBe(0);
+    });
+
+    test('should not count admin-only items for general user', async () => {
+      const now = new Date();
+      await NewsItem.insertMany([
+        {
+          externalId: 'admin-news',
+          title: { ja_JP: 'Admin only' },
+          publishedAt: now,
+          fetchedAt: now,
+          conditions: { targetRoles: ['admin'] },
+        },
+        {
+          externalId: 'general-news',
+          title: { ja_JP: 'General' },
+          publishedAt: now,
+          fetchedAt: now,
+        },
+      ]);
+
+      const { app } = buildApp({ admin: false });
+      const res = await request(app).get('/apiv3/news/unread-count');
+
+      expect(res.status).toBe(200);
+      // Contract: general user only sees 1 unread (not the admin-only item)
+      expect(res.body.count).toBe(1);
+    });
+  });
+});

+ 359 - 0
apps/app/src/features/news/server/routes/news.spec.ts

@@ -0,0 +1,359 @@
+import type { IUserHasId } from '@growi/core';
+import express from 'express';
+import mongoose from 'mongoose';
+import request from 'supertest';
+
+// Hoisted mocks
+const mocks = vi.hoisted(() => {
+  const listForUser = vi.fn();
+  const getUnreadCount = vi.fn();
+  const markRead = vi.fn();
+  const markAllRead = vi.fn();
+  const getConfig = vi.fn<(key: string) => unknown>();
+  const updateConfigs = vi.fn();
+  return {
+    NewsService: vi.fn(() => ({
+      listForUser,
+      getUnreadCount,
+      markRead,
+      markAllRead,
+    })),
+    listForUser,
+    getUnreadCount,
+    markRead,
+    markAllRead,
+    getConfig,
+    updateConfigs,
+  };
+});
+
+vi.mock('../services/news-service', () => ({
+  NewsService: mocks.NewsService,
+}));
+
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: mocks.getConfig,
+    updateConfigs: mocks.updateConfigs,
+  },
+}));
+
+// Middleware mocks - bypass auth
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser: () => (_req: unknown, _res: unknown, next: () => void) =>
+    next(),
+}));
+
+vi.mock('~/server/middlewares/login-required', () => ({
+  default:
+    () =>
+    (
+      req: express.Request & { user?: IUserHasId },
+      _res: unknown,
+      next: () => void,
+    ) => {
+      // Attach a mock user if not set
+      if (!req.user) {
+        req.user = {
+          _id: new mongoose.Types.ObjectId(),
+          admin: false,
+        } as unknown as IUserHasId;
+      }
+      next();
+    },
+}));
+
+import { createNewsRouter } from './news';
+
+const buildApp = (userOverride?: Partial<IUserHasId>) => {
+  const app = express();
+  app.use(express.json());
+  app.use((req: express.Request & { user?: IUserHasId }, _res, next) => {
+    req.user = {
+      _id: new mongoose.Types.ObjectId(),
+      admin: false,
+      ...userOverride,
+    } as unknown as IUserHasId;
+    next();
+  });
+  app.use('/apiv3/news', createNewsRouter());
+  return app;
+};
+
+describe('News API routes', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('GET /apiv3/news/list', () => {
+    test('should return news list with default params', async () => {
+      const mockResult = {
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      };
+      mocks.listForUser.mockResolvedValue(mockResult);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/list');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toMatchObject({ docs: [], totalDocs: 0 });
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        ['general'],
+        expect.objectContaining({ limit: 10, offset: 0 }),
+      );
+    });
+
+    test('should pass admin roles for admin user', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp({ admin: true });
+      await request(app).get('/apiv3/news/list');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        ['admin'],
+        expect.any(Object),
+      );
+    });
+
+    test('should silently cap limit at 100 when caller exceeds the upper bound', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 100,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?limit=99999');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ limit: 100 }),
+      );
+    });
+
+    test('should fall back to default limit when caller passes a non-numeric value', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?limit=abc');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ limit: 10 }),
+      );
+    });
+
+    test('should clamp limit up to 1 when caller passes a negative value', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 1,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?limit=-5');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ limit: 1 }),
+      );
+    });
+
+    test('should pass onlyUnread=true when query param is set', async () => {
+      mocks.listForUser.mockResolvedValue({
+        docs: [],
+        totalDocs: 0,
+        limit: 10,
+        offset: 0,
+        page: 1,
+        totalPages: 1,
+        hasNextPage: false,
+        hasPrevPage: false,
+        nextPage: null,
+        prevPage: null,
+        pagingCounter: 1,
+      });
+
+      const app = buildApp();
+      await request(app).get('/apiv3/news/list?onlyUnread=true');
+
+      expect(mocks.listForUser).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.any(Array),
+        expect.objectContaining({ onlyUnread: true }),
+      );
+    });
+  });
+
+  describe('GET /apiv3/news/unread-count', () => {
+    test('should return unread count', async () => {
+      mocks.getUnreadCount.mockResolvedValue(5);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/unread-count');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ count: 5 });
+    });
+  });
+
+  describe('POST /apiv3/news/mark-read', () => {
+    test('should mark a news item as read', async () => {
+      mocks.markRead.mockResolvedValue(undefined);
+
+      const newsItemId = new mongoose.Types.ObjectId().toString();
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ ok: true });
+      expect(mocks.markRead).toHaveBeenCalled();
+    });
+
+    test('should return 400 for invalid newsItemId', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/mark-read')
+        .send({ newsItemId: 'invalid-id' });
+
+      expect(res.status).toBe(400);
+      expect(mocks.markRead).not.toHaveBeenCalled();
+    });
+
+    test('should return 400 when newsItemId is missing', async () => {
+      const app = buildApp();
+      const res = await request(app).post('/apiv3/news/mark-read').send({});
+
+      expect(res.status).toBe(400);
+    });
+  });
+
+  describe('POST /apiv3/news/mark-all-read', () => {
+    test('should mark all news as read', async () => {
+      mocks.markAllRead.mockResolvedValue(undefined);
+
+      const app = buildApp();
+      const res = await request(app).post('/apiv3/news/mark-all-read');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ ok: true });
+      expect(mocks.markAllRead).toHaveBeenCalled();
+    });
+  });
+
+  describe('GET /apiv3/news/admin/delivery-setting', () => {
+    test('should return current value from configManager', async () => {
+      mocks.getConfig.mockReturnValue(true);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: true });
+      expect(mocks.getConfig).toHaveBeenCalledWith('news:isDeliveryEnabled');
+    });
+
+    test('should reflect false when delivery is disabled', async () => {
+      mocks.getConfig.mockReturnValue(false);
+
+      const app = buildApp();
+      const res = await request(app).get('/apiv3/news/admin/delivery-setting');
+
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+    });
+  });
+
+  describe('POST /apiv3/news/admin/delivery-setting', () => {
+    test('should update delivery setting via configManager', async () => {
+      mocks.updateConfigs.mockResolvedValue(undefined);
+
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: false });
+
+      expect(res.status).toBe(200);
+      expect(res.body).toEqual({ isDeliveryEnabled: false });
+      expect(mocks.updateConfigs).toHaveBeenCalledWith({
+        'news:isDeliveryEnabled': false,
+      });
+    });
+
+    test('should return 400 when flag is not boolean', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({ flag: 'true' });
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+
+    test('should return 400 when flag is missing', async () => {
+      const app = buildApp();
+      const res = await request(app)
+        .post('/apiv3/news/admin/delivery-setting')
+        .send({});
+
+      expect(res.status).toBe(400);
+      expect(mocks.updateConfigs).not.toHaveBeenCalled();
+    });
+  });
+});

+ 246 - 0
apps/app/src/features/news/server/routes/news.ts

@@ -0,0 +1,246 @@
+import type { IUserHasId } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import express from 'express';
+import mongoose from 'mongoose';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import adminRequiredFactory from '~/server/middlewares/admin-required';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import { configManager } from '~/server/service/config-manager';
+import loggerFactory from '~/utils/logger';
+
+import { NewsService } from '../services/news-service';
+
+const logger = loggerFactory('growi:feature:news:routes');
+
+/**
+ * Maximum number of news items returnable per request.
+ * Caps caller-supplied `limit` so a misuse cannot make a single request
+ * pull an unbounded result set into memory.
+ */
+const MAX_LIST_LIMIT = 100;
+const DEFAULT_LIST_LIMIT = 10;
+
+type NewsRequest = CrowiRequest & { user: IUserHasId };
+
+/**
+ * Returns user roles based on admin flag
+ */
+const getUserRoles = (user: IUserHasId): string[] => {
+  return user.admin ? ['admin'] : ['general'];
+};
+
+/**
+ * Resolve the effective list limit from a query value.
+ * Falls back to `DEFAULT_LIST_LIMIT` for missing/invalid input,
+ * and silently caps the result to `[1, MAX_LIST_LIMIT]`.
+ */
+const resolveLimit = (raw: unknown): number => {
+  const requested =
+    raw != null
+      ? parseInt(String(raw), 10) || DEFAULT_LIST_LIMIT
+      : DEFAULT_LIST_LIMIT;
+  return Math.min(Math.max(requested, 1), MAX_LIST_LIMIT);
+};
+
+/**
+ * Creates and returns the news Express router.
+ * Accepts an optional Crowi instance for middleware setup.
+ */
+export const createNewsRouter = (crowi?: Crowi): express.Router => {
+  const router = express.Router();
+
+  // Use loginRequiredFactory when crowi is provided, otherwise use a pass-through middleware for testing
+  const loginRequiredStrictly =
+    crowi != null
+      ? loginRequiredFactory(crowi)
+      : (_req: unknown, _res: unknown, next: () => void) => next();
+  const adminRequired =
+    crowi != null
+      ? adminRequiredFactory(crowi)
+      : (_req: unknown, _res: unknown, next: () => void) => next();
+
+  /**
+   * GET /news/list
+   * Returns paginated news items filtered by user role
+   */
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const user = req.user;
+        const userRoles = getUserRoles(user);
+
+        const limit = resolveLimit(req.query.limit);
+        const offset =
+          req.query.offset != null
+            ? parseInt(String(req.query.offset), 10) || 0
+            : 0;
+        const onlyUnread = req.query.onlyUnread === 'true';
+
+        const service = new NewsService();
+        const result = await service.listForUser(user._id, userRoles, {
+          limit,
+          offset,
+          onlyUnread,
+        });
+
+        return res.json(result);
+      } catch (err) {
+        logger.error('GET /news/list failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * GET /news/unread-count
+   * Returns the unread news count for the current user
+   */
+  router.get(
+    '/unread-count',
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const user = req.user;
+        const userRoles = getUserRoles(user);
+
+        const service = new NewsService();
+        const count = await service.getUnreadCount(user._id, userRoles);
+
+        return res.json({ count });
+      } catch (err) {
+        logger.error('GET /news/unread-count failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/mark-read
+   * Marks a single news item as read for the current user
+   */
+  router.post(
+    '/mark-read',
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const { newsItemId } = req.body;
+
+        if (!newsItemId || !mongoose.isValidObjectId(newsItemId)) {
+          return res
+            .status(400)
+            .json({ error: 'Invalid or missing newsItemId' });
+        }
+
+        const user = req.user;
+        const service = new NewsService();
+        await service.markRead(
+          user._id,
+          new mongoose.Types.ObjectId(newsItemId),
+        );
+
+        return res.json({ ok: true });
+      } catch (err) {
+        logger.error('POST /news/mark-read failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/mark-all-read
+   * Marks all news items as read for the current user
+   */
+  router.post(
+    '/mark-all-read',
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const user = req.user;
+        const userRoles = getUserRoles(user);
+
+        const service = new NewsService();
+        await service.markAllRead(user._id, userRoles);
+
+        return res.json({ ok: true });
+      } catch (err) {
+        logger.error('POST /news/mark-all-read failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * GET /news/admin/delivery-setting
+   * Returns the current value of `news:isDeliveryEnabled` (admin only)
+   */
+  router.get(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.READ.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    (_req, res) => {
+      try {
+        const isDeliveryEnabled = configManager.getConfig(
+          'news:isDeliveryEnabled',
+        );
+        return res.json({ isDeliveryEnabled });
+      } catch (err) {
+        logger.error('GET /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  /**
+   * POST /news/admin/delivery-setting
+   * Updates `news:isDeliveryEnabled` (admin only). Body: `{ flag: boolean }`.
+   * The new value is persisted to the `Config` collection and reflected on
+   * the next cron tick without a restart.
+   */
+  router.post(
+    '/admin/delivery-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req, res) => {
+      try {
+        const { flag } = req.body;
+        if (typeof flag !== 'boolean') {
+          return res.status(400).json({ error: '`flag` must be a boolean' });
+        }
+
+        await configManager.updateConfigs({ 'news:isDeliveryEnabled': flag });
+        return res.json({ isDeliveryEnabled: flag });
+      } catch (err) {
+        logger.error('POST /news/admin/delivery-setting failed', err);
+        return res.status(500).json({ error: 'Internal server error' });
+      }
+    },
+  );
+
+  return router;
+};
+
+/**
+ * Default export for Express app registration (crowi factory pattern).
+ * Required by the apiv3 router loader which calls require(...).default(crowi).
+ */
+// biome-ignore lint/style/noDefaultExport: required by apiv3 router loader
+export default (crowi: Crowi): express.Router => createNewsRouter(crowi);

+ 65 - 0
apps/app/src/features/news/server/services/feed-parser.ts

@@ -0,0 +1,65 @@
+import { z } from 'zod';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:feature:news:feed-parser');
+
+const FeedItemSchema = z.object({
+  id: z.string().min(1),
+  type: z.string().optional(),
+  emoji: z.string().optional(),
+  title: z.record(z.string()),
+  body: z.record(z.string()).optional(),
+  url: z.string().optional(),
+  publishedAt: z.string().min(1),
+  conditions: z
+    .object({
+      targetRoles: z.array(z.string()).optional(),
+      growiVersionRegExps: z.array(z.string()).optional(),
+    })
+    .optional(),
+});
+
+const FeedJsonSchema = z.object({
+  version: z.string(),
+  // Items are parsed individually so a single bad item does not abort the batch
+  items: z.array(z.unknown()),
+});
+
+export type FeedItem = z.infer<typeof FeedItemSchema>;
+
+export interface FeedJson {
+  version: string;
+  items: FeedItem[];
+}
+
+/**
+ * Validate parsed JSON against the feed schema.
+ * Items failing per-item validation are skipped (logged), allowing the rest to be processed.
+ * Returns null when the top-level shape is invalid.
+ */
+export const parseFeedJson = (raw: unknown): FeedJson | null => {
+  const topResult = FeedJsonSchema.safeParse(raw);
+  if (!topResult.success) {
+    logger.error(
+      { issues: topResult.error.issues },
+      'News feed JSON top-level shape invalid',
+    );
+    return null;
+  }
+
+  const validItems: FeedItem[] = [];
+  for (const rawItem of topResult.data.items) {
+    const itemResult = FeedItemSchema.safeParse(rawItem);
+    if (itemResult.success) {
+      validItems.push(itemResult.data);
+    } else {
+      logger.warn(
+        { issues: itemResult.error.issues },
+        'News feed item failed validation, skipping',
+      );
+    }
+  }
+
+  return { version: topResult.data.version, items: validItems };
+};

+ 319 - 0
apps/app/src/features/news/server/services/news-cron-service.spec.ts

@@ -0,0 +1,319 @@
+// Hoisted mocks
+const mocks = vi.hoisted(() => {
+  const upsertNewsItems = vi.fn();
+  const deleteItemsNotInFeed = vi.fn();
+  const mockFetch = vi.fn();
+  const getGrowiVersion = vi.fn(() => '7.5.0');
+  // Default delivery to enabled so existing tests behave as before.
+  // Tests that need OFF state can override via mocks.getConfig.mockImplementationOnce.
+  const getConfig = vi.fn<(key: string) => unknown>((key: string) => {
+    if (key === 'news:isDeliveryEnabled') return true;
+    return undefined;
+  });
+
+  return {
+    NewsService: vi.fn(() => ({
+      upsertNewsItems,
+      deleteItemsNotInFeed,
+    })),
+    upsertNewsItems,
+    deleteItemsNotInFeed,
+    mockFetch,
+    getGrowiVersion,
+    getConfig,
+  };
+});
+
+vi.mock('../services/news-service', () => ({
+  NewsService: mocks.NewsService,
+}));
+
+vi.mock('~/utils/growi-version', () => ({
+  getGrowiVersion: mocks.getGrowiVersion,
+}));
+
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: mocks.getConfig,
+  },
+}));
+
+// Mock global fetch
+vi.stubGlobal('fetch', mocks.mockFetch);
+
+// Mock Math.random for deterministic sleep (zero sleep)
+vi.spyOn(Math, 'random').mockReturnValue(0);
+
+import { NewsCronService } from './news-cron-service';
+
+const VALID_FEED = {
+  version: '1.0',
+  items: [
+    {
+      id: 'item-001',
+      title: { ja_JP: 'テスト', en_US: 'Test' },
+      publishedAt: '2026-01-01T00:00:00Z',
+    },
+    {
+      id: 'item-002',
+      title: { ja_JP: '管理者向け' },
+      publishedAt: '2026-01-02T00:00:00Z',
+      conditions: { targetRoles: ['admin'] },
+    },
+  ],
+};
+
+/** Build a Response-like mock that exposes `text()` returning the JSON-stringified body. */
+const mockResponse = (
+  body: unknown,
+  init?: { ok?: boolean; status?: number },
+) => ({
+  ok: init?.ok ?? true,
+  status: init?.status ?? 200,
+  text: () => Promise.resolve(JSON.stringify(body)),
+});
+
+describe('NewsCronService', () => {
+  let service: NewsCronService;
+
+  beforeEach(() => {
+    service = new NewsCronService();
+    vi.clearAllMocks();
+    // Reset random mock
+    vi.spyOn(Math, 'random').mockReturnValue(0);
+  });
+
+  describe('getCronSchedule', () => {
+    test('should return daily schedule at midnight', () => {
+      expect(service.getCronSchedule()).toBe('0 0 * * *');
+    });
+  });
+
+  describe('executeJob', () => {
+    test('should skip when news:isDeliveryEnabled is false', async () => {
+      mocks.getConfig.mockImplementationOnce((key: string) =>
+        key === 'news:isDeliveryEnabled' ? false : undefined,
+      );
+
+      await service.executeJob();
+
+      // Delivery flag short-circuits before any network call or DB write
+      expect(mocks.mockFetch).not.toHaveBeenCalled();
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
+
+    test('should run when news:isDeliveryEnabled is true (default)', async () => {
+      mocks.mockFetch.mockResolvedValue(
+        mockResponse({ version: '1.0', items: [] }),
+      );
+
+      await service.executeJob();
+
+      expect(mocks.getConfig).toHaveBeenCalledWith('news:isDeliveryEnabled');
+      expect(mocks.mockFetch).toHaveBeenCalled();
+    });
+
+    test('should fetch from the hardcoded vendor URL', async () => {
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).toHaveBeenCalledWith(
+        'https://growilabs.github.io/growi-news-feed/feed.json',
+        expect.any(Object),
+      );
+    });
+
+    test('should upsert items on successful fetch', async () => {
+      mocks.mockFetch.mockResolvedValue(mockResponse(VALID_FEED));
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).toHaveBeenCalledWith(VALID_FEED.items);
+      expect(mocks.deleteItemsNotInFeed).toHaveBeenCalledWith([
+        'item-001',
+        'item-002',
+      ]);
+    });
+
+    test('should NOT update DB when fetch fails', async () => {
+      mocks.mockFetch.mockResolvedValue({ ok: false, status: 500 });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteItemsNotInFeed).not.toHaveBeenCalled();
+    });
+
+    test('should NOT update DB when fetch throws', async () => {
+      mocks.mockFetch.mockRejectedValue(new Error('Network error'));
+
+      await expect(service.executeJob()).resolves.not.toThrow();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
+
+    test('should filter items by growiVersionRegExps', async () => {
+      mocks.getGrowiVersion.mockReturnValue('7.5.0');
+      const feedWithVersionFilter = {
+        version: '1.0',
+        items: [
+          {
+            id: 'match-item',
+            title: { ja_JP: 'バージョン一致' },
+            publishedAt: '2026-01-01T00:00:00Z',
+            conditions: { growiVersionRegExps: ['^7\\.5\\..*'] },
+          },
+          {
+            id: 'no-match-item',
+            title: { ja_JP: 'バージョン不一致' },
+            publishedAt: '2026-01-01T00:00:00Z',
+            conditions: { growiVersionRegExps: ['^6\\..*'] },
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithVersionFilter));
+
+      await service.executeJob();
+
+      const upsertCall = mocks.upsertNewsItems.mock.calls[0][0];
+      expect(upsertCall).toHaveLength(1);
+      expect(upsertCall[0].id).toBe('match-item');
+    });
+
+    test('should skip items with invalid growiVersionRegExps', async () => {
+      mocks.getGrowiVersion.mockReturnValue('7.5.0');
+      const feedWithInvalidRegex = {
+        version: '1.0',
+        items: [
+          {
+            id: 'invalid-regex-item',
+            title: { ja_JP: '不正Regex' },
+            publishedAt: '2026-01-01T00:00:00Z',
+            conditions: { growiVersionRegExps: ['[invalid'] },
+          },
+          {
+            id: 'valid-item',
+            title: { ja_JP: '正常アイテム' },
+            publishedAt: '2026-01-01T00:00:00Z',
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithInvalidRegex));
+
+      await service.executeJob();
+
+      const upsertCall = mocks.upsertNewsItems.mock.calls[0][0];
+      // invalid-regex-item is skipped (treated as not matching), valid-item passes
+      expect(upsertCall.map((i: { id: string }) => i.id)).toContain(
+        'valid-item',
+      );
+      expect(upsertCall.map((i: { id: string }) => i.id)).not.toContain(
+        'invalid-regex-item',
+      );
+    });
+
+    // Regression for Requirement 1.3: items removed from the feed must be
+    // deleted from the local DB. Earlier code computed `idsToDelete` from
+    // `feedJson.items` only, so DB items absent from the feed were never
+    // cleaned up. The cron must now hand the full set of feed externalIds
+    // to `deleteItemsNotInFeed`, which uses a $nin filter to remove the rest.
+    test('should pass every feed externalId to deleteItemsNotInFeed (regression for stale-item bug)', async () => {
+      const feed = {
+        version: '1.0',
+        items: [
+          {
+            id: 'still-present-1',
+            title: { ja_JP: 'still present 1' },
+            publishedAt: '2026-01-01T00:00:00Z',
+          },
+          {
+            id: 'still-present-2',
+            title: { ja_JP: 'still present 2' },
+            publishedAt: '2026-01-02T00:00:00Z',
+          },
+          // Item present in feed but version-filtered out — must remain in
+          // the deletion safelist so it is not wiped from the DB.
+          {
+            id: 'version-filtered',
+            title: { ja_JP: 'version filtered' },
+            publishedAt: '2026-01-03T00:00:00Z',
+            conditions: { growiVersionRegExps: ['^999\\.'] },
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feed));
+
+      await service.executeJob();
+
+      // The argument is the *full* feed externalId list, not the
+      // version-matched subset. Items absent from this list (e.g. an
+      // earlier `removed-from-feed` item still in the DB) will be
+      // deleted by the service via `$nin`.
+      expect(mocks.deleteItemsNotInFeed).toHaveBeenCalledWith([
+        'still-present-1',
+        'still-present-2',
+        'version-filtered',
+      ]);
+    });
+
+    test('should skip when response body exceeds size limit (5 MiB)', async () => {
+      // Build a string that exceeds 5 MiB
+      const oversizedText = 'x'.repeat(5 * 1024 * 1024 + 1);
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        text: () => Promise.resolve(oversizedText),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteItemsNotInFeed).not.toHaveBeenCalled();
+    });
+
+    test('should abort when top-level shape is invalid', async () => {
+      // Missing `items` field — top-level schema check fails
+      mocks.mockFetch.mockResolvedValue(mockResponse({ version: '1.0' }));
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteItemsNotInFeed).not.toHaveBeenCalled();
+    });
+
+    test('should skip individual invalid items but keep valid ones', async () => {
+      const feedWithMixedItems = {
+        version: '1.0',
+        items: [
+          // Missing required fields (title, publishedAt) → skipped
+          { id: 'broken-item' },
+          // Valid item
+          {
+            id: 'good-item',
+            title: { ja_JP: '正常' },
+            publishedAt: '2026-01-01T00:00:00Z',
+          },
+        ],
+      };
+      mocks.mockFetch.mockResolvedValue(mockResponse(feedWithMixedItems));
+
+      await service.executeJob();
+
+      const upsertCall = mocks.upsertNewsItems.mock.calls[0][0];
+      expect(upsertCall.map((i: { id: string }) => i.id)).toEqual([
+        'good-item',
+      ]);
+    });
+
+    test('should skip when response body is not valid JSON', async () => {
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        text: () => Promise.resolve('not-a-json{'),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
+  });
+});

+ 140 - 0
apps/app/src/features/news/server/services/news-cron-service.ts

@@ -0,0 +1,140 @@
+import { configManager } from '~/server/service/config-manager';
+import CronService from '~/server/service/cron';
+import { getGrowiVersion } from '~/utils/growi-version';
+import loggerFactory from '~/utils/logger';
+
+import type { INewsItemInput } from '../../interfaces/news-item';
+import { type FeedItem, parseFeedJson } from './feed-parser';
+import { NewsService } from './news-service';
+
+const logger = loggerFactory('growi:feature:news:cron');
+
+/** Maximum random sleep in ms (5 hours) */
+const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;
+
+/** HTTP fetch timeout in ms */
+const FETCH_TIMEOUT_MS = 10_000;
+
+/**
+ * Maximum response body size (5 MiB).
+ * Sanity limit for the trust boundary at the news feed adapter — caps how much
+ * an external endpoint (broken or compromised) can push into our process memory.
+ */
+const MAX_RESPONSE_SIZE_BYTES = 5 * 1024 * 1024;
+
+/**
+ * Vendor-controlled news feed URL. Hardcoded so a fresh deployment delivers
+ * news without any infrastructure-side env injection. Users (incl. admins)
+ * cannot change this; opt-out is performed via the `news:isDeliveryEnabled`
+ * config flag managed in the admin UI.
+ */
+const FEED_URL = 'https://growilabs.github.io/growi-news-feed/feed.json';
+
+/**
+ * Check if the item matches the current GROWI version
+ * Returns true if no version conditions set.
+ * If a regex is invalid, the item is skipped (returns false).
+ */
+const matchesGrowiVersion = (
+  item: FeedItem,
+  currentVersion: string,
+): boolean => {
+  const regExps = item.conditions?.growiVersionRegExps;
+  if (!regExps || regExps.length === 0) return true;
+
+  return regExps.some((pattern) => {
+    try {
+      return new RegExp(pattern).test(currentVersion);
+    } catch {
+      logger.warn(`Invalid growiVersionRegExp pattern skipped: ${pattern}`);
+      return false;
+    }
+  });
+};
+
+/**
+ * Sleep for a random duration between 0 and maxMs
+ */
+const randomSleep = (maxMs: number): Promise<void> => {
+  const ms = Math.floor(Math.random() * maxMs);
+  return new Promise((resolve) => setTimeout(resolve, ms));
+};
+
+export class NewsCronService extends CronService {
+  override getCronSchedule(): string {
+    return '0 0 * * *';
+  }
+
+  override async executeJob(): Promise<void> {
+    // Read the delivery toggle (DB > defaultValue: true) on every tick so
+    // an admin's UI change takes effect from the next scheduled run, with no
+    // pod restart required (Requirements 9.5, 9.6).
+    if (!configManager.getConfig('news:isDeliveryEnabled')) {
+      logger.debug('News delivery is disabled, skipping news feed sync');
+      return;
+    }
+
+    // Random sleep to distribute requests across multiple GROWI instances
+    await randomSleep(MAX_RANDOM_SLEEP_MS);
+
+    let rawJson: unknown;
+    try {
+      const response = await fetch(FEED_URL, {
+        signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+      });
+
+      if (!response.ok) {
+        logger.error(`Failed to fetch news feed: HTTP ${response.status}`);
+        return;
+      }
+
+      const text = await response.text();
+      if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_SIZE_BYTES) {
+        logger.error(
+          `News feed response exceeds size limit (${MAX_RESPONSE_SIZE_BYTES} bytes), skipping`,
+        );
+        return;
+      }
+
+      rawJson = JSON.parse(text);
+    } catch (err) {
+      logger.error('Error fetching news feed, keeping existing data', err);
+      return;
+    }
+
+    const feedJson = parseFeedJson(rawJson);
+    if (feedJson == null) {
+      return;
+    }
+
+    const currentVersion = getGrowiVersion();
+    const filteredItems = feedJson.items.filter((item) =>
+      matchesGrowiVersion(item, currentVersion),
+    );
+
+    // Convert FeedItem to INewsItemInput (reuse id as externalId)
+    const newsItemInputs: INewsItemInput[] = filteredItems.map((item) => ({
+      id: item.id,
+      title: item.title,
+      body: item.body,
+      emoji: item.emoji,
+      url: item.url,
+      publishedAt: item.publishedAt,
+      conditions: item.conditions
+        ? {
+            targetRoles: item.conditions.targetRoles,
+          }
+        : undefined,
+    }));
+
+    // Pass the full set of feed externalIds so the service can delete any DB
+    // item that is no longer present in the feed (Requirement 1.3). Includes
+    // items filtered out by version match — those remain "in the feed" and
+    // are allowed to age out via the NewsItem TTL.
+    const feedExternalIds = feedJson.items.map((item) => item.id);
+
+    const service = new NewsService();
+    await service.upsertNewsItems(newsItemInputs);
+    await service.deleteItemsNotInFeed(feedExternalIds);
+  }
+}

+ 445 - 0
apps/app/src/features/news/server/services/news-service.spec.ts

@@ -0,0 +1,445 @@
+import mongoose from 'mongoose';
+
+// Use vi.hoisted so these variables are accessible inside vi.mock factory
+const mocks = vi.hoisted(() => {
+  const newsItemFind = vi.fn();
+  const newsItemBulkWrite = vi.fn();
+  const newsItemDeleteMany = vi.fn();
+  const newsItemCountDocuments = vi.fn();
+
+  const newsReadStatusDistinct = vi.fn();
+  const newsReadStatusUpdateOne = vi.fn();
+  const newsReadStatusInsertMany = vi.fn();
+
+  return {
+    NewsItem: {
+      find: newsItemFind,
+      bulkWrite: newsItemBulkWrite,
+      deleteMany: newsItemDeleteMany,
+      countDocuments: newsItemCountDocuments,
+    },
+    NewsReadStatus: {
+      distinct: newsReadStatusDistinct,
+      updateOne: newsReadStatusUpdateOne,
+      insertMany: newsReadStatusInsertMany,
+    },
+    newsItemFind,
+    newsItemBulkWrite,
+    newsItemDeleteMany,
+    newsItemCountDocuments,
+    newsReadStatusDistinct,
+    newsReadStatusUpdateOne,
+    newsReadStatusInsertMany,
+  };
+});
+
+vi.mock('../models/news-item', () => ({
+  NewsItem: mocks.NewsItem,
+}));
+
+vi.mock('../models/news-read-status', () => ({
+  NewsReadStatus: mocks.NewsReadStatus,
+}));
+
+import { NewsService } from './news-service';
+
+describe('NewsService', () => {
+  let service: NewsService;
+
+  beforeEach(() => {
+    service = new NewsService();
+    vi.clearAllMocks();
+  });
+
+  describe('listForUser', () => {
+    test('should return empty result when no news items', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0 },
+      );
+
+      expect(result.docs).toEqual([]);
+      expect(result.totalDocs).toBe(0);
+    });
+
+    test('should attach isRead=true for read items', async () => {
+      const newsId = new mongoose.Types.ObjectId();
+      const readNewsId = new mongoose.Types.ObjectId();
+
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: newsId,
+            externalId: 'n1',
+            title: { ja_JP: 'Test' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: readNewsId,
+            externalId: 'n2',
+            title: { ja_JP: 'Read' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(2);
+      mocks.newsReadStatusDistinct.mockResolvedValue([readNewsId]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0 },
+      );
+
+      expect(result.docs).toHaveLength(2);
+      const unread = result.docs.find((d) => d._id.equals(newsId));
+      const read = result.docs.find((d) => d._id.equals(readNewsId));
+      expect(unread?.isRead).toBe(false);
+      expect(read?.isRead).toBe(true);
+    });
+
+    test('should not include items with non-matching targetRoles in docs', async () => {
+      const generalItemId = new mongoose.Types.ObjectId();
+
+      // Mock returns both items (simulating DB returning role-filtered results)
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: generalItemId,
+            externalId: 'general-news',
+            title: { ja_JP: 'General' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(1);
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0 },
+      );
+
+      // Contract: only items matching user's role appear in docs
+      expect(result.docs).toHaveLength(1);
+      expect(result.docs.every((d) => d._id.equals(generalItemId))).toBe(true);
+    });
+
+    test('should exclude read items from docs when onlyUnread is true', async () => {
+      const unreadId = new mongoose.Types.ObjectId();
+      const readId = new mongoose.Types.ObjectId();
+
+      mocks.newsReadStatusDistinct.mockResolvedValue([readId]);
+      // When onlyUnread=true, DB query already excludes read items
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: unreadId,
+            externalId: 'unread-news',
+            title: { ja_JP: 'Unread' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(1);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 10, offset: 0, onlyUnread: true },
+      );
+
+      // Contract: no read item appears in output
+      expect(result.docs).toHaveLength(1);
+      expect(result.docs[0].isRead).toBe(false);
+      expect(result.docs.some((d) => d._id.equals(readId))).toBe(false);
+    });
+
+    test('should return correct pagination metadata', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p1',
+            title: { ja_JP: 'P1' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p2',
+            title: { ja_JP: 'P2' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p3',
+            title: { ja_JP: 'P3' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p4',
+            title: { ja_JP: 'P4' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+          {
+            _id: new mongoose.Types.ObjectId(),
+            externalId: 'p5',
+            title: { ja_JP: 'P5' },
+            publishedAt: new Date(),
+            fetchedAt: new Date(),
+          },
+        ]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(23);
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+
+      const result = await service.listForUser(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+        { limit: 5, offset: 10 },
+      );
+
+      // Contract: pagination fields are correct for offset=10, limit=5, total=23
+      expect(result.totalDocs).toBe(23);
+      expect(result.limit).toBe(5);
+      expect(result.page).toBe(3);
+      expect(result.totalPages).toBe(5);
+      expect(result.hasNextPage).toBe(true);
+      expect(result.hasPrevPage).toBe(true);
+    });
+  });
+
+  describe('markRead', () => {
+    test('should upsert a NewsReadStatus record', async () => {
+      mocks.newsReadStatusUpdateOne.mockResolvedValue({ upsertedCount: 1 });
+
+      const userId = new mongoose.Types.ObjectId();
+      const newsItemId = new mongoose.Types.ObjectId();
+      await service.markRead(userId, newsItemId);
+
+      expect(mocks.newsReadStatusUpdateOne).toHaveBeenCalledWith(
+        { userId, newsItemId },
+        expect.objectContaining({ $setOnInsert: expect.any(Object) }),
+        { upsert: true },
+      );
+    });
+
+    test('should be idempotent (no error on duplicate)', async () => {
+      mocks.newsReadStatusUpdateOne.mockResolvedValue({ upsertedCount: 0 });
+
+      const userId = new mongoose.Types.ObjectId();
+      const newsItemId = new mongoose.Types.ObjectId();
+      await expect(service.markRead(userId, newsItemId)).resolves.not.toThrow();
+      await expect(service.markRead(userId, newsItemId)).resolves.not.toThrow();
+    });
+  });
+
+  describe('markAllRead', () => {
+    test('should complete without error when news items exist', async () => {
+      const itemId = new mongoose.Types.ObjectId();
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi.fn().mockResolvedValue([{ _id: itemId }]),
+      });
+      mocks.newsReadStatusInsertMany.mockResolvedValue([]);
+
+      const userId = new mongoose.Types.ObjectId();
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+    });
+
+    test('should complete without error when no news items exist', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi.fn().mockResolvedValue([]),
+      });
+
+      const userId = new mongoose.Types.ObjectId();
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+
+      // Contract: no write operation when nothing to mark
+      expect(mocks.newsReadStatusInsertMany).not.toHaveBeenCalled();
+    });
+
+    test('should silently ignore duplicate key errors (already-read items)', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi
+          .fn()
+          .mockResolvedValue([{ _id: new mongoose.Types.ObjectId() }]),
+      });
+      const duplicateError = Object.assign(new Error('duplicate key'), {
+        code: 11000,
+      });
+      mocks.newsReadStatusInsertMany.mockRejectedValue(duplicateError);
+
+      const userId = new mongoose.Types.ObjectId();
+      // Contract: idempotent — calling twice doesn't throw
+      await expect(
+        service.markAllRead(userId, ['general']),
+      ).resolves.not.toThrow();
+    });
+
+    test('should throw non-duplicate errors', async () => {
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi
+          .fn()
+          .mockResolvedValue([{ _id: new mongoose.Types.ObjectId() }]),
+      });
+      const otherError = Object.assign(new Error('connection lost'), {
+        code: 12345,
+      });
+      mocks.newsReadStatusInsertMany.mockRejectedValue(otherError);
+
+      const userId = new mongoose.Types.ObjectId();
+      // Contract: real errors propagate to caller
+      await expect(service.markAllRead(userId, ['general'])).rejects.toThrow(
+        'connection lost',
+      );
+    });
+  });
+
+  describe('getUnreadCount', () => {
+    test('should return the number of unread items', async () => {
+      mocks.newsReadStatusDistinct.mockResolvedValue([
+        new mongoose.Types.ObjectId(),
+      ]);
+      mocks.newsItemCountDocuments.mockResolvedValue(2);
+
+      const userId = new mongoose.Types.ObjectId();
+      const count = await service.getUnreadCount(userId, ['general']);
+
+      // Contract: returns the unread count as a number
+      expect(count).toBe(2);
+    });
+
+    test('should return 0 when all items are read', async () => {
+      mocks.newsReadStatusDistinct.mockResolvedValue([
+        new mongoose.Types.ObjectId(),
+        new mongoose.Types.ObjectId(),
+      ]);
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+
+      const count = await service.getUnreadCount(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+      );
+      expect(count).toBe(0);
+    });
+
+    test('should return 0 when no news items exist', async () => {
+      mocks.newsReadStatusDistinct.mockResolvedValue([]);
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+
+      const count = await service.getUnreadCount(
+        new mongoose.Types.ObjectId(),
+        ['general'],
+      );
+      expect(count).toBe(0);
+    });
+  });
+
+  describe('upsertNewsItems', () => {
+    test('should call bulkWrite with upsert for each item', async () => {
+      mocks.newsItemBulkWrite.mockResolvedValue({ upsertedCount: 1 });
+
+      await service.upsertNewsItems([
+        {
+          id: 'ext-001',
+          title: { ja_JP: 'Test' },
+          publishedAt: '2026-01-01T00:00:00Z',
+        },
+      ]);
+
+      expect(mocks.newsItemBulkWrite).toHaveBeenCalledTimes(1);
+      const [ops, opts] = mocks.newsItemBulkWrite.mock.calls[0];
+      expect(ops).toHaveLength(1);
+      expect(ops[0].updateOne.filter).toEqual({ externalId: 'ext-001' });
+      expect(ops[0].updateOne.update.$set.externalId).toBe('ext-001');
+      expect(ops[0].updateOne.upsert).toBe(true);
+      expect(opts).toEqual({ ordered: false });
+    });
+
+    test('should batch multiple items into a single bulkWrite call', async () => {
+      mocks.newsItemBulkWrite.mockResolvedValue({ upsertedCount: 2 });
+
+      await service.upsertNewsItems([
+        {
+          id: 'ext-001',
+          title: { ja_JP: 'Item 1' },
+          publishedAt: '2026-01-01T00:00:00Z',
+        },
+        {
+          id: 'ext-002',
+          title: { ja_JP: 'Item 2' },
+          publishedAt: '2026-01-02T00:00:00Z',
+        },
+      ]);
+
+      expect(mocks.newsItemBulkWrite).toHaveBeenCalledTimes(1);
+      const [ops] = mocks.newsItemBulkWrite.mock.calls[0];
+      expect(ops).toHaveLength(2);
+      expect(ops[0].updateOne.filter).toEqual({ externalId: 'ext-001' });
+      expect(ops[1].updateOne.filter).toEqual({ externalId: 'ext-002' });
+    });
+
+    test('should do nothing when items is empty', async () => {
+      await service.upsertNewsItems([]);
+      expect(mocks.newsItemBulkWrite).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('deleteItemsNotInFeed', () => {
+    test('should call deleteMany with $nin filter for items not in feed', async () => {
+      mocks.newsItemDeleteMany.mockResolvedValue({ deletedCount: 1 });
+
+      await service.deleteItemsNotInFeed(['ext-001', 'ext-002']);
+
+      expect(mocks.newsItemDeleteMany).toHaveBeenCalledWith({
+        externalId: { $nin: ['ext-001', 'ext-002'] },
+      });
+    });
+
+    test('should call deleteMany with $nin: [] when feed is empty (deletes all cached items)', async () => {
+      mocks.newsItemDeleteMany.mockResolvedValue({ deletedCount: 5 });
+
+      await service.deleteItemsNotInFeed([]);
+
+      expect(mocks.newsItemDeleteMany).toHaveBeenCalledWith({
+        externalId: { $nin: [] },
+      });
+    });
+  });
+});

+ 184 - 0
apps/app/src/features/news/server/services/news-service.ts

@@ -0,0 +1,184 @@
+import type { Types } from 'mongoose';
+
+import type { PaginateResult } from '~/interfaces/in-app-notification';
+import loggerFactory from '~/utils/logger';
+
+import type {
+  INewsItemInput,
+  INewsItemWithReadStatus,
+} from '../../interfaces/news-item';
+import { NewsItem } from '../models/news-item';
+import { NewsReadStatus } from '../models/news-read-status';
+
+const logger = loggerFactory('growi:feature:news:service');
+
+/**
+ * Build role filter query for NewsItem
+ */
+const buildRoleFilter = (userRoles: string[]) => ({
+  $or: [
+    { 'conditions.targetRoles': { $exists: false } },
+    { 'conditions.targetRoles': { $size: 0 } },
+    { 'conditions.targetRoles': { $in: userRoles } },
+  ],
+});
+
+export class NewsService {
+  /**
+   * List news items for a user with role filter and read status
+   */
+  async listForUser(
+    userId: Types.ObjectId,
+    userRoles: string[],
+    options: { limit: number; offset: number; onlyUnread?: boolean },
+  ): Promise<PaginateResult<INewsItemWithReadStatus>> {
+    const { limit, offset, onlyUnread = false } = options;
+
+    const roleFilter = buildRoleFilter(userRoles);
+
+    // Get read item IDs for this user
+    const readItemIds = await NewsReadStatus.distinct('newsItemId', { userId });
+
+    const query: Record<string, unknown> = { ...roleFilter };
+    if (onlyUnread) {
+      query._id = { $nin: readItemIds };
+    }
+
+    const [items, totalDocs] = await Promise.all([
+      NewsItem.find(query)
+        .sort({ publishedAt: -1 })
+        .skip(offset)
+        .limit(limit)
+        .lean(),
+      NewsItem.countDocuments(query),
+    ]);
+
+    const readIdSet = new Set(readItemIds.map((id) => id.toString()));
+
+    const docs: INewsItemWithReadStatus[] = items.map((item) => ({
+      ...item,
+      isRead: readIdSet.has(item._id.toString()),
+    }));
+
+    const totalPages = Math.ceil(totalDocs / limit) || 1;
+    const page = Math.floor(offset / limit) + 1;
+
+    return {
+      docs,
+      totalDocs,
+      limit,
+      offset,
+      page,
+      pagingCounter: offset + 1,
+      hasPrevPage: page > 1,
+      hasNextPage: page < totalPages,
+      prevPage: page > 1 ? page - 1 : null,
+      nextPage: page < totalPages ? page + 1 : null,
+      totalPages,
+    };
+  }
+
+  /**
+   * Get unread count for a user
+   */
+  async getUnreadCount(
+    userId: Types.ObjectId,
+    userRoles: string[],
+  ): Promise<number> {
+    const roleFilter = buildRoleFilter(userRoles);
+
+    const readItemIds = await NewsReadStatus.distinct('newsItemId', { userId });
+
+    return NewsItem.countDocuments({
+      ...roleFilter,
+      _id: { $nin: readItemIds },
+    });
+  }
+
+  /**
+   * Mark a single news item as read (idempotent)
+   */
+  async markRead(
+    userId: Types.ObjectId,
+    newsItemId: Types.ObjectId,
+  ): Promise<void> {
+    await NewsReadStatus.updateOne(
+      { userId, newsItemId },
+      { $setOnInsert: { userId, newsItemId, readAt: new Date() } },
+      { upsert: true },
+    );
+  }
+
+  /**
+   * Mark all news items as read for the user (filtered by role)
+   */
+  async markAllRead(
+    userId: Types.ObjectId,
+    userRoles: string[],
+  ): Promise<void> {
+    const roleFilter = buildRoleFilter(userRoles);
+    const items = await NewsItem.find(roleFilter).lean();
+
+    if (items.length === 0) return;
+
+    const now = new Date();
+    const statusDocs = items.map((item) => ({
+      userId,
+      newsItemId: item._id,
+      readAt: now,
+    }));
+
+    try {
+      await NewsReadStatus.insertMany(statusDocs, { ordered: false });
+    } catch (err: unknown) {
+      // Ignore duplicate key errors (already read items) — ordered: false continues on duplicates
+      if ((err as { code?: number }).code !== 11000) {
+        logger.error({ err }, 'markAllRead failed');
+        throw err;
+      }
+    }
+  }
+
+  /**
+   * Upsert news items from feed (keyed by externalId)
+   */
+  async upsertNewsItems(items: INewsItemInput[]): Promise<void> {
+    if (items.length === 0) return;
+
+    const now = new Date();
+
+    await NewsItem.bulkWrite(
+      items.map((item) => ({
+        updateOne: {
+          filter: { externalId: item.id },
+          update: {
+            $set: {
+              externalId: item.id,
+              title: item.title,
+              body: item.body,
+              emoji: item.emoji,
+              url: item.url,
+              publishedAt: new Date(item.publishedAt),
+              fetchedAt: now,
+              conditions: item.conditions,
+            },
+          },
+          upsert: true,
+        },
+      })),
+      { ordered: false },
+    );
+  }
+
+  /**
+   * Delete every cached news item whose externalId is NOT in the supplied set.
+   * Caller passes the full list of externalIds present in the latest feed; any DB
+   * item missing from that list is considered stale and removed (Requirement 1.3).
+   *
+   * Note: passing an empty array means "feed has no items" and will delete every
+   * cached news item. Callers must only invoke this after a successful feed fetch.
+   */
+  async deleteItemsNotInFeed(feedExternalIds: string[]): Promise<void> {
+    await NewsItem.deleteMany({ externalId: { $nin: feedExternalIds } });
+  }
+}

+ 4 - 1
apps/app/src/interfaces/in-app-notification.ts

@@ -1,4 +1,4 @@
-import type { IUser } from '@growi/core';
+import type { HasObjectId, IUser } from '@growi/core';
 
 import type { SupportedActionType, SupportedTargetModelType } from './activity';
 
@@ -19,6 +19,9 @@ export interface IInAppNotification<T = unknown> {
   parsedSnapshot?: any;
 }
 
+export type IInAppNotificationHasId<T = unknown> = IInAppNotification<T> &
+  HasObjectId;
+
 /*
  * Note:
  * Need to use mongoose PaginateResult as a type after upgrading mongoose v6.0.0.

+ 6 - 0
apps/app/src/server/crowi/index.ts

@@ -456,6 +456,12 @@ class Crowi {
 
     startOpenaiCronIfEnabled();
     startAccessTokenCron();
+
+    // News feed sync cron
+    const { NewsCronService } = await import(
+      '~/features/news/server/services/news-cron-service'
+    );
+    new NewsCronService().startCron();
   }
 
   getSlack(): unknown {

+ 4 - 4
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -133,7 +133,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.get(
     '/list',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -222,7 +222,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.get(
     '/status',
-    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.READ.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -272,7 +272,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.post(
     '/open',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,
@@ -309,7 +309,7 @@ module.exports = (crowi: Crowi) => {
    */
   router.put(
     '/all-statuses-open',
-    accessTokenParser([SCOPE.WRITE.USER_SETTINGS.IN_APP_NOTIFICATION], {
+    accessTokenParser([SCOPE.WRITE.FEATURES.IN_APP_NOTIFICATION], {
       acceptLegacy: true,
     }),
     loginRequiredStrictly,

+ 4 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -129,6 +129,10 @@ module.exports = (crowi, app) => {
   }
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
+  router.use(
+    '/news',
+    require('~/features/news/server/routes/news').default(crowi),
+  );
 
   router.use('/personal-setting', require('./personal-setting')(crowi));
   router.use('/user-activities', require('./user-activities')(crowi));

+ 8 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -280,6 +280,9 @@ export const CONFIG_KEYS = [
   'otel:anonymizeInBestEffort',
   'otel:serviceInstanceId',
 
+  // News Settings
+  'news:isDeliveryEnabled',
+
   // S2S Messaging Pubsub Settings
   's2sMessagingPubsub:serverType',
   's2sMessagingPubsub:nchan:publishPath',
@@ -1215,6 +1218,11 @@ export const CONFIG_DEFINITIONS = {
     defaultValue: undefined,
   }),
 
+  // News Settings
+  'news:isDeliveryEnabled': defineConfig<boolean>({
+    defaultValue: true,
+  }),
+
   // S2S Messaging Pubsub Settings
   's2sMessagingPubsub:serverType': defineConfig<string | undefined>({
     envVarName: 'S2SMSG_PUBSUB_SERVER_TYPE',

+ 62 - 9
apps/app/src/stores/in-app-notification.ts

@@ -1,9 +1,11 @@
 import type { SWRConfiguration, SWRResponse } from 'swr';
 import useSWR from 'swr';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+import useSWRInfinite from 'swr/infinite';
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type {
-  IInAppNotification,
+  IInAppNotificationHasId,
   InAppNotificationStatuses,
   PaginateResult,
 } from '~/interfaces/in-app-notification';
@@ -14,21 +16,24 @@ import { apiv3Get } from '../client/util/apiv3-client';
 
 const logger = loggerFactory('growi:cli:InAppNotification');
 
-type inAppNotificationPaginateResult = PaginateResult<IInAppNotification>;
+type InAppNotificationPaginateResult = PaginateResult<IInAppNotificationHasId>;
 
 export const useSWRxInAppNotifications = (
   limit: number,
   offset?: number,
   status?: InAppNotificationStatuses,
   config?: SWRConfiguration,
-): SWRResponse<PaginateResult<IInAppNotification>, Error> => {
+): SWRResponse<InAppNotificationPaginateResult, Error> => {
   return useSWR(
     ['/in-app-notification/list', limit, offset, status],
     ([endpoint]) =>
-      apiv3Get(endpoint, { limit, offset, status }).then((response) => {
-        const inAppNotificationPaginateResult =
-          response.data as inAppNotificationPaginateResult;
-        inAppNotificationPaginateResult.docs.forEach((doc) => {
+      apiv3Get<InAppNotificationPaginateResult>(endpoint, {
+        limit,
+        offset,
+        status,
+      }).then((response) => {
+        const result = response.data;
+        result.docs.forEach((doc) => {
           try {
             if (doc.targetModel === SupportedTargetModel.MODEL_USER) {
               doc.parsedSnapshot = userSerializers.parseSnapshot(doc.snapshot);
@@ -37,7 +42,7 @@ export const useSWRxInAppNotifications = (
             logger.warn('Failed to parse snapshot', err);
           }
         });
-        return inAppNotificationPaginateResult;
+        return result;
       }),
     config,
   );
@@ -48,6 +53,54 @@ export const useSWRxInAppNotificationStatus = (): SWRResponse<
   Error
 > => {
   return useSWR('/in-app-notification/status', (endpoint) =>
-    apiv3Get(endpoint).then((response) => response.data.count),
+    apiv3Get<{ count: number }>(endpoint).then(
+      (response) => response.data.count,
+    ),
+  );
+};
+
+type InAppNotificationListKey =
+  | [string, number, number, InAppNotificationStatuses | undefined]
+  | null;
+
+/**
+ * SWRInfinite hook for paginated in-app notifications (for infinite scroll)
+ */
+export const useSWRINFxInAppNotifications = (
+  limit: number,
+  options?: { status?: InAppNotificationStatuses },
+  config?: SWRConfiguration,
+): SWRInfiniteResponse<InAppNotificationPaginateResult, Error> => {
+  const status = options?.status;
+
+  return useSWRInfinite<InAppNotificationPaginateResult, Error>(
+    (pageIndex, previousPageData): InAppNotificationListKey => {
+      if (previousPageData != null && !previousPageData.hasNextPage)
+        return null;
+      const offset = pageIndex * limit;
+      return ['/in-app-notification/list', limit, offset, status];
+    },
+    ([endpoint, limit, offset, status]) =>
+      apiv3Get<InAppNotificationPaginateResult>(endpoint, {
+        limit,
+        offset,
+        status,
+      }).then((response) => {
+        const result = response.data;
+        result.docs.forEach((doc) => {
+          try {
+            if (doc.targetModel === SupportedTargetModel.MODEL_USER) {
+              doc.parsedSnapshot = userSerializers.parseSnapshot(doc.snapshot);
+            }
+          } catch (err) {
+            logger.warn('Failed to parse snapshot', err);
+          }
+        });
+        return result;
+      }),
+    {
+      ...config,
+      revalidateFirstPage: false,
+    },
   );
 };

+ 3 - 3
packages/core/src/interfaces/scope.spec.ts

@@ -63,9 +63,9 @@ describe('Scope type', () => {
     // Expected count based on the SCOPE_SEED structure:
     // Admin: 17 leaf scopes + 1 wildcard = 18
     // User Settings: 6 leaf + 2 nested (api) + 2 wildcards = 10
-    // Features: 6 leaf scopes + 1 wildcard = 7
-    // Total per action: 35
-    // Total: 35 * 2 (read/write) = 70
+    // Features: 7 leaf scopes + 1 wildcard = 8
+    // Total per action: 36
+    // Total: 36 * 2 (read/write) = 72
     // But some wildcards are at category level, so actual count may vary
 
     // Just ensure we have a reasonable number of scopes

+ 3 - 0
packages/core/src/interfaces/scope.ts

@@ -48,6 +48,7 @@ const SCOPE_SEED_USER = {
     bookmark: {},
     attachment: {},
     page_bulk_export: {},
+    in_app_notification: {},
   },
 } as const;
 
@@ -124,6 +125,7 @@ type ReadFeaturesScope =
   | 'read:features:bookmark'
   | 'read:features:attachment'
   | 'read:features:page_bulk_export'
+  | 'read:features:in_app_notification'
   | 'read:features:*';
 
 // Write scopes - Admin
@@ -167,6 +169,7 @@ type WriteFeaturesScope =
   | 'write:features:bookmark'
   | 'write:features:attachment'
   | 'write:features:page_bulk_export'
+  | 'write:features:in_app_notification'
   | 'write:features:*';
 
 // Combined Scope type - all valid scope strings