Browse Source

add(spec): impl v1

Ryotaro Nagahara 1 month ago
parent
commit
2c65621ea9
29 changed files with 2483 additions and 74 deletions
  1. 34 34
      .kiro/specs/news-inappnotification/tasks.md
  2. 5 1
      apps/app/public/static/locales/en_US/commons.json
  3. 5 1
      apps/app/public/static/locales/fr_FR/commons.json
  4. 5 1
      apps/app/public/static/locales/ja_JP/commons.json
  5. 5 1
      apps/app/public/static/locales/ko_KR/commons.json
  6. 5 1
      apps/app/public/static/locales/zh_CN/commons.json
  7. 7 1
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  8. 97 0
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationForms.spec.tsx
  9. 264 30
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  10. 5 4
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  11. 205 0
      apps/app/src/features/news/client/components/NewsItem.spec.tsx
  12. 94 0
      apps/app/src/features/news/client/components/NewsItem.tsx
  13. 59 0
      apps/app/src/features/news/client/hooks/use-news.ts
  14. 34 0
      apps/app/src/features/news/interfaces/news-item.ts
  15. 11 0
      apps/app/src/features/news/interfaces/news-read-status.ts
  16. 56 0
      apps/app/src/features/news/server/models/news-item.spec.ts
  17. 56 0
      apps/app/src/features/news/server/models/news-item.ts
  18. 29 0
      apps/app/src/features/news/server/models/news-read-status.spec.ts
  19. 43 0
      apps/app/src/features/news/server/models/news-read-status.ts
  20. 176 0
      apps/app/src/features/news/server/routes/news-integration.integ.ts
  21. 214 0
      apps/app/src/features/news/server/routes/news.spec.ts
  22. 175 0
      apps/app/src/features/news/server/routes/news.ts
  23. 239 0
      apps/app/src/features/news/server/services/news-cron-service.spec.ts
  24. 146 0
      apps/app/src/features/news/server/services/news-cron-service.ts
  25. 280 0
      apps/app/src/features/news/server/services/news-service.spec.ts
  26. 180 0
      apps/app/src/features/news/server/services/news-service.ts
  27. 6 0
      apps/app/src/server/crowi/index.ts
  28. 4 0
      apps/app/src/server/routes/apiv3/index.js
  29. 44 0
      apps/app/src/stores/in-app-notification.ts

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

@@ -1,150 +1,150 @@
 # Implementation Plan
 # Implementation Plan
 
 
-- [ ] 0. 動作確認用ローカルフィードサーバーをセットアップする
+- [x] 0. 動作確認用ローカルフィードサーバーをセットアップする
   - `/tmp/feed.json` にサンプルフィードファイルを作成する。`emoji` あり・なし(未設定時は 📢 フォールバック確認)、`title`/`body` の多言語フィールド(`ja_JP`, `en_US`)、`url` あり・なし、`conditions.targetRoles`(admin のみ、全ユーザー)の両パターンを含む複数アイテムで構成する
   - `/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` でアクセスできることを確認する
   - devcontainer 内で `cd /tmp && python3 -m http.server 8099` を起動し、`http://localhost:8099/feed.json` でアクセスできることを確認する
   - `.env` に `NEWS_FEED_URL=http://localhost:8099/feed.json` を追加する
   - `.env` に `NEWS_FEED_URL=http://localhost:8099/feed.json` を追加する
   - 以降のタスクで cron 動作確認が必要な場合はこのサーバーを使用する
   - 以降のタスクで cron 動作確認が必要な場合はこのサーバーを使用する
   - _Requirements: 1.1, 1.6_
   - _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 スキーマを定義する
   - `externalId`(ユニークインデックス)、多言語 `title`/`body`(Map of String)、`emoji`、`url`、`publishedAt`(インデックス)、`fetchedAt`(TTL 90日インデックス)、`conditions.targetRoles` を持つ Mongoose スキーマを定義する
   - 型インターフェース `INewsItem` と `INewsItemHasId` を定義する
   - 型インターフェース `INewsItem` と `INewsItemHasId` を定義する
   - _Requirements: 2.1, 2.2, 2.3, 2.4_
   - _Requirements: 2.1, 2.2, 2.3, 2.4_
 
 
-- [ ] 1.2 (P) NewsReadStatus モデルを実装する
+- [x] 1.2 (P) NewsReadStatus モデルを実装する
   - `userId`・`newsItemId` の複合ユニークインデックス、`readAt` を持つ Mongoose スキーマを定義する
   - `userId`・`newsItemId` の複合ユニークインデックス、`readAt` を持つ Mongoose スキーマを定義する
   - 型インターフェース `INewsReadStatus` を定義する
   - 型インターフェース `INewsReadStatus` を定義する
   - _Requirements: 3.3_
   - _Requirements: 3.3_
 
 
-- [ ] 2. ニュースサービス層を実装する
-- [ ] 2.1 ニュース一覧取得ロジックを実装する
+- [x] 2. ニュースサービス層を実装する
+- [x] 2.1 ニュース一覧取得ロジックを実装する
   - `listForUser(userId, userRoles, { limit, offset, onlyUnread })` を実装する
   - `listForUser(userId, userRoles, { limit, offset, onlyUnread })` を実装する
   - `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返すロール別フィルタを適用する
   - `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返すロール別フィルタを適用する
   - NewsReadStatus との突き合わせにより各アイテムに `isRead: boolean` を付与する
   - NewsReadStatus との突き合わせにより各アイテムに `isRead: boolean` を付与する
   - 結果は `publishedAt` 降順で返す
   - 結果は `publishedAt` 降順で返す
   - _Requirements: 3.4, 4.1, 4.2_
   - _Requirements: 3.4, 4.1, 4.2_
 
 
-- [ ] 2.2 既読管理ロジックを実装する
+- [x] 2.2 既読管理ロジックを実装する
   - `markRead(userId, newsItemId)` を実装する。NewsReadStatus を upsert することで冪等性を保証する
   - `markRead(userId, newsItemId)` を実装する。NewsReadStatus を upsert することで冪等性を保証する
   - `markAllRead(userId, userRoles)` を実装する。ロール別フィルタに合致する全未読アイテムを一括既読にする
   - `markAllRead(userId, userRoles)` を実装する。ロール別フィルタに合致する全未読アイテムを一括既読にする
   - `getUnreadCount(userId, userRoles)` を実装する
   - `getUnreadCount(userId, userRoles)` を実装する
   - _Requirements: 3.1, 3.2, 3.5_
   - _Requirements: 3.1, 3.2, 3.5_
 
 
-- [ ] 2.3 フィード同期ロジックを実装する
+- [x] 2.3 フィード同期ロジックを実装する
   - `upsertNewsItems(items)` を実装する。`externalId` をキーに upsert し、`fetchedAt` を更新する
   - `upsertNewsItems(items)` を実装する。`externalId` をキーに upsert し、`fetchedAt` を更新する
   - `deleteNewsItemsByExternalIds(externalIds)` を実装する
   - `deleteNewsItemsByExternalIds(externalIds)` を実装する
   - _Requirements: 1.2, 1.3_
   - _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/list`(`limit`, `offset`, `onlyUnread` クエリパラメータ)を実装する
   - `GET /apiv3/news/unread-count` を実装する
   - `GET /apiv3/news/unread-count` を実装する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - _Requirements: 3.4, 3.5, 4.1, 4.2_
   - _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-read`(`newsItemId` を受け取る)を実装する。`newsItemId` を `mongoose.isValidObjectId()` で検証する
   - `POST /apiv3/news/mark-all-read` を実装する
   - `POST /apiv3/news/mark-all-read` を実装する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する
   - _Requirements: 3.1, 3.2_
   - _Requirements: 3.1, 3.2_
 
 
-- [ ] 3.3 News API ルートをアプリに登録する
+- [x] 3.3 News API ルートをアプリに登録する
   - Express アプリの apiv3 ルーター定義に `news.ts` を追加する
   - Express アプリの apiv3 ルーター定義に `news.ts` を追加する
   - _Requirements: 3.1, 3.4_
   - _Requirements: 3.1, 3.4_
 
 
-- [ ] 4. NewsCronService を実装する
-- [ ] 4.1 (P) フィード取得・DB 同期処理を実装する
+- [x] 4. NewsCronService を実装する
+- [x] 4.1 (P) フィード取得・DB 同期処理を実装する
   - `CronService` を継承し `getCronSchedule()` で `'0 1 * * *'` を返す
   - `CronService` を継承し `getCronSchedule()` で `'0 1 * * *'` を返す
   - `executeJob()` を実装する:`NEWS_FEED_URL` 未設定時はスキップ、HTTP GET、取得失敗時はログ記録のみ(既存データ維持)
   - `executeJob()` を実装する:`NEWS_FEED_URL` 未設定時はスキップ、HTTP GET、取得失敗時はログ記録のみ(既存データ維持)
   - 取得した各アイテムの `growiVersionRegExps` と現バージョンを照合し、不一致アイテムを除外する。不正 regex は try-catch でスキップしてログ警告する
   - 取得した各アイテムの `growiVersionRegExps` と現バージョンを照合し、不一致アイテムを除外する。不正 regex は try-catch でスキップしてログ警告する
   - フィード外のアイテムを DB から削除し、ランダムスリープ(0–5分)でリクエストを分散する
   - フィード外のアイテムを DB から削除し、ランダムスリープ(0–5分)でリクエストを分散する
   - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
   - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_
 
 
-- [ ] 4.2 cron をアプリ起動時に登録する
+- [x] 4.2 cron をアプリ起動時に登録する
   - アプリの初期化処理で `NewsCronService.startCron()` を呼ぶ
   - アプリの初期化処理で `NewsCronService.startCron()` を呼ぶ
   - _Requirements: 1.1_
   - _Requirements: 1.1_
 
 
-- [ ] 5. フロントエンド SWR フックを実装する
-- [ ] 5.1 (P) ニュース用 SWR フックを新設する
+- [x] 5. フロントエンド SWR フックを実装する
+- [x] 5.1 (P) ニュース用 SWR フックを新設する
   - `useSWRINFxNews(limit, options)` を `useSWRInfinite` ベースで実装する。キーに `limit`, `pageIndex`, `onlyUnread` を含める
   - `useSWRINFxNews(limit, options)` を `useSWRInfinite` ベースで実装する。キーに `limit`, `pageIndex`, `onlyUnread` を含める
   - `useSWRxNewsUnreadCount()` を実装する
   - `useSWRxNewsUnreadCount()` を実装する
   - _Requirements: 5.4, 7.1_
   - _Requirements: 5.4, 7.1_
 
 
-- [ ] 5.2 (P) InAppNotification 用の無限スクロール対応フックを追加する
+- [x] 5.2 (P) InAppNotification 用の無限スクロール対応フックを追加する
   - 既存 `useSWRxInAppNotifications`(`useSWR` ベース)に加えて `useSWRINFxInAppNotifications(limit, options)` を `useSWRInfinite` ベースで新設する
   - 既存 `useSWRxInAppNotifications`(`useSWR` ベース)に加えて `useSWRINFxInAppNotifications(limit, options)` を `useSWRInfinite` ベースで新設する
   - 既存フックは `InAppNotificationPage.tsx` での利用のため維持する
   - 既存フックは `InAppNotificationPage.tsx` での利用のため維持する
   - _Requirements: 5.4_
   - _Requirements: 5.4_
 
 
-- [ ] 6. InAppNotification パネルを改修する
-- [ ] 6.1 フィルタタブを追加する
+- [x] 6. InAppNotification パネルを改修する
+- [x] 6.1 フィルタタブを追加する
   - `InAppNotification.tsx` に `activeFilter: 'all' | 'news' | 'notifications'` の state(デフォルト `'all'`)を追加し、`InAppNotificationForms` と `InAppNotificationContent` へ prop として渡す
   - `InAppNotification.tsx` に `activeFilter: 'all' | 'news' | 'notifications'` の state(デフォルト `'all'`)を追加し、`InAppNotificationForms` と `InAppNotificationContent` へ prop として渡す
   - `InAppNotificationForms` に Bootstrap `btn-group` でフィルタボタン(「すべて」「通知」「お知らせ」)を追加する。既存「未読のみ」トグルは維持する
   - `InAppNotificationForms` に Bootstrap `btn-group` でフィルタボタン(「すべて」「通知」「お知らせ」)を追加する。既存「未読のみ」トグルは維持する
   - _Requirements: 5.2, 5.3_
   - _Requirements: 5.2, 5.3_
 
 
-- [ ] 6.2 無限スクロールを導入する
+- [x] 6.2 無限スクロールを導入する
   - `InAppNotificationContent` で `useSWRINFxNews` と `useSWRINFxInAppNotifications` を使用するよう変更する
   - `InAppNotificationContent` で `useSWRINFxNews` と `useSWRINFxInAppNotifications` を使用するよう変更する
   - 既存の `InfiniteScroll` コンポーネントをラップしてリストを表示する
   - 既存の `InfiniteScroll` コンポーネントをラップしてリストを表示する
   - 既存の `// TODO: Infinite scroll implemented` コメントを解消する
   - 既存の `// TODO: Infinite scroll implemented` コメントを解消する
   - _Requirements: 5.4_
   - _Requirements: 5.4_
 
 
-- [ ] 6.3 「すべて」フィルタ時のクライアントサイドマージを実装する
+- [x] 6.3 「すべて」フィルタ時のクライアントサイドマージを実装する
   - `activeFilter === 'all'` の場合、通知(`createdAt`)とニュース(`publishedAt`)を日時降順でマージして表示する
   - `activeFilter === 'all'` の場合、通知(`createdAt`)とニュース(`publishedAt`)を日時降順でマージして表示する
   - `activeFilter === 'news'` の場合は NewsItem のみ、`activeFilter === 'notifications'` の場合は InAppNotification のみ表示する
   - `activeFilter === 'news'` の場合は NewsItem のみ、`activeFilter === 'notifications'` の場合は InAppNotification のみ表示する
   - _Requirements: 5.1, 5.2_
   - _Requirements: 5.1, 5.2_
 
 
-- [ ] 7. NewsItem コンポーネントを実装する
-- [ ] 7.1 (P) ニュースアイテムの表示コンポーネントを実装する
+- [x] 7. NewsItem コンポーネントを実装する
+- [x] 7.1 (P) ニュースアイテムの表示コンポーネントを実装する
   - `emoji` フィールドをタイトル前に表示する。未設定時は 📢 をフォールバックとする
   - `emoji` フィールドをタイトル前に表示する。未設定時は 📢 をフォールバックとする
   - 多言語タイトルをブラウザ言語で解決する。フォールバック順は `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
   - 多言語タイトルをブラウザ言語で解決する。フォールバック順は `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
   - 未読時はタイトルを `fw-bold` + 左端に `bg-primary` 8px 丸ドット、既読時は `fw-normal` + 同幅の透明スペーサーで表示する
   - 未読時はタイトルを `fw-bold` + 左端に `bg-primary` 8px 丸ドット、既読時は `fw-normal` + 同幅の透明スペーサーで表示する
   - _Requirements: 5.5, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
   - _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 して未読インジケータを更新する
   - クリック時に `POST /apiv3/news/mark-read` を呼び、SWR キャッシュを mutate して未読インジケータを更新する
   - `url` が設定されている場合は新しいタブで開く
   - `url` が設定されている場合は新しいタブで開く
   - _Requirements: 5.6, 5.7_
   - _Requirements: 5.6, 5.7_
 
 
-- [ ] 8. (P) 未読バッジにニュース未読数を合算する
+- [x] 8. (P) 未読バッジにニュース未読数を合算する
   - `PrimaryItemForNotification` で `useSWRxNewsUnreadCount` を呼び、既存の InAppNotification 未読カウントと合算してバッジに表示する
   - `PrimaryItemForNotification` で `useSWRxNewsUnreadCount` を呼び、既存の InAppNotification 未読カウントと合算してバッジに表示する
   - 全ニュースが既読の場合はニュース分のカウントを含めない
   - 全ニュースが既読の場合はニュース分のカウントを含めない
   - _Requirements: 7.1, 7.2_
   - _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`(ニュースはありません)
   - `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_
   - _Requirements: 8.3, 8.4_
 
 
-- [ ] 10. サーバーサイドテストを実装する
-- [ ] 10.1 NewsCronService のテストを実装する
+- [x] 10. サーバーサイドテストを実装する
+- [x] 10.1 NewsCronService のテストを実装する
   - `executeJob()` が正常取得時に upsert・削除を行うことを確認する
   - `executeJob()` が正常取得時に upsert・削除を行うことを確認する
   - `NEWS_FEED_URL` 未設定時にスキップすることを確認する
   - `NEWS_FEED_URL` 未設定時にスキップすることを確認する
   - フィード取得失敗時に DB データが変更されないことを確認する
   - フィード取得失敗時に DB データが変更されないことを確認する
   - `growiVersionRegExps` の一致・不一致・不正 regex の各ケースをテストする
   - `growiVersionRegExps` の一致・不一致・不正 regex の各ケースをテストする
   - _Requirements: 1.1, 1.2, 1.3, 1.5, 1.6, 1.7_
   - _Requirements: 1.1, 1.2, 1.3, 1.5, 1.6, 1.7_
 
 
-- [ ] 10.2 NewsService のテストを実装する
+- [x] 10.2 NewsService のテストを実装する
   - `listForUser()` がロール別フィルタを正しく適用し `isRead` を付与することを確認する
   - `listForUser()` がロール別フィルタを正しく適用し `isRead` を付与することを確認する
   - `onlyUnread=true` で未読のみ返ることを確認する
   - `onlyUnread=true` で未読のみ返ることを確認する
   - `markRead()` の冪等性(2回呼んでもエラーなし)を確認する
   - `markRead()` の冪等性(2回呼んでもエラーなし)を確認する
   - `getUnreadCount()` が `markAllRead()` 後に 0 を返すことを確認する
   - `getUnreadCount()` が `markAllRead()` 後に 0 を返すことを確認する
   - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1, 4.2_
   - _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` がロール別フィルタを強制することを確認する
   - `GET /apiv3/news/list` がロール別フィルタを強制することを確認する
   - `POST /apiv3/news/mark-read` が冪等であることを確認する
   - `POST /apiv3/news/mark-read` が冪等であることを確認する
   - 未認証リクエストが 401 を返すことを確認する
   - 未認証リクエストが 401 を返すことを確認する
   - _Requirements: 3.1, 3.4, 4.1_
   - _Requirements: 3.1, 3.4, 4.1_
 
 
-- [ ] 11. フロントエンドテストを実装する
-- [ ] 11.1 NewsItem コンポーネントのテストを実装する
+- [x] 11. フロントエンドテストを実装する
+- [x] 11.1 NewsItem コンポーネントのテストを実装する
   - `emoji` 未設定時に 📢 が表示されることをテストする
   - `emoji` 未設定時に 📢 が表示されることをテストする
   - タイトルのロケールフォールバック(`browserLocale → ja_JP → en_US`)をテストする
   - タイトルのロケールフォールバック(`browserLocale → ja_JP → en_US`)をテストする
   - 未読・既読の視覚表示(`fw-bold`、青ドット、スペーサー)をテストする
   - 未読・既読の視覚表示(`fw-bold`、青ドット、スペーサー)をテストする
   - クリック時に `mark-read` が呼ばれ、`url` がある場合に新タブで開くことをテストする
   - クリック時に `mark-read` が呼ばれ、`url` がある場合に新タブで開くことをテストする
   - _Requirements: 5.5, 5.6, 5.7, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2_
   - _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 カバレッジ)
   - フィルタタブ切り替えで表示対象が変わることを確認する(5.2 の AC カバレッジ)
   - 「未読のみ」トグルとの組み合わせで2重フィルタリングが機能することを確認する(5.3 の AC カバレッジ)
   - 「未読のみ」トグルとの組み合わせで2重フィルタリングが機能することを確認する(5.3 の AC カバレッジ)
   - _Requirements: 5.2, 5.3_
   - _Requirements: 5.2, 5.3_

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

@@ -58,7 +58,11 @@
     "unopend": "Unread",
     "unopend": "Unread",
     "mark_all_as_read": "Mark all as read",
     "mark_all_as_read": "Mark all as read",
     "no_unread_messages": "no_unread_messages",
     "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": {
   "personal_dropdown": {
     "home": "Home",
     "home": "Home",

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

@@ -57,7 +57,11 @@
     "all": "Toutes",
     "all": "Toutes",
     "unopend": "Non-lues",
     "unopend": "Non-lues",
     "mark_all_as_read": "Tout marquer comme lu",
     "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": {
   "personal_dropdown": {
     "home": "Accueil",
     "home": "Accueil",

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

@@ -61,7 +61,11 @@
     "unopend": "未読",
     "unopend": "未読",
     "mark_all_as_read": "全て既読にする",
     "mark_all_as_read": "全て既読にする",
     "no_unread_messages": "未読はありません",
     "no_unread_messages": "未読はありません",
-    "only_unread": "未読のみ"
+    "only_unread": "未読のみ",
+    "news": "お知らせ",
+    "notifications": "通知",
+    "filter_all": "すべて",
+    "no_news": "ニュースはありません"
   },
   },
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "ホーム",
     "home": "ホーム",

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

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

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

@@ -61,7 +61,11 @@
     "unopend": "未读",
     "unopend": "未读",
     "mark_all_as_read": "标记为已读",
     "mark_all_as_read": "标记为已读",
     "no_unread_messages": "no_unread_messages",
     "no_unread_messages": "no_unread_messages",
-    "only_unread": "Only unread"
+    "only_unread": "Only unread",
+    "news": "公告",
+    "notifications": "通知",
+    "filter_all": "全部",
+    "no_news": "暂无公告"
   },
   },
   "personal_dropdown": {
   "personal_dropdown": {
     "home": "家",
     "home": "家",

+ 7 - 1
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

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

+ 264 - 30
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -1,29 +1,73 @@
-import React, { type JSX } from 'react';
+import { type JSX, useId, useMemo } from 'react';
+import type { HasObjectId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import InAppNotificationList from '~/client/components/InAppNotification/InAppNotificationList';
+import InAppNotificationElm from '~/client/components/InAppNotification/InAppNotificationElm';
+import InfiniteScroll from '~/client/components/InfiniteScroll';
+import { NewsItem } from '~/features/news/client/components/NewsItem';
+import { useSWRINFxNews } from '~/features/news/client/hooks/use-news';
+import type { INewsItemWithReadStatus } from '~/features/news/interfaces/news-item';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
-import { useSWRxInAppNotifications } from '~/stores/in-app-notification';
+import { useSWRINFxInAppNotifications } from '~/stores/in-app-notification';
+
+import type { FilterType } from './InAppNotification';
+
+const NEWS_PER_PAGE = 10;
 
 
 type InAppNotificationFormsProps = {
 type InAppNotificationFormsProps = {
   isUnopendNotificationsVisible: boolean;
   isUnopendNotificationsVisible: boolean;
   onChangeUnopendNotificationsVisible: () => void;
   onChangeUnopendNotificationsVisible: () => void;
+  activeFilter: FilterType;
+  onChangeFilter: (filter: FilterType) => void;
 };
 };
+
 export const InAppNotificationForms = (
 export const InAppNotificationForms = (
   props: InAppNotificationFormsProps,
   props: InAppNotificationFormsProps,
 ): JSX.Element => {
 ): JSX.Element => {
-  const { isUnopendNotificationsVisible, onChangeUnopendNotificationsVisible } =
-    props;
+  const {
+    isUnopendNotificationsVisible,
+    onChangeUnopendNotificationsVisible,
+    activeFilter,
+    onChangeFilter,
+  } = props;
   const { t } = useTranslation('commons');
   const { t } = useTranslation('commons');
+  const toggleId = useId();
 
 
   return (
   return (
     <div className="my-2">
     <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">
       <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">
+        <label className="form-check-label" htmlFor={toggleId}>
           {t('in_app_notification.only_unread')}
           {t('in_app_notification.only_unread')}
         </label>
         </label>
         <input
         <input
-          id="flexSwitchCheckDefault"
+          id={toggleId}
           className="form-check-input"
           className="form-check-input"
           type="checkbox"
           type="checkbox"
           role="switch"
           role="switch"
@@ -38,37 +82,227 @@ export const InAppNotificationForms = (
 
 
 type InAppNotificationContentProps = {
 type InAppNotificationContentProps = {
   isUnopendNotificationsVisible: boolean;
   isUnopendNotificationsVisible: boolean;
+  activeFilter: FilterType;
 };
 };
+
+type MergedItem =
+  | { type: 'news'; item: INewsItemWithReadStatus; sortKey: Date }
+  | {
+      type: 'notification';
+      item: IInAppNotification & HasObjectId;
+      sortKey: Date;
+    };
+
 export const InAppNotificationContent = (
 export const InAppNotificationContent = (
   props: InAppNotificationContentProps,
   props: InAppNotificationContentProps,
 ): JSX.Element => {
 ): JSX.Element => {
-  const { isUnopendNotificationsVisible } = props;
+  const { isUnopendNotificationsVisible, activeFilter } = props;
   const { t } = useTranslation('commons');
   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 },
+  const notificationStatus = isUnopendNotificationsVisible
+    ? InAppNotificationStatuses.STATUS_UNOPENED
+    : undefined;
+
+  // Always call both hooks (React rules of hooks)
+  const newsResponse = useSWRINFxNews(
+    NEWS_PER_PAGE,
+    { onlyUnread: isUnopendNotificationsVisible },
+    { keepPreviousData: true },
+  );
+
+  const notificationResponse = useSWRINFxInAppNotifications(
+    NEWS_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: (IInAppNotification & HasObjectId)[] =
+    useMemo(() => {
+      if (!notificationResponse.data) return [];
+      return notificationResponse.data.flatMap(
+        (page) => page.docs,
+      ) as (IInAppNotification & HasObjectId)[];
+    }, [notificationResponse.data]);
+
+  // Determine if each stream has exhausted its pages
+  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
+  const allModeSWRResponse = useMemo(
+    () => ({
+      data: newsResponse.data,
+      error: newsResponse.error ?? notificationResponse.error,
+      isValidating:
+        newsResponse.isValidating || notificationResponse.isValidating,
+      isLoading: newsResponse.isLoading || notificationResponse.isLoading,
+      mutate: newsResponse.mutate,
+      setSize: (updater: number | ((size: number) => number)) => {
+        const promises: Promise<unknown>[] = [];
+        if (!newsExhausted) {
+          promises.push(
+            newsResponse.setSize(
+              typeof updater === 'function'
+                ? updater(newsResponse.size)
+                : updater,
+            ),
+          );
+        }
+        if (!notifExhausted) {
+          promises.push(
+            notificationResponse.setSize(
+              typeof updater === 'function'
+                ? updater(notificationResponse.size)
+                : updater,
+            ),
+          );
+        }
+        return Promise.all(promises) as unknown as Promise<
+          (typeof newsResponse.data)[]
+        >;
+      },
+      size: Math.max(newsResponse.size, notificationResponse.size),
+    }),
+    [newsResponse, notificationResponse, newsExhausted, notifExhausted],
+  );
+
+  // Merged and sorted items for 'all' filter
+  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]);
+
+  const handleReadMutate = () => {
+    newsResponse.mutate();
+  };
+
+  if (activeFilter === 'news') {
+    if (allNewsItems.length === 0 && !newsResponse.isValidating) {
+      return <>{t('in_app_notification.no_news')}</>;
+    }
+
+    return (
+      <div className="overflow-auto" style={{ maxHeight: '60vh' }}>
+        <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="overflow-auto" style={{ maxHeight: '60vh' }}>
+        <InfiniteScroll
+          swrInifiniteResponse={notificationResponse}
+          isReachingEnd={notifExhausted}
+        >
+          <div className="list-group">
+            {allNotificationItems.map((notification) => (
+              <InAppNotificationElm
+                key={notification._id.toString()}
+                notification={notification}
+              />
+            ))}
+          </div>
+        </InfiniteScroll>
+      </div>
+    );
+  }
+
+  // 'all' filter: merged view
+  if (
+    mergedItems.length === 0 &&
+    !newsResponse.isValidating &&
+    !notificationResponse.isValidating
+  ) {
+    return <>{t('in_app_notification.no_notification')}</>;
+  }
 
 
   return (
   return (
-    <>
-      {inAppNotificationData != null &&
-      inAppNotificationData.docs.length === 0 ? (
-        // no items
-        t('in_app_notification.no_notification')
-      ) : (
-        // render list-group
-        <InAppNotificationList
-          inAppNotificationData={inAppNotificationData}
-          onUnopenedNotificationOpend={mutateInAppNotificationData}
-        />
-      )}
-    </>
+    <div className="overflow-auto" style={{ maxHeight: '60vh' }}>
+      <InfiniteScroll
+        swrInifiniteResponse={
+          allModeSWRResponse as unknown as Parameters<
+            typeof InfiniteScroll
+          >[0]['swrInifiniteResponse']
+        }
+        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}
+                />
+              );
+            }
+            return (
+              <InAppNotificationElm
+                key={`notif-${entry.item._id.toString()}`}
+                notification={entry.item}
+              />
+            );
+          })}
+        </div>
+      </InfiniteScroll>
+    </div>
   );
   );
 };
 };

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

@@ -1,5 +1,6 @@
 import { memo, useCallback, useEffect } from 'react';
 import { memo, useCallback, useEffect } from 'react';
 
 
+import { useSWRxNewsUnreadCount } from '~/features/news/client/hooks/use-news';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useGlobalSocket } from '~/states/socket-io';
 import { useGlobalSocket } from '~/states/socket-io';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
@@ -20,10 +21,10 @@ export const PrimaryItemForNotification = memo(
     const { data: notificationCount, mutate: mutateNotificationCount } =
     const { data: notificationCount, mutate: mutateNotificationCount } =
       useSWRxInAppNotificationStatus();
       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(
     const itemHoverHandler = useCallback(
       (contents: SidebarContentsType) => {
       (contents: SidebarContentsType) => {

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

@@ -0,0 +1,205 @@
+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();
+  return { apiv3Post, mutate };
+});
+
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Post: mocks.apiv3Post,
+}));
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+    i18n: { language: 'ja_JP' },
+  }),
+}));
+
+// 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();
+  });
+
+  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 browser language is ja_JP', () => {
+      const item = makeNewsItem({
+        title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
+      });
+      render(
+        <NewsItem
+          item={item}
+          onReadMutate={onReadMutate}
+          browserLanguage="ja_JP"
+        />,
+      );
+      expect(screen.getByText('日本語タイトル')).toBeTruthy();
+    });
+
+    test('should fallback to ja_JP when browser language has no match', () => {
+      const item = makeNewsItem({
+        title: { ja_JP: '日本語タイトル', en_US: 'English Title' },
+      });
+      render(
+        <NewsItem
+          item={item}
+          onReadMutate={onReadMutate}
+          browserLanguage="de_DE"
+        />,
+      );
+      expect(screen.getByText('日本語タイトル')).toBeTruthy();
+    });
+
+    test('should fallback to en_US when ja_JP is not available', () => {
+      const item = makeNewsItem({ title: { en_US: 'English Only' } });
+      render(
+        <NewsItem
+          item={item}
+          onReadMutate={onReadMutate}
+          browserLanguage="de_DE"
+        />,
+      );
+      expect(screen.getByText('English Only')).toBeTruthy();
+    });
+
+    test('should fallback to first available key when neither ja_JP nor en_US', () => {
+      const item = makeNewsItem({ title: { fr_FR: 'Titre Français' } });
+      render(
+        <NewsItem
+          item={item}
+          onReadMutate={onReadMutate}
+          browserLanguage="de_DE"
+        />,
+      );
+      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();
+      });
+    });
+  });
+});

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

@@ -0,0 +1,94 @@
+import type { FC } from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+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;
+  browserLanguage?: string;
+};
+
+export const NewsItem: FC<Props> = ({
+  item,
+  onReadMutate,
+  browserLanguage,
+}) => {
+  const locale =
+    browserLanguage ??
+    (typeof navigator !== 'undefined'
+      ? navigator.language.replace('-', '_')
+      : 'ja_JP');
+  const title = resolveTitle(item.title, locale);
+  const emoji = item.emoji ?? DEFAULT_EMOJI;
+
+  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"
+      style={{
+        cursor: 'pointer',
+        width: '100%',
+        textAlign: 'left',
+        background: 'none',
+      }}
+      onClick={handleClick}
+    >
+      <div className="d-flex align-items-center">
+        {/* Unread indicator dot or transparent spacer */}
+        <span
+          className={`${item.isRead ? '' : 'bg-primary'} rounded-circle me-3`}
+          style={{ width: 8, height: 8, minWidth: 8, display: 'inline-block' }}
+        />
+
+        {/* Avatar position: emoji */}
+        <span className="me-2" style={{ fontSize: '1.2rem', lineHeight: 1 }}>
+          {emoji}
+        </span>
+
+        {/* Content column */}
+        <div>
+          <span className={item.isRead ? 'fw-normal' : 'fw-bold'}>{title}</span>
+          <div className="text-muted small">
+            {item.publishedAt instanceof Date
+              ? item.publishedAt.toLocaleDateString(locale.replace('_', '-'))
+              : new Date(item.publishedAt).toLocaleDateString(
+                  locale.replace('_', '-'),
+                )}
+          </div>
+        </div>
+      </div>
+    </button>
+  );
+};

+ 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,
+  );
+};

+ 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);

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

@@ -0,0 +1,176 @@
+/**
+ * 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/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);
+    });
+  });
+});

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

@@ -0,0 +1,214 @@
+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();
+  return {
+    NewsService: vi.fn(() => ({
+      listForUser,
+      getUnreadCount,
+      markRead,
+      markAllRead,
+    })),
+    listForUser,
+    getUnreadCount,
+    markRead,
+    markAllRead,
+  };
+});
+
+vi.mock('../services/news-service', () => ({
+  NewsService: mocks.NewsService,
+}));
+
+// 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 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();
+    });
+  });
+});

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

@@ -0,0 +1,175 @@
+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 { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import loggerFactory from '~/utils/logger';
+
+import { NewsService } from '../services/news-service';
+
+const logger = loggerFactory('growi:feature:news:routes');
+
+type NewsRequest = CrowiRequest & { user: IUserHasId };
+
+/**
+ * Returns user roles based on admin flag
+ */
+const getUserRoles = (user: IUserHasId): string[] => {
+  return user.admin ? ['admin'] : ['general'];
+};
+
+/**
+ * Creates and returns the news Express router.
+ * Accepts an optional Crowi instance for middleware setup.
+ */
+export const createNewsRouter = (crowi?: { loginRequired?: unknown }) => {
+  const router = express.Router();
+
+  // Use loginRequiredFactory when crowi is provided, otherwise use a pass-through middleware for testing
+  const loginRequiredStrictly =
+    crowi != null
+      ? loginRequiredFactory(
+          crowi as Parameters<typeof loginRequiredFactory>[0],
+        )
+      : (_req: unknown, _res: unknown, next: () => void) => next();
+
+  /**
+   * GET /news/list
+   * Returns paginated news items filtered by user role
+   */
+  router.get(
+    '/list',
+    accessTokenParser([SCOPE.READ.USER_SETTINGS.IN_APP_NOTIFICATION], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    async (req: NewsRequest, res) => {
+      try {
+        const user = req.user;
+        const userRoles = getUserRoles(user);
+
+        const limit =
+          req.query.limit != null
+            ? parseInt(String(req.query.limit), 10) || 10
+            : 10;
+        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.USER_SETTINGS.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.READ.USER_SETTINGS.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.READ.USER_SETTINGS.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' });
+      }
+    },
+  );
+
+  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: Parameters<typeof loginRequiredFactory>[0]) =>
+  createNewsRouter({ loginRequired: crowi });

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

@@ -0,0 +1,239 @@
+// Hoisted mocks
+const mocks = vi.hoisted(() => {
+  const upsertNewsItems = vi.fn();
+  const deleteNewsItemsByExternalIds = vi.fn();
+  const mockFetch = vi.fn();
+  const getGrowiVersion = vi.fn(() => '7.5.0');
+
+  return {
+    NewsService: vi.fn(() => ({
+      upsertNewsItems,
+      deleteNewsItemsByExternalIds,
+    })),
+    upsertNewsItems,
+    deleteNewsItemsByExternalIds,
+    mockFetch,
+    getGrowiVersion,
+  };
+});
+
+vi.mock('../services/news-service', () => ({
+  NewsService: mocks.NewsService,
+}));
+
+vi.mock('~/utils/growi-version', () => ({
+  getGrowiVersion: mocks.getGrowiVersion,
+}));
+
+// 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'] },
+    },
+  ],
+};
+
+describe('NewsCronService', () => {
+  let service: NewsCronService;
+  const originalEnv = process.env.NEWS_FEED_URL;
+
+  beforeEach(() => {
+    service = new NewsCronService();
+    vi.clearAllMocks();
+    // Reset random mock
+    vi.spyOn(Math, 'random').mockReturnValue(0);
+  });
+
+  afterEach(() => {
+    process.env.NEWS_FEED_URL = originalEnv;
+  });
+
+  describe('getCronSchedule', () => {
+    test('should return daily schedule at 1AM', () => {
+      expect(service.getCronSchedule()).toBe('0 1 * * *');
+    });
+  });
+
+  describe('executeJob', () => {
+    test('should skip when NEWS_FEED_URL is not set', async () => {
+      delete process.env.NEWS_FEED_URL;
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).not.toHaveBeenCalled();
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+    });
+
+    test('should skip when NEWS_FEED_URL is empty string', async () => {
+      process.env.NEWS_FEED_URL = '';
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).not.toHaveBeenCalled();
+    });
+
+    test('should skip when NEWS_FEED_URL uses non-allowed http', async () => {
+      process.env.NEWS_FEED_URL = 'http://example.com/feed.json';
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).not.toHaveBeenCalled();
+    });
+
+    test('should allow https:// URLs', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve(VALID_FEED),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).toHaveBeenCalledWith(
+        'https://example.com/feed.json',
+        expect.any(Object),
+      );
+    });
+
+    test('should allow http://localhost URLs', async () => {
+      process.env.NEWS_FEED_URL = 'http://localhost:8099/feed.json';
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve(VALID_FEED),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).toHaveBeenCalled();
+    });
+
+    test('should allow http://127.0.0.1 URLs', async () => {
+      process.env.NEWS_FEED_URL = 'http://127.0.0.1:8099/feed.json';
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve(VALID_FEED),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.mockFetch).toHaveBeenCalled();
+    });
+
+    test('should upsert items on successful fetch', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      mocks.mockFetch.mockResolvedValue({
+        ok: true,
+        json: () => Promise.resolve(VALID_FEED),
+      });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).toHaveBeenCalledWith(VALID_FEED.items);
+      expect(mocks.deleteNewsItemsByExternalIds).toHaveBeenCalledWith([]);
+    });
+
+    test('should NOT update DB when fetch fails', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      mocks.mockFetch.mockResolvedValue({ ok: false, status: 500 });
+
+      await service.executeJob();
+
+      expect(mocks.upsertNewsItems).not.toHaveBeenCalled();
+      expect(mocks.deleteNewsItemsByExternalIds).not.toHaveBeenCalled();
+    });
+
+    test('should NOT update DB when fetch throws', async () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      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 () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      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({
+        ok: true,
+        json: () => Promise.resolve(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 () => {
+      process.env.NEWS_FEED_URL = 'https://example.com/feed.json';
+      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({
+        ok: true,
+        json: () => Promise.resolve(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',
+      );
+    });
+  });
+});

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

@@ -0,0 +1,146 @@
+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 { NewsService } from './news-service';
+
+const logger = loggerFactory('growi:feature:news:cron');
+
+/** Maximum random sleep in ms (5 minutes) */
+const MAX_RANDOM_SLEEP_MS = 5 * 60 * 1000;
+
+/** HTTP fetch timeout in ms */
+const FETCH_TIMEOUT_MS = 10_000;
+
+interface FeedItem {
+  id: string;
+  type?: string;
+  emoji?: string;
+  title: Record<string, string>;
+  body?: Record<string, string>;
+  url?: string;
+  publishedAt: string;
+  conditions?: {
+    targetRoles?: string[];
+    growiVersionRegExps?: string[];
+  };
+}
+
+interface FeedJson {
+  version: string;
+  items: FeedItem[];
+}
+
+/**
+ * Check if the given URL is allowed for fetching
+ */
+const isAllowedUrl = (url: string): boolean => {
+  if (url.startsWith('https://')) return true;
+  if (url.startsWith('http://localhost')) return true;
+  if (url.startsWith('http://127.0.0.1')) return true;
+  return false;
+};
+
+/**
+ * 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 1 * * *';
+  }
+
+  override async executeJob(): Promise<void> {
+    const feedUrl = process.env.NEWS_FEED_URL;
+
+    if (!feedUrl || feedUrl.trim() === '') {
+      logger.debug('NEWS_FEED_URL is not set, skipping news feed sync');
+      return;
+    }
+
+    if (!isAllowedUrl(feedUrl)) {
+      logger.warn(
+        `NEWS_FEED_URL "${feedUrl}" is not allowed. Only https:// and http://localhost or http://127.0.0.1 are permitted.`,
+      );
+      return;
+    }
+
+    // Random sleep to distribute requests across multiple GROWI instances
+    await randomSleep(MAX_RANDOM_SLEEP_MS);
+
+    let feedJson: FeedJson;
+    try {
+      const response = await fetch(feedUrl, {
+        signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
+      });
+
+      if (!response.ok) {
+        logger.error(`Failed to fetch news feed: HTTP ${response.status}`);
+        return;
+      }
+
+      feedJson = (await response.json()) as FeedJson;
+    } catch (err) {
+      logger.error('Error fetching news feed, keeping existing data', err);
+      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,
+    }));
+
+    const feedIds = new Set(filteredItems.map((item) => item.id));
+
+    // Get all existing external IDs to find which ones are no longer in the feed
+    // We pass all filtered items' IDs — items not in the feed are determined by exclusion
+    const allFeedIds = feedJson.items.map((item) => item.id);
+    const idsToDelete = allFeedIds.filter((id) => !feedIds.has(id));
+
+    const service = new NewsService();
+    await service.upsertNewsItems(newsItemInputs);
+    await service.deleteNewsItemsByExternalIds(idsToDelete);
+  }
+}

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

@@ -0,0 +1,280 @@
+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 newsItemUpdateMany = 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,
+      updateMany: newsItemUpdateMany,
+      deleteMany: newsItemDeleteMany,
+      countDocuments: newsItemCountDocuments,
+    },
+    NewsReadStatus: {
+      distinct: newsReadStatusDistinct,
+      updateOne: newsReadStatusUpdateOne,
+      insertMany: newsReadStatusInsertMany,
+    },
+    newsItemFind,
+    newsItemUpdateMany,
+    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 filter by targetRoles when conditions are set', async () => {
+      const userId = new mongoose.Types.ObjectId();
+
+      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([]);
+
+      await service.listForUser(userId, ['general'], { limit: 10, offset: 0 });
+
+      const findCall = mocks.newsItemFind.mock.calls[0][0];
+      expect(findCall).toMatchObject({
+        $or: expect.arrayContaining([
+          { 'conditions.targetRoles': { $exists: false } },
+          { 'conditions.targetRoles': { $size: 0 } },
+          { 'conditions.targetRoles': { $in: ['general'] } },
+        ]),
+      });
+    });
+
+    test('should filter onlyUnread when specified', async () => {
+      const userId = new mongoose.Types.ObjectId();
+      const readId = new mongoose.Types.ObjectId();
+      mocks.newsReadStatusDistinct.mockResolvedValue([readId]);
+
+      mocks.newsItemFind.mockReturnValue({
+        sort: vi.fn().mockReturnThis(),
+        skip: vi.fn().mockReturnThis(),
+        limit: vi.fn().mockReturnThis(),
+        lean: vi.fn().mockResolvedValue([]),
+      });
+      mocks.newsItemCountDocuments.mockResolvedValue(0);
+
+      await service.listForUser(userId, ['general'], {
+        limit: 10,
+        offset: 0,
+        onlyUnread: true,
+      });
+
+      const findCall = mocks.newsItemFind.mock.calls[0][0];
+      expect(findCall).toMatchObject({
+        _id: { $nin: [readId] },
+      });
+    });
+  });
+
+  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('getUnreadCount', () => {
+    test('should return the number of unread items', async () => {
+      const id1 = new mongoose.Types.ObjectId();
+      const id2 = new mongoose.Types.ObjectId();
+      const id3 = new mongoose.Types.ObjectId();
+
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi
+          .fn()
+          .mockResolvedValue([{ _id: id1 }, { _id: id2 }, { _id: id3 }]),
+      });
+      mocks.newsReadStatusDistinct.mockResolvedValue([id1]);
+
+      const userId = new mongoose.Types.ObjectId();
+      const count = await service.getUnreadCount(userId, ['general']);
+      // 3 total - 1 read = 2 unread
+      expect(count).toBe(2);
+    });
+
+    test('should return 0 when all items are read', async () => {
+      const id1 = new mongoose.Types.ObjectId();
+      const id2 = new mongoose.Types.ObjectId();
+
+      mocks.newsItemFind.mockReturnValue({
+        lean: vi.fn().mockResolvedValue([{ _id: id1 }, { _id: id2 }]),
+      });
+      mocks.newsReadStatusDistinct.mockResolvedValue([id1, id2]);
+
+      const userId = new mongoose.Types.ObjectId();
+      const count = await service.getUnreadCount(userId, ['general']);
+      expect(count).toBe(0);
+    });
+  });
+
+  describe('upsertNewsItems', () => {
+    test('should call updateMany with upsert for each item', async () => {
+      mocks.newsItemUpdateMany.mockResolvedValue({ upsertedCount: 1 });
+
+      await service.upsertNewsItems([
+        {
+          id: 'ext-001',
+          title: { ja_JP: 'Test' },
+          publishedAt: '2026-01-01T00:00:00Z',
+        },
+      ]);
+
+      expect(mocks.newsItemUpdateMany).toHaveBeenCalledTimes(1);
+      const [filter, update, opts] = mocks.newsItemUpdateMany.mock.calls[0];
+      expect(filter).toEqual({ externalId: 'ext-001' });
+      expect(update.$set.externalId).toBe('ext-001');
+      expect(opts).toEqual({ upsert: true });
+    });
+
+    test('should upsert multiple items', async () => {
+      mocks.newsItemUpdateMany.mockResolvedValue({ upsertedCount: 1 });
+
+      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.newsItemUpdateMany).toHaveBeenCalledTimes(2);
+    });
+  });
+
+  describe('deleteNewsItemsByExternalIds', () => {
+    test('should call deleteMany with externalId filter', async () => {
+      mocks.newsItemDeleteMany.mockResolvedValue({ deletedCount: 1 });
+
+      await service.deleteNewsItemsByExternalIds(['ext-001', 'ext-002']);
+
+      expect(mocks.newsItemDeleteMany).toHaveBeenCalledWith({
+        externalId: { $in: ['ext-001', 'ext-002'] },
+      });
+    });
+
+    test('should do nothing if externalIds is empty', async () => {
+      await service.deleteNewsItemsByExternalIds([]);
+      expect(mocks.newsItemDeleteMany).not.toHaveBeenCalled();
+    });
+  });
+});

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

@@ -0,0 +1,180 @@
+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 as unknown as INewsItemWithReadStatus),
+      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 [allItems, readItemIds] = await Promise.all([
+      NewsItem.find(roleFilter).lean(),
+      NewsReadStatus.distinct('newsItemId', { userId }),
+    ]);
+
+    const readIdSet = new Set(readItemIds.map((id) => id.toString()));
+    return allItems.filter((item) => !readIdSet.has(item._id.toString()))
+      .length;
+  }
+
+  /**
+   * 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('markAllRead failed', err);
+        throw err;
+      }
+    }
+  }
+
+  /**
+   * Upsert news items from feed (keyed by externalId)
+   */
+  async upsertNewsItems(items: INewsItemInput[]): Promise<void> {
+    const now = new Date();
+
+    await Promise.all(
+      items.map((item) =>
+        NewsItem.updateMany(
+          { externalId: item.id },
+          {
+            $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 },
+        ),
+      ),
+    );
+  }
+
+  /**
+   * Delete news items that are no longer in the feed
+   */
+  async deleteNewsItemsByExternalIds(externalIds: string[]): Promise<void> {
+    if (externalIds.length === 0) return;
+
+    await NewsItem.deleteMany({ externalId: { $in: externalIds } });
+  }
+}

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

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

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

@@ -123,6 +123,10 @@ module.exports = (crowi, app) => {
   }
   }
 
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
   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('/personal-setting', require('./personal-setting')(crowi));
   router.use('/user-activities', require('./user-activities')(crowi));
   router.use('/user-activities', require('./user-activities')(crowi));

+ 44 - 0
apps/app/src/stores/in-app-notification.ts

@@ -1,5 +1,7 @@
 import type { SWRConfiguration, SWRResponse } from 'swr';
 import type { SWRConfiguration, SWRResponse } from 'swr';
 import useSWR from 'swr';
 import useSWR from 'swr';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+import useSWRInfinite from 'swr/infinite';
 
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type {
 import type {
@@ -51,3 +53,45 @@ export const useSWRxInAppNotificationStatus = (): SWRResponse<
     apiv3Get(endpoint).then((response) => response.data.count),
     apiv3Get(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<PaginateResult<IInAppNotification>, Error> => {
+  const status = options?.status;
+
+  return useSWRInfinite<PaginateResult<IInAppNotification>, 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(endpoint, { limit, offset, status }).then((response) => {
+        const result = response.data as inAppNotificationPaginateResult;
+        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,
+    },
+  );
+};