Bläddra i källkod

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

feat(spec): Add design, research, and tasks for news in-app notification
Yuki Takei 8 timmar sedan
förälder
incheckning
db67a273e3

+ 598 - 0
.kiro/specs/news-inappnotification/design.md

@@ -0,0 +1,598 @@
+# Design Document: news-inappnotification
+
+## Overview
+
+本機能は GROWI インスタンスが外部の静的 JSON フィード(GitHub Pages)を定期取得し、ニュースとして InAppNotification パネルに表示する。既存の通知(InAppNotification)とニュース(NewsItem)は別モデルで管理し、UI のみクライアント側で時系列マージして統合表示する。
+
+**Purpose**: GROWI 運営者が配信するニュース(リリース情報、セキュリティ通知、お知らせ等)を、ユーザーが既存の通知導線から確認できるようにする。
+
+**Users**: すべての GROWI ログインユーザー。ロール(admin/general)により表示対象を制御できる。
+
+**Impact**: InAppNotification サイドバーパネルに「すべて/通知/お知らせ」フィルタタブと無限スクロールを追加する。既存の「未読のみ」トグルは維持し、フィルタタブとの2重フィルタリングを提供する。
+
+### Goals
+
+- 外部フィード(`NEWS_FEED_URL`)を cron で定期取得し、MongoDB にキャッシュする
+- InAppNotification パネルで通知とニュースを統合表示する
+- ニュースの既読/未読状態をユーザー単位で管理する
+- ロール別表示制御(admin/general)をサーバーサイドで強制する
+- 多言語ニュース(`ja_JP`, `en_US` 等)をブラウザ言語に応じて表示する
+
+### Non-Goals
+
+- GROWI 管理者によるニュース作成・編集 UI(フィードリポジトリで管理)
+- リアルタイムプッシュ通知(cron ポーリングのみ)
+- `growiVersionRegExps` 以外の条件によるフィルタ(将来フェーズ)
+- RSS/Atom フォーマットへの対応(将来フェーズ)
+
+---
+
+## Architecture
+
+### Existing Architecture Analysis
+
+InAppNotification は per-user ドキュメント設計であり、`user` フィールドが必須。通知発生時に全対象ユーザー分のドキュメントを生成する(push 型)。ニュースは全ユーザーで1件のドキュメントを共有し、ユーザーがパネルを開いたときに取得する(pull 型)。この設計上の差異により、ニュースは別モデルとして実装する(詳細は `research.md` の Design Decisions を参照)。
+
+サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、`InAppNotificationSubstance.tsx` へ prop として渡すパターンを採用している。本機能のフィルタ state も同じパターンで実装する。
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+  GitHubPages[GitHub Pages\nfeed.json]
+  NewsCron[NewsCronService]
+  NewsItemModel[NewsItem Model]
+  NewsReadModel[NewsReadStatus Model]
+  NewsService[NewsService]
+  NewsAPI[News API\napiv3/news]
+  SidebarPanel[InAppNotification Panel\nSidebar/InAppNotification/]
+  NewsHooks[useSWRINFxNews\nstores/news.ts]
+  IANHooks[useSWRINFxInAppNotifications\nstores/in-app-notification.ts]
+  InfScroll[InfiniteScroll Component]
+  BadgeItem[PrimaryItemForNotification]
+
+  GitHubPages -->|HTTP GET cron| NewsCron
+  NewsCron -->|upsert / delete| NewsItemModel
+  NewsAPI -->|delegates| NewsService
+  NewsService -->|query| NewsItemModel
+  NewsService -->|query / write| NewsReadModel
+  SidebarPanel -->|fetch| NewsHooks
+  SidebarPanel -->|fetch| IANHooks
+  NewsHooks -->|apiv3Get| NewsAPI
+  SidebarPanel -->|renders| InfScroll
+  BadgeItem -->|count sum| NewsHooks
+```
+
+**Architecture Integration**:
+- 選択パターン: Pull 型 + クライアントサイドマージ
+- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`
+- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `InAppNotificationSubstance.tsx`(フィルタタブ + InfiniteScroll), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算)
+- 既存 `InfiniteScroll.tsx` をそのまま再利用
+
+### Technology Stack
+
+| Layer | 選択 / バージョン | 役割 |
+|---|---|---|
+| Backend Cron | node-cron(既存) | フィード定期取得スケジューリング |
+| Backend HTTP | node `fetch` / axios(既存) | `NEWS_FEED_URL` から feed.json 取得 |
+| Data Store | MongoDB + Mongoose(既存) | NewsItem, NewsReadStatus の永続化 |
+| Frontend Data | SWR `useSWRInfinite`(既存) | ニュース・通知の無限スクロール取得 |
+| Frontend State | React `useState`(既存パターン) | フィルタタブ・未読トグルのローカル state |
+| i18n | next-i18next / `commons.json`(既存) | UI ラベルの多言語化 |
+
+---
+
+## System Flows
+
+### フィード取得フロー
+
+```mermaid
+sequenceDiagram
+  participant Cron as NewsCronService
+  participant Feed as GitHub Pages
+  participant DB as MongoDB
+
+  Cron->>Cron: getCronSchedule() = '0 1 * * *'
+  Cron->>Cron: NEWS_FEED_URL 未設定? → スキップ
+  Cron->>Feed: HTTP GET feed.json
+  alt 取得失敗
+    Cron->>Cron: ログ記録、既存 DB データ維持
+  else 取得成功
+    Cron->>Cron: growiVersionRegExps でフィルタ
+    Cron->>DB: externalId で upsert(新規/更新)
+    Cron->>DB: フィードにないアイテムを削除
+  end
+  Note over DB: TTL インデックス(90日)で自動削除
+```
+
+### パネル表示フロー
+
+```mermaid
+sequenceDiagram
+  participant User
+  participant Panel as InAppNotification Panel
+  participant NewsAPI as News API
+  participant IANAPI as InAppNotification API
+
+  User->>Panel: パネルを開く
+  Panel->>NewsAPI: useSWRINFxNews(limit, { onlyUnread, userRole })
+  Panel->>IANAPI: useSWRINFxInAppNotifications(limit, { status })
+  alt フィルタ = 'all'
+    Panel->>Panel: 両データを publishedAt/createdAt で降順マージ
+  else フィルタ = 'news'
+    Panel->>Panel: NewsItem のみ表示
+  else フィルタ = 'notifications'
+    Panel->>Panel: InAppNotification のみ表示
+  end
+  Panel->>User: レンダリング
+  User->>Panel: スクロール末端に達する
+  Panel->>NewsAPI: setSize(size + 1)(次ページ fetch)
+```
+
+### 既読フロー
+
+```mermaid
+sequenceDiagram
+  participant User
+  participant Component as NewsItem Component
+  participant API as News API
+  participant DB as MongoDB
+
+  User->>Component: クリック
+  Component->>API: POST /apiv3/news/mark-read { newsItemId }
+  API->>DB: NewsReadStatus upsert(userId + newsItemId)
+  Component->>Component: SWR mutate(ローカルキャッシュ更新)
+  Component->>User: url が存在すれば新タブで開く
+```
+
+---
+
+## Requirements Traceability
+
+| 要件 | Summary | コンポーネント | インターフェース | フロー |
+|---|---|---|---|---|
+| 1.1–1.7 | フィード定期取得 | NewsCronService | `executeJob()` | フィード取得フロー |
+| 2.1–2.4 | NewsItem モデル | NewsItem Model | MongoDB schema | フィード取得フロー |
+| 3.1–3.5 | 既読/未読管理 | NewsReadStatus Model, NewsService, News API | `POST /mark-read`, `GET /unread-count` | 既読フロー |
+| 4.1–4.2 | ロール別表示制御 | NewsService | `listForUser(userRole)` | パネル表示フロー |
+| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationSubstance | filter state props | パネル表示フロー |
+| 6.1–6.4 | 視覚表示 | NewsItem Component | CSS classes(`fw-bold`, `bg-primary`) | — |
+| 7.1–7.2 | 未読バッジ | PrimaryItemForNotification | `useSWRxNewsUnreadCount` | — |
+| 8.1–8.4 | 多言語対応 | NewsItem Component, locales | locale fallback logic | — |
+
+---
+
+## Components and Interfaces
+
+### サーバーサイド
+
+| コンポーネント | 層 | Intent | 要件 | 主要依存 |
+|---|---|---|---|---|
+| NewsCronService | Server / Cron | フィード定期取得・DB 同期 | 1.1–1.7 | CronService (P0), NewsService (P0) |
+| NewsItem Model | Server / Data | ニュースアイテムの永続化 | 2.1–2.4 | MongoDB (P0) |
+| NewsReadStatus Model | Server / Data | ユーザー既読状態の永続化 | 3.1–3.3 | MongoDB (P0) |
+| NewsService | Server / Domain | ニュース一覧・既読管理のビジネスロジック | 3.4–3.5, 4.1–4.2 | NewsItem Model (P0), NewsReadStatus Model (P0) |
+| News API | Server / API | HTTP エンドポイント提供 | 3.1–3.5, 4.1–4.2 | NewsService (P0) |
+
+---
+
+#### NewsCronService
+
+| Field | Detail |
+|---|---|
+| Intent | フィード URL から JSON を定期取得し NewsItem を upsert/delete する |
+| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 |
+
+**Responsibilities & Constraints**
+- 毎日 AM 1:00 に実行(`'0 1 * * *'`)
+- `NEWS_FEED_URL` 未設定時はスキップ(エラーなし)
+- 取得失敗時は既存 DB データを維持
+- `growiVersionRegExps` の照合はここで実施(DB には合致アイテムのみ保存)
+- ランダムスリープ(0–5分)で複数インスタンスのリクエストを分散
+
+**Dependencies**
+- Inbound: node-cron — スケジュール実行(P0)
+- Outbound: NewsService — upsert/delete(P0)
+- External: `NEWS_FEED_URL` の HTTP エンドポイント — feed.json 取得(P0)
+
+**Contracts**: Batch [x]
+
+##### Batch / Job Contract
+- Trigger: `node-cron` スケジュール `'0 1 * * *'`
+- Input: `NEWS_FEED_URL` 環境変数、GROWI バージョン文字列
+- Output: MongoDB の NewsItem コレクションを最新フィードと同期
+- Idempotency: `externalId` ユニークインデックスにより冪等。再実行しても重複なし
+
+##### Service Interface
+```typescript
+class NewsCronService extends CronService {
+  getCronSchedule(): string;  // '0 1 * * *'
+  executeJob(): Promise<void>;
+}
+```
+
+**Implementation Notes**
+- Integration: `server/service/cron.ts` の `CronService` を継承。`startCron()` をアプリ起動時に呼ぶ
+- Validation: `NEWS_FEED_URL` が `https://` で始まることを確認。`growiVersionRegExps` は try-catch で個別評価し、不正 regex はスキップ
+- Risks: フィード取得タイムアウト(10秒推奨)。外部依存のため失敗を前提に設計する
+
+---
+
+#### NewsItem Model
+
+| Field | Detail |
+|---|---|
+| Intent | フィードから取得したニュースアイテムを全ユーザー共通で1件保持する |
+| Requirements | 2.1, 2.2, 2.3, 2.4 |
+
+**Contracts**: State [x]
+
+##### State Management
+```typescript
+interface INewsItem {
+  _id: Types.ObjectId;
+  externalId: string;                    // unique index
+  title: Record<string, string>;         // { ja_JP: string, en_US?: string, ... }
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: Date;                     // index
+  fetchedAt: Date;                       // TTL index (90 days = 7776000s)
+  conditions?: {
+    targetRoles?: string[];              // ['admin'] | ['admin', 'general'] | undefined
+  };
+}
+```
+
+**Indexes**:
+- `externalId`: unique index(重複排除)
+- `publishedAt`: index(降順ソート)
+- `fetchedAt`: TTL index(90日で自動削除)
+
+---
+
+#### NewsReadStatus Model
+
+| Field | Detail |
+|---|---|
+| Intent | ユーザーが既読にした時のみドキュメントを作成。ドキュメント不在 = 未読 |
+| Requirements | 3.1, 3.2, 3.3 |
+
+**Contracts**: State [x]
+
+##### State Management
+```typescript
+interface INewsReadStatus {
+  _id: Types.ObjectId;
+  userId: Types.ObjectId;              // compound unique index with newsItemId
+  newsItemId: Types.ObjectId;         // compound unique index with userId
+  readAt: Date;
+}
+```
+
+**Indexes**:
+- `{ userId, newsItemId }`: compound unique index(重複防止・冪等性保証)
+
+---
+
+#### NewsService
+
+| Field | Detail |
+|---|---|
+| Intent | ニュース一覧取得・既読管理のビジネスロジックを担う |
+| Requirements | 3.4, 3.5, 4.1, 4.2 |
+
+**Contracts**: Service [x]
+
+##### Service Interface
+```typescript
+interface INewsService {
+  listForUser(
+    userId: Types.ObjectId,
+    userRoles: string[],
+    options: { limit: number; offset: number; onlyUnread?: boolean }
+  ): Promise<PaginateResult<INewsItemWithReadStatus>>;
+
+  getUnreadCount(userId: Types.ObjectId, userRoles: string[]): Promise<number>;
+
+  markRead(userId: Types.ObjectId, newsItemId: Types.ObjectId): Promise<void>;
+
+  markAllRead(userId: Types.ObjectId, userRoles: string[]): Promise<void>;
+
+  upsertNewsItems(items: INewsItemInput[]): Promise<void>;
+
+  deleteNewsItemsByExternalIds(externalIds: string[]): Promise<void>;
+}
+
+interface INewsItemWithReadStatus extends INewsItem {
+  isRead: boolean;
+}
+```
+
+- Preconditions: `userId` は有効な ObjectId
+- Postconditions: `listForUser` の結果は `publishedAt` 降順。各アイテムに `isRead` が付与される
+- ロールフィルタ: `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返す
+
+---
+
+#### News API
+
+| Field | Detail |
+|---|---|
+| Intent | ニュース一覧取得・既読管理の HTTP エンドポイントを提供する |
+| Requirements | 3.1, 3.4, 3.5, 4.1, 4.2 |
+
+**Contracts**: API [x]
+
+##### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|---|---|---|---|---|
+| GET | `/apiv3/news/list` | `?limit&offset&onlyUnread` | `PaginateResult<INewsItemWithReadStatus>` | 401 |
+| GET | `/apiv3/news/unread-count` | — | `{ count: number }` | 401 |
+| POST | `/apiv3/news/mark-read` | `{ newsItemId: string }` | `{ ok: true }` | 400, 401 |
+| POST | `/apiv3/news/mark-all-read` | — | `{ ok: true }` | 401 |
+
+全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する。
+
+**Implementation Notes**
+- Integration: `apps/app/src/server/routes/apiv3/news.ts` に新規作成
+- Validation: `newsItemId` は `mongoose.isValidObjectId()` で検証
+- Risks: ロールフィルタはサーバーサイドで強制。クライアントから `targetRoles` を受け取らない
+
+---
+
+### クライアントサイド
+
+| コンポーネント | 層 | Intent | 要件 | 主要依存 |
+|---|---|---|---|---|
+| useSWRINFxNews | Client / Hooks | ニュースアイテムの無限スクロール取得 | 5.4 | News API (P0) |
+| useSWRxNewsUnreadCount | Client / Hooks | ニュース未読カウント取得 | 7.1 | News API (P0) |
+| useSWRINFxInAppNotifications | Client / Hooks | 通知の無限スクロール取得(既存 hook を拡張) | 5.4 | InAppNotification API (P0) |
+| InAppNotification.tsx(変更) | Client / UI | フィルタ state を追加管理 | 5.2, 5.3 | useState (P0) |
+| InAppNotificationSubstance.tsx(変更) | Client / UI | フィルタタブ + InfiniteScroll | 5.1–5.5 | useSWRINFxNews (P0), InfiniteScroll (P0) |
+| NewsItem Component | Client / UI | ニュースアイテム1件の表示 | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
+| PrimaryItemForNotification(変更) | Client / UI | 未読バッジに NewsItem の未読数を合算 | 7.1, 7.2 | useSWRxNewsUnreadCount (P0) |
+
+---
+
+#### useSWRINFxNews
+
+| Field | Detail |
+|---|---|
+| Intent | ニュースアイテムの無限スクロールデータ取得 |
+| Requirements | 5.4 |
+
+**Contracts**: State [x]
+
+##### State Management
+```typescript
+// stores/news.ts
+export const useSWRINFxNews = (
+  limit: number,
+  options?: { onlyUnread?: boolean },
+  config?: SWRConfiguration,
+): SWRInfiniteResponse<PaginateResult<INewsItemWithReadStatus>, Error>;
+
+export const useSWRxNewsUnreadCount = (): SWRResponse<number, Error>;
+```
+
+キー: `['/news/list', limit, pageIndex, options.onlyUnread]`
+
+---
+
+#### InAppNotification.tsx(変更)
+
+| Field | Detail |
+|---|---|
+| Intent | フィルタタブ state を追加し、子コンポーネントへ伝播する |
+| Requirements | 5.2, 5.3 |
+
+**Implementation Notes**
+- 既存 `isUnopendNotificationsVisible` state はそのまま維持
+- `activeFilter: 'all' | 'news' | 'notifications'` を `useState('all')` で追加
+- `InAppNotificationForms` と `InAppNotificationContent` へ prop を追加
+
+```typescript
+type FilterType = 'all' | 'news' | 'notifications';
+```
+
+---
+
+#### InAppNotificationSubstance.tsx(変更)
+
+| Field | Detail |
+|---|---|
+| Intent | フィルタタブ UI の追加と、InfiniteScroll を用いた統合リスト表示 |
+| Requirements | 5.1, 5.2, 5.3, 5.4, 5.5 |
+
+**Contracts**: State [x]
+
+**InAppNotificationForms への追加**:
+- フィルタボタン(「すべて」「通知」「お知らせ」)を Bootstrap `btn-group` で実装
+- 既存「未読のみ」トグルは維持
+
+**InAppNotificationContent の変更**:
+- `activeFilter` に応じて3パターンに分岐
+  - `'all'`: `useSWRINFxNews` + `useSWRINFxInAppNotifications` の結果を `publishedAt/createdAt` 降順でマージ
+  - `'news'`: `useSWRINFxNews` のみ。`NewsList` に渡す
+  - `'notifications'`: `useSWRINFxInAppNotifications` のみ。既存 `InAppNotificationList` に渡す
+- 既存 `InfiniteScroll` コンポーネントを使用(`client/components/InfiniteScroll.tsx`)
+- 既存 `// TODO: Infinite scroll implemented` コメントを解消
+
+---
+
+#### NewsItem Component
+
+| Field | Detail |
+|---|---|
+| Intent | ニュースアイテム1件を表示する(emoji、タイトル、未読インジケータ) |
+| Requirements | 5.5, 5.6, 5.7, 6.1, 6.2, 6.3, 6.4, 8.1, 8.2 |
+
+**Implementation Notes**
+- 配置: `features/news/client/components/NewsItem.tsx`
+- ロケールフォールバック: `browserLocale → ja_JP → en_US → 最初に利用可能なキー`
+- 未読: `fw-bold` + 左端に `bg-primary` 8px 丸ドット
+- 既読: `fw-normal` + 同幅の透明スペーサー
+- `emoji` 未設定時は `📢` をフォールバック
+- クリック時: `POST /mark-read` + SWR mutate + `url` があれば新タブで開く
+
+---
+
+## Data Models
+
+### Domain Model
+
+```mermaid
+erDiagram
+  NewsItem {
+    ObjectId _id
+    string externalId
+    object title
+    object body
+    string emoji
+    string url
+    Date publishedAt
+    Date fetchedAt
+    object conditions
+  }
+  NewsReadStatus {
+    ObjectId _id
+    ObjectId userId
+    ObjectId newsItemId
+    Date readAt
+  }
+  User {
+    ObjectId _id
+    string username
+    string role
+  }
+
+  NewsReadStatus }o--|| User : "userId"
+  NewsReadStatus }o--|| NewsItem : "newsItemId"
+```
+
+- NewsItem は全ユーザーで共有する集約ルート(per-instance、not per-user)
+- NewsReadStatus は「ユーザーが既読にした」という事実のみを記録。削除によって「未読に戻す」ことも可能
+
+### Physical Data Model
+
+**NewsItem Collection** (`newsitems`):
+
+```typescript
+const NewsItemSchema = new Schema<INewsItem>({
+  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: { expires: '90d' } },
+  conditions: {
+    targetRoles: [{ type: String }],
+  },
+});
+```
+
+**NewsReadStatus Collection** (`newsreadstatuses`):
+
+```typescript
+const NewsReadStatusSchema = new Schema<INewsReadStatus>({
+  userId: { type: Schema.Types.ObjectId, required: true, ref: 'User' },
+  newsItemId: { type: Schema.Types.ObjectId, required: true, ref: 'NewsItem' },
+  readAt: { type: Date, required: true, default: Date.now },
+});
+NewsReadStatusSchema.index({ userId: 1, newsItemId: 1 }, { unique: true });
+```
+
+### Data Contracts & Integration
+
+**API レスポンス型**:
+
+```typescript
+interface INewsItemWithReadStatus {
+  _id: string;
+  externalId: string;
+  title: Record<string, string>;
+  body?: Record<string, string>;
+  emoji?: string;
+  url?: string;
+  publishedAt: string;  // ISO 8601
+  conditions?: { targetRoles?: string[] };
+  isRead: boolean;
+}
+
+interface PaginateResult<T> {
+  docs: T[];
+  totalDocs: number;
+  limit: number;
+  offset: number;
+  hasNextPage: boolean;
+}
+```
+
+---
+
+## Error Handling
+
+### Error Strategy
+
+フィード取得はフォールバック優先(失敗しても既存データを維持)。API エンドポイントは fail-fast(認証エラーは即時 401)。
+
+### Error Categories and Responses
+
+| カテゴリ | エラー | 対応 |
+|---|---|---|
+| Cron / External | フィード取得失敗(ネットワーク、タイムアウト) | `logger.error` + 既存 DB データ維持。次回 cron で再試行 |
+| Cron / Config | `NEWS_FEED_URL` 未設定 | スキップ(ログなし)。設定されるまで無害に動作 |
+| Cron / Validation | `growiVersionRegExps` に不正 regex | try-catch で該当アイテムをスキップ、`logger.warn` |
+| API / Auth | 未認証リクエスト | 401(`loginRequiredStrictly` が処理) |
+| API / Validation | 不正な `newsItemId` フォーマット | 400(`mongoose.isValidObjectId()` チェック) |
+| API / Conflict | `mark-read` の重複呼び出し | upsert で冪等処理。エラーなし |
+
+### Monitoring
+
+- `NewsCronService.executeJob()` の成功/失敗を `logger.info` / `logger.error` で記録
+- `mark-read` 件数を `logger.debug` で記録(デバッグ用)
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+
+- `NewsCronService.executeJob()`: 正常取得 → upsert、取得失敗 → DB 変更なし、`NEWS_FEED_URL` 未設定 → スキップ
+- `NewsCronService.executeJob()`: `growiVersionRegExps` 一致 → 保存、不一致 → 除外
+- `NewsService.listForUser()`: `targetRoles` フィルタ(admin のみ、general 除外)
+- `NewsService.listForUser()`: `onlyUnread=true` で未読のみ返す
+- `NewsService.getUnreadCount()`: 未読件数の正確な計算
+
+### Integration Tests
+
+- `GET /apiv3/news/list`: ロール別フィルタが正しく動作する
+- `POST /apiv3/news/mark-read`: 2回呼んでもエラーなし(冪等性)
+- `POST /apiv3/news/mark-all-read` 後に `GET /apiv3/news/unread-count` が 0 を返す
+- 未認証リクエストが 401 を返す
+
+### Component Tests
+
+- `NewsItem`: `emoji` 未設定時に 📢 が表示される
+- `NewsItem`: `title` ロケールフォールバック(`browserLocale → ja_JP → en_US`)
+- `NewsItem`: 未読時に `fw-bold` + 青ドット、既読時に `fw-normal` + スペーサー
+- `InAppNotificationForms`: フィルタタブのクリックで `activeFilter` が変わる
+
+---
+
+## Security Considerations
+
+- すべての `/apiv3/news/*` エンドポイントに `loginRequiredStrictly` を適用する
+- `conditions.targetRoles` のフィルタリングはサーバーサイドの `NewsService.listForUser()` で強制する。クライアントから `targetRoles` パラメータを受け付けない
+- `NEWS_FEED_URL` は `https://` のみ許可(HTTP 不可)
+- フィードから取得したデータはそのまま DB に保存し、クライアントへのレスポンス時に Mongoose スキーマで型安全に扱う
+
+## Performance & Scalability
+
+- NewsItem は全ユーザーで1件共有のため、ユーザー数に比例してドキュメントが増えない
+- `publishedAt` インデックスにより降順ソートが効率的
+- `fetchedAt` TTL インデックス(90日)で古いデータを自動削除し、コレクションサイズを制限
+- `NewsReadStatus` の compound unique index により `listForUser` の LEFT JOIN 相当クエリが効率的

+ 1 - 1
.kiro/specs/news-inappnotification/requirements.md

@@ -72,7 +72,7 @@ GROWI の InAppNotification にニュース配信・表示機能を追加する
 2. The InAppNotificationパネル shall 上部にフィルタボタン(「すべて」「通知」「お知らせ」)を配置し、デフォルトは「すべて」とする。「お知らせ」選択時はニュースのみ、「通知」選択時はニュース以外のすべての通知を表示する
 3. The InAppNotificationパネル shall 既存の「未読のみ」トグルスイッチを維持し、種別フィルタと組み合わせた2重フィルタリングを提供する。種別フィルタ(すべて/通知/お知らせ)で表示対象を絞り込んだ上で、トグルON時は未読アイテムのみをさらに絞り込む
 4. The InAppNotificationパネル shall リスト領域に最大高さを設定し、超過分はスクロールで表示する。スクロールが末端に達した場合は次のページを自動で読み込む無限スクロールとする
-5. The InAppNotificationパネル shall ニュースアイテムの `type` に応じた絵文字アイコンをタイトル前に表示する(`release`→🎉, `security`→⚠️, `tips`→💡, `maintenance`→🔧, `announcement`→📢, 未設定→📢)
+5. The InAppNotificationパネル shall ニュースアイテムの `emoji` フィールドをタイトル前に表示する。`emoji` 未設定の場合は 📢 をフォールバックとして使用する
 6. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall ニュースの詳細 URL を新しいタブで開く
 7. When ユーザーがニュースアイテムをクリックした場合, the InAppNotification UI shall 該当ニュースを既読としてマークし、未読インジケータを更新する
 

+ 142 - 0
.kiro/specs/news-inappnotification/research.md

@@ -0,0 +1,142 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Discovery findings and architectural rationale for the news-inappnotification feature.
+
+---
+
+## Summary
+
+- **Feature**: `news-inappnotification`
+- **Discovery Scope**: Complex Integration(新機能 + 既存 InAppNotification UI 拡張)
+- **Key Findings**:
+  - `CronService` 抽象クラスが `server/service/cron.ts` に存在。`NewsCronService extends CronService` のみで cron 基盤が利用可能
+  - `InfiniteScroll` コンポーネントが `client/components/InfiniteScroll.tsx` に存在。`SWRInfiniteResponse` を受け取る汎用実装で再利用可能
+  - サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が state を管理。フィルタ追加はここへの `useState` 追加で対応できる
+  - マージドビュー(すべて)はサーバーサイド JOIN 不要。クライアントサイドで日時ソートするだけで実現できる
+  - 既存 `useSWRxInAppNotifications` は `useSWR`(ページネーション)ベース。無限スクロールのために `useSWRInfinite` 版(`useSWRINFx` prefix)を新設する必要がある
+
+---
+
+## Research Log
+
+### InAppNotification 既存実装の分析
+
+- **Context**: NewsItem を既存 InAppNotification に乗せるか、別モデルにするかの判断
+- **Sources**: `server/models/in-app-notification.ts`, `server/routes/apiv3/in-app-notification.ts`, `server/service/in-app-notification.ts`
+- **Findings**:
+  - InAppNotification は per-user ドキュメント設計。`user` フィールドが必須で、配信時点で全ユーザー分のドキュメントを生成する
+  - `status` フィールド(UNOPENED/OPENED)は per-user ドキュメントが存在することを前提としており、配信時点でのドキュメント生成が不可避
+  - `targetModel` と `action` が enum 制約を持ち、ニュースの externalId 管理に使えない
+  - `snapshot` フィールドにニュース本文を格納した場合、ユーザー数分の本文コピーが発生する
+- **Implications**: NewsItem は別モデルとして実装する。requirements.md の Note に記載された設計根拠が技術的に正確であることを確認
+
+### CronService パターンの確認
+
+- **Context**: フィード定期取得の実装方針
+- **Sources**: `server/service/cron.ts`, `server/service/access-token/access-token-deletion-cron.ts`
+- **Findings**:
+  - `abstract getCronSchedule(): string` と `abstract executeJob(): Promise<void>` を実装するだけでよい
+  - `node-cron` を使用。スケジュール変更は `getCronSchedule()` のオーバーライドで対応
+  - `startCron()` を呼ぶだけで cron が開始される
+- **Implications**: `NewsCronService` の実装は最小限で済む
+
+### InfiniteScroll 実装パターン
+
+- **Context**: 要件 5.4「無限スクロール」の実装方針
+- **Sources**: `client/components/InfiniteScroll.tsx`, `stores/page-listing.tsx`
+- **Findings**:
+  - `InfiniteScroll` コンポーネントは `SWRInfiniteResponse` を props で受け取る汎用コンポーネント
+  - `IntersectionObserver` でセンチネル要素を監視し、`setSize(size + 1)` でページ追加
+  - `useSWRInfinite` のキー命名規則: `useSWRINFx*` prefix
+  - `InAppNotificationSubstance.tsx` に `// TODO: Infinite scroll implemented` コメントあり。今回の実装でこの TODO を解消する
+- **Implications**: `useSWRINFxNews` と `useSWRINFxInAppNotifications` を新設し、既存の `InfiniteScroll` コンポーネントをそのまま利用する
+
+### フロントエンド状態管理パターン
+
+- **Context**: フィルタタブ(すべて/通知/お知らせ)と未読トグルの状態管理方針
+- **Sources**: `Sidebar/InAppNotification/InAppNotification.tsx`, Jotai atom パターン
+- **Findings**:
+  - 既存の「未読のみ」トグルは `useState` で管理され、prop として子コンポーネントに渡している
+  - Jotai は cross-component の持続的 state に使用。パネル内のローカル UI state には `useState` で十分
+  - フィルタタブは同様に `useState` で `'all' | 'news' | 'notifications'` を管理する
+- **Implications**: Jotai は不要。`useState` で統一する
+
+### クライアントサイドマージの実現可能性
+
+- **Context**: 「すべて」フィルタで通知とニュースを時系列マージする実装
+- **Findings**:
+  - InAppNotification は `createdAt` 順、NewsItem は `publishedAt` 順
+  - 両者を `useSWRInfinite` で別々に取得し、各ページのデータをマージしてソート
+  - ページング境界をまたぐマージは複雑になるため、「すべて」フィルタ時は両 API を large limit(例: 20件)で fetch し、クライアントマージする方針
+- **Implications**: 無限スクロールのマージは実装複雑度が高い。「すべて」フィルタ時は両データソースを独立した `useSWRInfinite` で管理し、表示時にマージする
+
+### i18n キー管理
+
+- **Context**: 新規 UI ラベルの多言語化
+- **Sources**: `public/static/locales/ja_JP/commons.json`
+- **Findings**:
+  - `in_app_notification` 名前空間に既存キーが存在(`only_unread`, `no_notification` 等)
+  - 対応ロケール: `ja_JP`, `en_US`, `zh_CN`, `ko_KR`, `fr_FR`
+- **Implications**: 同名前空間に追加キー(`news`, `all`, `notifications`, `no_news`)を追加する
+
+---
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks | Notes |
+|---|---|---|---|---|
+| サーバーサイドマージ | DB の aggregate で通知+ニュースを JOIN してソート | クライアントが単純 | 異なるモデルの JOIN は複雑、ページング境界の処理が難しい | 採用しない |
+| **クライアントサイドマージ** | 別 API で取得しクライアントで日時ソート | 各 API が独立してシンプル | 「すべて」時は2回 API コール | **採用** |
+| ニュース専用ページ | `/me/news` 等の別ページにニュースを表示 | 実装シンプル | 導線が分散、要件 5.1 に不合致 | 採用しない |
+
+---
+
+## Design Decisions
+
+### Decision: NewsItem と NewsReadStatus を別モデルとする
+
+- **Context**: InAppNotification モデルで代替できないか検討
+- **Alternatives Considered**:
+  1. InAppNotification モデルを拡張して newsItem を追加
+  2. 新規 NewsItem + NewsReadStatus モデルを作成
+- **Selected Approach**: 新規モデルを作成(Option 2)
+- **Rationale**: InAppNotification は per-user ドキュメント設計。配信時に全ユーザー分のドキュメントを生成する必要があり、SaaS 規模でストレージ効率が悪い。NewsItem は全ユーザーで1件を共有し、NewsReadStatus は既読時のみ作成する
+- **Trade-offs**: 新モデル追加のコストはあるが、スケール時のストレージ効率は大幅に向上する
+- **Follow-up**: TTL インデックス(90日)の動作確認
+
+### Decision: growiVersionRegExps のフィルタは cron 側で適用
+
+- **Context**: バージョン条件のフィルタタイミング
+- **Alternatives Considered**:
+  1. DB に全件保存し、API クエリ時にフィルタ
+  2. cron 取得時にフィルタし、該当アイテムのみ保存
+- **Selected Approach**: cron 取得時にフィルタ(Option 2)
+- **Rationale**: GROWI のバージョンはインスタンス起動時に確定し、動的に変わらない。DB に不要なデータを保存しない方がクリーン
+- **Trade-offs**: バージョンアップ後に古いアイテムが再表示されない(次回 cron まで)。許容範囲内
+
+### Decision: useSWRInfinite で InAppNotification も再実装
+
+- **Context**: 既存 `useSWRxInAppNotifications` は `useSWR` ベース(ページネーション)
+- **Alternatives Considered**:
+  1. 既存 hook をそのまま使い、InAppNotification の無限スクロールは別途実装
+  2. `useSWRInfinite` ベースの新 hook に切り替え
+- **Selected Approach**: `useSWRINFxInAppNotifications` を新設(Option 2)
+- **Rationale**: `InfiniteScroll` コンポーネントは `SWRInfiniteResponse` を要求する。既存 TODO コメントも無限スクロール実装を示唆している
+- **Trade-offs**: 既存 `useSWRxInAppNotifications` は `InAppNotificationPage.tsx` でも使われているため、両方を維持する
+
+---
+
+## Risks & Mitigations
+
+- クライアントサイドマージで「すべて」フィルタ時に2倍の API コール — 初回は許容。将来的にサーバーサイド集約 API を検討
+- フィード URL が HTTPS でない場合のセキュリティリスク — `NEWS_FEED_URL` のバリデーションで `https://` を強制
+- `growiVersionRegExps` の regex が不正な場合 — try-catch でキャッチし、そのアイテムをスキップしてログ記録
+
+---
+
+## References
+
+- [node-cron documentation](https://github.com/node-cron/node-cron) — cron スケジュール構文
+- [SWR Infinite Loading](https://swr.vercel.app/docs/pagination#infinite-loading) — `useSWRInfinite` パターン
+- [Mongoose TTL indexes](https://mongoosejs.com/docs/guide.html#indexes) — TTL インデックス設定

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

@@ -3,18 +3,18 @@
   "created_at": "2026-03-24T00:00:00Z",
   "updated_at": "2026-03-24T01:00:00Z",
   "language": "ja",
-  "phase": "requirements-generated",
+  "phase": "tasks-generated",
   "approvals": {
     "requirements": {
       "generated": true,
-      "approved": false
+      "approved": true
     },
     "design": {
-      "generated": false,
-      "approved": false
+      "generated": true,
+      "approved": true
     },
     "tasks": {
-      "generated": false,
+      "generated": true,
       "approved": false
     }
   },

+ 150 - 0
.kiro/specs/news-inappnotification/tasks.md

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