|
|
@@ -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` 参照と組み合わせて、変化のないリスト項目は再レンダをスキップする
|