Browse Source

update: design.md

Ryotaro Nagahara 3 weeks ago
parent
commit
6f48b68e3d
1 changed files with 129 additions and 28 deletions
  1. 129 28
      .kiro/specs/news-inappnotification/design.md

+ 129 - 28
.kiro/specs/news-inappnotification/design.md

@@ -33,7 +33,7 @@
 
 InAppNotification は per-user ドキュメント設計であり、`user` フィールドが必須。通知発生時に全対象ユーザー分のドキュメントを生成する(push 型)。ニュースは全ユーザーで1件のドキュメントを共有し、ユーザーがパネルを開いたときに取得する(pull 型)。この設計上の差異により、ニュースは別モデルとして実装する(詳細は `research.md` の Design Decisions を参照)。
 
-サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、`InAppNotificationSubstance.tsx` へ prop として渡すパターンを採用している。本機能のフィルタ state も同じパターンで実装する。
+サイドバーパネルは `Sidebar/InAppNotification/InAppNotification.tsx` が `useState` でトグル state を管理し、子コンポーネントへ prop として渡すパターンを採用している。本機能ではフィルタ state も同じく親で管理する。データ層(2 つの SWR ストリーム合流・マージ・mutation handlers)は責務集中による凝集度低下を避けるため `hooks/useMergedInAppNotifications.ts` のカスタムフックに集約し、Forms(フィルタ UI)と Content(リスト描画)のプレゼンテーションを分離する。
 
 ### Architecture Pattern & Boundary Map
 
@@ -65,8 +65,9 @@ graph TB
 
 **Architecture Integration**:
 - 選択パターン: Pull 型 + クライアントサイドマージ
-- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`
-- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `InAppNotificationSubstance.tsx`(フィルタタブ + InfiniteScroll), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算)
+- 新規コンポーネント: `NewsCronService`, `NewsItem Model`, `NewsReadStatus Model`, `NewsService`, `News API`, `NewsItem Component`, `useSWRINFxNews`, `useMergedInAppNotifications`(パネルのデータ層フック), `InAppNotificationForms.tsx`, `InAppNotificationContent.tsx`
+- 既存コンポーネント拡張: `InAppNotification.tsx`(フィルタ state 追加), `useSWRINFxInAppNotifications`(新設), `PrimaryItemForNotification`(未読カウント合算), `InAppNotificationElm.tsx`(既存通知側の修正あり)
+- スコープ拡張: `@growi/core` に `features.in_app_notification` を新設し、News API と既存 `/in-app-notification/*` の通知データ取得系エンドポイントを移行(設定 CRUD は `user_settings.in_app_notification` のまま)
 - 既存 `InfiniteScroll.tsx` をそのまま再利用
 
 ### Technology Stack
@@ -92,14 +93,15 @@ sequenceDiagram
   participant Feed as GitHub Pages
   participant DB as MongoDB
 
-  Cron->>Cron: getCronSchedule() = '0 1 * * *'
+  Cron->>Cron: getCronSchedule() = '0 0 * * *'(midnight 起動)
   Cron->>Cron: NEWS_FEED_URL 未設定? → スキップ
+  Cron->>Cron: randomSleep(0–5 時間)でリクエスト時刻を分散
   Cron->>Feed: HTTP GET feed.json
   alt 取得失敗
     Cron->>Cron: ログ記録、既存 DB データ維持
   else 取得成功
     Cron->>Cron: growiVersionRegExps でフィルタ
-    Cron->>DB: externalId で upsert(新規/更新
+    Cron->>DB: bulkWrite で一括 upsert(externalId キー、ordered:false
     Cron->>DB: フィードにないアイテムを削除
   end
   Note over DB: TTL インデックス(90日)で自動削除
@@ -155,7 +157,7 @@ sequenceDiagram
 | 2.1–2.4 | NewsItem モデル | NewsItem Model | MongoDB schema | フィード取得フロー |
 | 3.1–3.5 | 既読/未読管理 | NewsReadStatus Model, NewsService, News API | `POST /mark-read`, `GET /unread-count` | 既読フロー |
 | 4.1–4.2 | ロール別表示制御 | NewsService | `listForUser(userRole)` | パネル表示フロー |
-| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationSubstance | filter state props | パネル表示フロー |
+| 5.1–5.7 | UI 統合表示 | InAppNotification Panel, InAppNotificationForms, InAppNotificationContent, useMergedInAppNotifications | filter state props, フックの戻り値 | パネル表示フロー |
 | 6.1–6.4 | 視覚表示 | NewsItem Component | CSS classes(`fw-bold`, `bg-primary`) | — |
 | 7.1–7.2 | 未読バッジ | PrimaryItemForNotification | `useSWRxNewsUnreadCount` | — |
 | 8.1–8.4 | 多言語対応 | NewsItem Component, locales | locale fallback logic | — |
@@ -184,11 +186,13 @@ sequenceDiagram
 | Requirements | 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7 |
 
 **Responsibilities & Constraints**
-- 毎日 AM 1:00 に実行(`'0 1 * * *'`)
+- 毎日 0 時に発火し、ランダムスリープで実取得時刻を 0–5 時に分散させる(cron 起動 `'0 0 * * *'` + `randomSleep(0–5h)`)
 - `NEWS_FEED_URL` 未設定時はスキップ(エラーなし)
 - 取得失敗時は既存 DB データを維持
 - `growiVersionRegExps` の照合はここで実施(DB には合致アイテムのみ保存)
-- ランダムスリープ(0–5分)で複数インスタンスのリクエストを分散
+
+**配信先への分散戦略**:
+全 GROWI インスタンスが同じ時間帯にフィードへアクセスするため、CDN ミス時に origin(GitHub Pages)へ集中する thundering herd を避ける必要がある。`'0 1 * * *'` + 5 分窓では実用上の希釈が小さいため、**5 時間ウィンドウ + 60 倍希釈**(対 5 分窓比)に拡張した。GitHub Pages の月間 100GB 帯域クォータと CDN キャッシュ TTL 10 分という外部条件を踏まえ、夜間帯 0–5 時に均等分散する設計。
 
 **Dependencies**
 - Inbound: node-cron — スケジュール実行(P0)
@@ -198,7 +202,7 @@ sequenceDiagram
 **Contracts**: Batch [x]
 
 ##### Batch / Job Contract
-- Trigger: `node-cron` スケジュール `'0 1 * * *'`
+- Trigger: `node-cron` スケジュール `'0 0 * * *'`(実取得は randomSleep を経て 0–5 時に分散)
 - Input: `NEWS_FEED_URL` 環境変数、GROWI バージョン文字列
 - Output: MongoDB の NewsItem コレクションを最新フィードと同期
 - Idempotency: `externalId` ユニークインデックスにより冪等。再実行しても重複なし
@@ -206,9 +210,11 @@ sequenceDiagram
 ##### Service Interface
 ```typescript
 class NewsCronService extends CronService {
-  getCronSchedule(): string;  // '0 1 * * *'
+  getCronSchedule(): string;  // '0 0 * * *'
   executeJob(): Promise<void>;
 }
+
+const MAX_RANDOM_SLEEP_MS = 5 * 60 * 60 * 1000;  // 5 hours
 ```
 
 **Implementation Notes**
@@ -313,6 +319,29 @@ interface INewsItemWithReadStatus extends INewsItem {
 - Postconditions: `listForUser` の結果は `publishedAt` 降順。各アイテムに `isRead` が付与される
 - ロールフィルタ: `conditions.targetRoles` が未設定または `userRoles` に一致するアイテムのみ返す
 
+**`upsertNewsItems` の実装制約**:
+
+配信側(`tmp/news-feed-delivery-spec.md`)でフィードアイテム数の上限は規定されない(運用の柔軟性を優先)。受信側 NewsItem の TTL(90 日)はフィードに残り続けるアイテムの `fetchedAt` が毎回更新されるため実質発火しない。よって items 配列は理論上無制限に成長しうる。実運用想定は 5 年で ~150–250 件だが上限保証は無い。
+
+`Promise.all(items.map(NewsItem.updateMany))` での並列 fan-out は項目数増加時に DB コネクションプール圧迫・IO 飽和を招くため、**`NewsItem.bulkWrite([...], { ordered: false })` で 1 DB コマンドにバッチ化**する。`markAllRead` の `insertMany({ ordered: false })` と一貫したスタイル。
+
+```typescript
+async upsertNewsItems(items: INewsItemInput[]): Promise<void> {
+  if (items.length === 0) return;
+  const now = new Date();
+  await NewsItem.bulkWrite(
+    items.map(item => ({
+      updateOne: {
+        filter: { externalId: item.id },
+        update: { $set: { ... fetchedAt: now } },
+        upsert: true,
+      },
+    })),
+    { ordered: false },
+  );
+}
+```
+
 ---
 
 #### News API
@@ -335,8 +364,28 @@ interface INewsItemWithReadStatus extends INewsItem {
 
 全エンドポイントに `loginRequiredStrictly` と `accessTokenParser` を適用する。
 
+**Scope 設計**:
+
+GROWI の scope 階層は以下の意味論で運用する:
+
+| 階層 | 意味 | 例 |
+|---|---|---|
+| `user_settings.X` | ユーザーの **X 機能に関する設定値** の CRUD | `/personal-setting/in-app-notification-settings`(通知設定) |
+| `features.X` | **X 機能のデータ自体** へのアクセス | `/pages/list`(ページデータ), `/news/list`(ニュースデータ) |
+
+通知データ取得は機能データへのアクセスに該当するため `features.in_app_notification` を新設し、News API 4 エンドポイントを移行する。あわせて既存 `/in-app-notification/*` の 4 エンドポイント(`list` / `status` / `open` / `all-statuses-open`)も同スコープへ移行(既存は `user_settings.in_app_notification` を誤用していた)。`/personal-setting/in-app-notification-settings` GET/POST は通知設定 CRUD なので `user_settings.in_app_notification` のまま維持する。
+
+| Method | Endpoint | Scope |
+|---|---|---|
+| GET | `/apiv3/news/list` | `read:features:in_app_notification` |
+| GET | `/apiv3/news/unread-count` | `read:features:in_app_notification` |
+| POST | `/apiv3/news/mark-read` | `write:features:in_app_notification` |
+| POST | `/apiv3/news/mark-all-read` | `write:features:in_app_notification` |
+
+`@growi/core` の `SCOPE_SEED_USER.features.in_app_notification` 追加と、`accesstoken_scopes_desc` i18n(`en_US` / `ja_JP` / `zh_CN` / `fr_FR`)の更新が必要。
+
 **Implementation Notes**
-- Integration: `apps/app/src/server/routes/apiv3/news.ts` に新規作成
+- Integration: `apps/app/src/features/news/server/routes/news.ts` に新規作成。`createNewsRouter(crowi?: Crowi)` をエクスポートし、optional `Crowi` で受けてテスト時にミドルウェアを pass-through できる構造(型アサーションは使わない)
 - Validation: `newsItemId` は `mongoose.isValidObjectId()` で検証
 - Risks: ロールフィルタはサーバーサイドで強制。クライアントから `targetRoles` を受け取らない
 
@@ -349,9 +398,11 @@ interface INewsItemWithReadStatus extends INewsItem {
 | useSWRINFxNews | Client / Hooks | ニュースアイテムの無限スクロール取得 | 5.4 | News API (P0) |
 | useSWRxNewsUnreadCount | Client / Hooks | ニュース未読カウント取得 | 7.1 | News API (P0) |
 | useSWRINFxInAppNotifications | Client / Hooks | 通知の無限スクロール取得(既存 hook を拡張) | 5.4 | InAppNotification API (P0) |
+| useMergedInAppNotifications | Client / Hooks | パネルのデータ層(2 SWR + 終端判定 + 合成 response + マージ + 既読 mutation handlers) | 5.1–5.5 | useSWRINFxNews (P0), useSWRINFxInAppNotifications (P0) |
 | InAppNotification.tsx(変更) | Client / UI | フィルタ state を追加管理 | 5.2, 5.3 | useState (P0) |
-| InAppNotificationSubstance.tsx(変更) | Client / UI | フィルタタブ + InfiniteScroll | 5.1–5.5 | useSWRINFxNews (P0), InfiniteScroll (P0) |
-| NewsItem Component | Client / UI | ニュースアイテム1件の表示 | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
+| InAppNotificationForms.tsx(新設) | Client / UI | フィルタタブ + 未読トグル UI | 5.2, 5.3 | — |
+| InAppNotificationContent.tsx(新設) | Client / UI | 3 分岐レンダラー(all/news/notifications) + InfiniteScroll | 5.1, 5.4, 5.5 | useMergedInAppNotifications (P0), InfiniteScroll (P0) |
+| NewsItem Component | Client / UI | ニュースアイテム1件の表示(`React.memo` で wrap) | 5.5, 5.6, 5.7, 6.1–6.4, 8.1–8.2 | — |
 | PrimaryItemForNotification(変更) | Client / UI | 未読バッジに NewsItem の未読数を合算 | 7.1, 7.2 | useSWRxNewsUnreadCount (P0) |
 
 ---
@@ -405,28 +456,59 @@ type FilterType = 'all' | 'news' | 'notifications';
 
 ---
 
-#### InAppNotificationSubstance.tsx(変更)
+#### Panel modules: Forms + Content + data hook
 
 | Field | Detail |
 |---|---|
-| Intent | フィルタタブ UI の追加と、InfiniteScroll を用いた統合リスト表示 |
+| Intent | フィルタタブ UI とリスト描画を独立に保ち、データ層はカスタムフックに集約する |
 | Requirements | 5.1, 5.2, 5.3, 5.4, 5.5 |
 
 **Contracts**: State [x]
 
-**InAppNotificationForms への追加**:
+**ファイル構成**:
+
+```
+client/components/Sidebar/InAppNotification/
+├── InAppNotification.tsx                  (フィルタ state を管理し props で配布)
+├── InAppNotificationForms.tsx             (Forms UI のみ)
+├── InAppNotificationContent.tsx           (3 分岐レンダラー)
+└── hooks/
+    └── useMergedInAppNotifications.ts     (データ層)
+```
+
+**設計原則**:
+- **データ層と表示層を分離する**。`useMergedInAppNotifications` フックがニュース・通知の両 `useSWRInfinite` 呼び出し、ページ終端判定、合成 SWRInfiniteResponse の構築、マージ、既読 mutation handlers を一手に引き受ける。これにより `InAppNotificationContent` はフックの戻り値を受け取って `activeFilter` で 3 分岐するだけの薄い renderer になる
+- **Forms はプレゼンテーションのみ**。データ層に触れない
+- 単一ファイルで 7 責務(スクロール戦略・SWR 2 本・終端判定・合成 response・マージ・mutation 2 種・3 分岐 render)を抱えていた v1 の `InAppNotificationSubstance.tsx`(339 行)は廃止し、上記 3 モジュールに分割する
+
+**InAppNotificationForms**:
 - フィルタボタン(「すべて」「通知」「お知らせ」)を Bootstrap `btn-group` で実装
-- 既存「未読のみ」トグルは維持
-
-**InAppNotificationContent の変更**:
-- `activeFilter` に応じて3パターンに分岐
-  - `'all'`: `useSWRINFxNews` + `useSWRINFxInAppNotifications` の両フックを呼び、現在ロード済みの全ページデータを `publishedAt/createdAt` 降順でマージして表示する。`InfiniteScroll.tsx` に渡すために **合成 `swrInifiniteResponse` オブジェクト**を生成する:`setSize` は終端に達していない方のストリームをインクリメント(両方未終端なら両方インクリメント)、`isValidating` はいずれかが true なら true、とする。両ストリームが終端に達したら `isReachingEnd = true` として `InfiniteScroll` に渡す
-  - `'news'`: `useSWRINFxNews` のみ。`NewsList` に渡す
-  - `'notifications'`: `useSWRINFxInAppNotifications` のみ。既存 `InAppNotificationList` に渡す
-- 既存 `InfiniteScroll` コンポーネントを使用(`client/components/InfiniteScroll.tsx`)
+- 既存「未読のみ」トグルを維持
+
+**InAppNotificationContent (3 分岐)**:
+- `'all'`: `useMergedInAppNotifications.allModeSWRResponse` + `mergedItems` を `InfiniteScroll` に渡し、両ストリームをマージ表示
+- `'news'`: `newsResponse` + `allNewsItems` のみ
+- `'notifications'`: `notificationResponse` + `allNotificationItems` のみ
+- 既存 `InfiniteScroll` コンポーネント(`client/components/InfiniteScroll.tsx`)を再利用
 - 既存 `// TODO: Infinite scroll implemented` コメントを解消
 
-**サイドバーモード別スクロール戦略(実装後に判明した設計上の決定)**:
+**useMergedInAppNotifications フック**:
+
+戻り値:
+```typescript
+{
+  newsResponse, allNewsItems, newsExhausted,
+  notificationResponse, allNotificationItems, notifExhausted,
+  allModeSWRResponse, mergedItems,
+  handleReadMutate, handleNotificationRead,
+}
+```
+
+- `'all'` モード用の合成 `SWRInfiniteResponse`: `setSize` は終端に達していないストリームをインクリメント(両方未終端なら両方)、`isValidating` はいずれかが true なら true、両ストリーム終端時に `isReachingEnd = true`
+- `mergedItems` は両ストリームの `flatMap → publishedAt/createdAt 降順 sort`
+- ハンドラは `useCallback` で参照を安定化する。SWR の `mutate` は cache key 単位で stable なので、`{ mutate: mutateNews } = newsResponse` のように destructure して deps に含める(biome のルール対応)
+
+**サイドバーモード別スクロール戦略**:
 
 サイドバーには2種類のモードがあり、スクロール担当コンテナが異なる。
 
@@ -443,11 +525,11 @@ collapsed モードで `overflow-auto + maxHeight` を使い、dock/drawer モ
 
 **通知ドット即時消去: SWR mutate による楽観的更新**:
 
-`InAppNotificationElm` はクリック時に `apiv3Post('/in-app-notification/open')` でサーバーへ書き込みを行うが、UI への反映は SWR キャッシュの即時書き換えで行う。`InAppNotificationContent` 内で `notificationResponse.mutate(updater, { revalidate: false })` を用い、`useSWRInfinite` のページごとに該当 `doc.status` を `STATUS_OPENED` へ書き換える。
+`InAppNotificationElm` はクリック時に `apiv3Post('/in-app-notification/open')` でサーバーへ書き込みを行うが、UI への反映は SWR キャッシュの即時書き換えで行う。`useMergedInAppNotifications.handleNotificationRead` 内で `mutateNotifications(updater, { revalidate: false })` を用い、`useSWRInfinite` のページごとに該当 `doc.status` を `STATUS_OPENED` へ書き換える。
 
 `useSWRInfinite` のキャッシュは `SWRConfig` プロバイダの Map に保持されるため、同一 React tree のアンマウント/リマウントを跨いで状態が維持され、リマウント後もドットは消えたままとなる。ローカル `useState` を持たずに SWR の標準機能のみで完結させることで、キャッシュ・再検証制御・キー共有といった SWR の利点をそのまま活かせる。
 
-品質改善の経緯: PR #10986 のレビュー FB を受け、当初採用した `useState<Set<string>>` 戦略を SWR `mutate` + `revalidate: false` に差し替えた。実機検証で SWRConfig のグローバル Map がアンマウント/リマウント間でキャッシュを保持することを確認し、ドット復活の再発がないことを確認した。
+品質改善の経緯: PR #10986 のレビュー FB を受け、当初採用した `useState<Set<string>>` 戦略を SWR `mutate` + `revalidate: false` に差し替えた。さらに PR #11050 で Substance 単一ファイル構造を Forms / Content / data hook の 3 モジュールに分割(凝集度向上)し、ハンドラを `useCallback` 化した。
 
 ---
 
@@ -469,6 +551,7 @@ collapsed モードで `overflow-auto + maxHeight` を使い、dock/drawer モ
 - Bootstrap クラス: `w-100 text-start bg-transparent fs-5 lh-1` などを利用し、インラインスタイルを最小化
 - 未読ドット: `InAppNotificationElm` と共有の `UnreadDot.module.scss` を使用し、両者の見た目を完全に揃える
 - クリック時: `POST /mark-read` + SWR mutate + `url` があれば新タブで開く
+- **再レンダ最適化**: `export const NewsItem = memo(NewsItemInner)` で `React.memo` ラップ。親 `InAppNotificationContent` の再レンダ時、SWR が同一参照を返している `item` props と `useCallback` で参照安定化された `onReadMutate` props により、変化のないアイテムは再レンダされない。`<InAppNotificationElm>`(legacy 経路 `InAppNotificationDropdown` / `InAppNotificationPage` から共有される)の memo 化は本機能のスコープ外として将来 PR で対応
 
 ---
 
@@ -613,13 +696,31 @@ interface INewsItemWithReadStatus {
 ## Security Considerations
 
 - すべての `/apiv3/news/*` エンドポイントに `loginRequiredStrictly` を適用する
+- アクセストークン用 scope は **`features.in_app_notification`** を使用する(read / write)。設定 CRUD 用の `user_settings.in_app_notification` とはセマンティクスが異なるため流用しない。アクセストークン発行時にユーザーが意図した粒度でアクセスを許可できるようにする
 - `conditions.targetRoles` のフィルタリングはサーバーサイドの `NewsService.listForUser()` で強制する。クライアントから `targetRoles` パラメータを受け付けない
 - `NEWS_FEED_URL` は `https://` で始まる URL は常に許可。`http://localhost` または `http://127.0.0.1` で始まる URL はローカル開発用として許可。それ以外の `http://` は拒否する
 - フィードから取得したデータはそのまま DB に保存し、クライアントへのレスポンス時に Mongoose スキーマで型安全に扱う
 
 ## Performance & Scalability
 
+**データ量とインデックス**:
 - NewsItem は全ユーザーで1件共有のため、ユーザー数に比例してドキュメントが増えない
 - `publishedAt` インデックスにより降順ソートが効率的
-- `fetchedAt` TTL インデックス(90日)で古いデータを自動削除し、コレクションサイズを制限
+- `fetchedAt` TTL インデックス(90日)は **フィードから外れたアイテムにのみ実質発火** する(フィードに残り続けるアイテムは毎回 `fetchedAt` が更新されるため発火しない)。よってコレクションサイズの上限は配信側のキュレーションに依存する
 - `NewsReadStatus` の compound unique index により `listForUser` の LEFT JOIN 相当クエリが効率的
+
+**フィードアイテム規模の前提**:
+- 配信側スキーマ(`tmp/news-feed-delivery-spec.md`)でフィードアイテム数の上限規定は設けない(運用の柔軟性を優先)
+- 想定ペース: release 12–24 件/年、security/tips/maintenance/announcement 合わせて 30–50 件/年
+- 5 年運用で **150–250 件程度** の見込み。ただし上限保証はないため、実装は無制限成長に耐える形で設計する
+
+**書き込み戦略**:
+- `NewsCronService.executeJob()` 内の upsert は `NewsItem.bulkWrite([...], { ordered: false })` で 1 DB コマンドにバッチ化。`Promise.all(items.map(updateMany))` の並列 fan-out は項目数増加時に DB コネクションプール圧迫・IO 飽和を招くため採用しない
+
+**配信先への分散**:
+- cron を `'0 0 * * *'` + `randomSleep(0–5 時間)` に設定し、複数 GROWI インスタンスのリクエストを夜間 5 時間ウィンドウに均等分散する
+- `'0 1 * * *'` + 5 分窓と比較して **約 60 倍の希釈**。GitHub Pages の月間 100GB 帯域クォータ・10 分 CDN キャッシュ TTL に対して thundering herd を回避できる
+- 即時性は不要(日次配信)であり、5 時間ウィンドウは UX への影響なし
+
+**フロントエンド再レンダ**:
+- `<NewsItem>` は `React.memo` ラップ。`useMergedInAppNotifications` のハンドラ群は `useCallback` で参照安定化されており、SWR が返す `item` 参照と組み合わせて、変化のないリスト項目は再レンダをスキップする