---
title: "統合フィードアイテム データ設計書"
---

最終更新: 2026-09-17

## 1. 概要

トップ画面の新着投稿セクション（`components/NewPostsSection.tsx`）等における、Ameba・Instagram・TikTok・YouTube 4種のSNSデータ取得を、TanStack Query（`@tanstack/react-query`）によるクライアントサイドキャッシュ層に置き換えるための設計。

現状、`NewPostsSection`は完全なサーバーコンポーネントとして実装されており、Blob/DB直接アクセス関数を`await`で直列に呼び出し、UI層とデータ取得・整形ロジックが密結合になっている。本設計では、各SNSのデータ取得をAPI層（Route Handler）に切り出し、クライアント側からTanStack Queryの`useQuery`で取得・キャッシュする構成に変更する。これにより、UI層とデータ取得層を分離し、将来的にライブ・リリース等の他領域にも同じパターンを展開できるようにする。

対応する実装Issue: MorningStatusApp#1569

---

## 2. 背景・目的

| 目的 | 詳細 |
| --- | --- |
| UI層とデータ取得層の分離 | `NewPostsSection.tsx`（857行）がSNS4種の同期判定・フィルタリング・遡及判定・ソート・UI描画を一括で担っている「Fat Component」状態を解消する |
| クライアントサイドキャッシュの導入 | Blob/DB専用のサーバー関数をAPI層（Route Handler）経由でクライアントから呼び出し可能にし、TanStack Queryでキャッシュ・再取得を管理する |
| 汎用的なデータ取得基盤の確立 | SNS4種で先行導入し、有効性を確認できればライブ・リリース等他領域にも水平展開する（§6参照） |

---

## 3. 統合フィードアイテム（`UnifiedFeedItem`）スキーマ

### 3-1. 判別共用体の定義（`types/feed.ts` 想定）

`source`フィールドを判別キーとする判別共用体（Discriminated Union）として定義する。

```typescript
const FeedItemBaseSchema = z.object({
  memberId: z.string(),
  capturedAt: z.string(),      // 収集日時（新着判定の基準。投稿日時ではない）
  isRetroactive: z.boolean(),  // 遡及収集判定
});

export const AmebaFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("ameba"),
  blogUrl: z.string().url(),
  title: z.string(),
  publishedAtLabel: z.string().nullable(), // 遡及時のみ実投稿日時ラベルを表示
  lastUpdated: z.string(),
});

export const InstagramFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("instagram"),
  postUrl: z.string().url(),
  thumbnailUrl: z.string().url().optional(),
  isLive: z.boolean().optional(),
  isReel: z.boolean().optional(),
  caption: z.string(),
});

export const TikTokFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("tiktok"),
  memberId: z.string().optional(), // OGメンバーのみ。公式アカウントの場合はundefined
  postUrl: z.string().url(),
  thumbnailUrl: z.string().url().optional(),
  caption: z.string(),
  accountId: z.string().optional(),
  channelType: z.enum(["official", "og"]).optional(),
});

export const YoutubeFeedItemSchema = FeedItemBaseSchema.extend({
  source: z.literal("youtube"),
  memberId: z.string().optional(), // OGメンバーのみ。公式チャンネルの場合はundefined
  videoId: z.string(),
  title: z.string(),
  publishedAt: z.string(),
  channelType: z.enum(["official", "og"]),
});

export const UnifiedFeedItemSchema = z.discriminatedUnion("source", [
  AmebaFeedItemSchema,
  InstagramFeedItemSchema,
  TikTokFeedItemSchema,
  YoutubeFeedItemSchema,
]);
export type UnifiedFeedItem = z.infer<typeof UnifiedFeedItemSchema>;
```

### 3-2. 既存型からのマッピングルール

| SNS | 既存の型・データソース | `UnifiedFeedItem`へのマッピング |
| --- | --- | --- |
| Ameba | `Member.statusHistory[]`/`latestStatus`（`StatusEntry`、`types/member.ts`）。専用の投稿一覧取得関数を持たず、`members`と`AmebaSyncStatus`から動的算出する（§4-2参照） | `blogUrl` ← `hasOfficialAmebaBlog(member)`（`member.officialSns`にAmebaブログのactiveエントリがあるか）でメンバーを絞り込んだ上で`findSupportedBlogUrl(entry.sources)`、`title` ← `entry.content.split("\n")[0]`、`lastUpdated` ← `entry.lastUpdated`、`capturedAt` ← `entry.capturedAt`、`isRetroactive` ← `isRetroactivePost(entry.lastUpdated, amebaSyncStatus.previousAutoSyncedAt)`、`publishedAtLabel` ← 遡及時のみ`formatJST(entry.lastUpdated)` |
| Instagram | `DailyNewPost`（`types/instagram.ts`、`InstagramPostSchema`を`memberName`で拡張） | `postUrl`・`thumbnailUrl`・`isLive`・`isReel`・`caption`・`capturedAt`・`memberId`はそのまま対応。`isRetroactive`は既存に相当フィールドなし（要調整、§4-2参照） |
| TikTok | `TikTokPost`（`types/tiktok.ts`） | `postUrl`・`thumbnailUrl`・`caption`・`capturedAt`・`accountId`・`channelType`・`memberId`はそのまま対応。`memberId`は`channelType: 'og'`のみ存在し、公式アカウントの投稿では`undefined`（§3-1参照） |
| YouTube | `YoutubePost`（`types/youtube.ts`） | `videoId`・`title`・`publishedAt`・`capturedAt`・`channelType`・`memberId`はそのまま対応。`memberId`は`channelType: 'og'`のみ存在し、公式チャンネルの投稿では`undefined`（§3-1参照） |

備考: Instagramの`DailyNewPost`には遡及収集判定に相当するフィールドが現状存在しない。実装Issue（#1569）側で、既存の新着判定ロジック（`instagramPostsAll = rawDailyNewPosts.filter(...)`）に遡及判定が含まれているかを確認し、無ければ`isRetroactive: false`固定とするか、Ameba同様のウィンドウ判定を追加するかを実装時に判断する。

備考: `findSupportedBlogUrl(sources)`は`sources`配列からAmeba URLを探索するだけで、それが本人の公式登録ブログかどうかは検証しない。`officialSns`を見ずにこの関数の戻り値だけで「Ameba投稿」と判定すると、公式Amebaブログ未登録のメンバーが一般Web検索で他人のameblo.jp URLを拾った際に誤判定してしまう（MorningStatusApp #1628で実例が確認され修正済み）。`hasOfficialAmebaBlog`によるメンバー単位の事前フィルタは、この誤判定を避けるための必須の前段ステップである。

---

## 4. API層設計（Route Handler）

### 4-1. エンドポイント一覧

| エンドポイント | 責務 | 内部で呼ぶ既存関数 |
| --- | --- | --- |
| `GET /api/feed/ameba` | `members`と`AmebaSyncStatus`から新着ウィンドウ判定・フィルタリングを行い`AmebaFeedItem[]`を返す | `getMembersFromBlob()`、`getAmebaSyncStatusFromBlob()`（`lib/blob.ts`） |
| `GET /api/feed/instagram` | `DailyNewPost[]`を`InstagramFeedItem[]`に変換して返す | `getDailyNewPostsFromBlob()`（`lib/blob.ts`） |
| `GET /api/feed/tiktok` | `TikTokPost[]`を`TikTokFeedItem[]`に変換して返す | `getRecentTikTokPosts()`（`lib/tiktok.ts`） |
| `GET /api/feed/youtube` | `YoutubePost[]`を`YoutubeFeedItem[]`に変換して返す | `getRecentYoutubePosts()`（`lib/youtube.ts`） |

各エンドポイントは`UnifiedFeedItem[]`（該当SNSのバリアントのみ）を返す。official/active/og等の表示ブロック分類・ソート順は、既存実装と同様にUI層（クライアント側）の責務として残し、Route Handler側では行わない。

### 4-2. Ameba専用の非対称な設計

TikTok/YouTubeは`getRecentXPosts()`が投稿一覧を独立して返すデータソースだが、**Amebaには投稿一覧を返す専用関数が存在しない**。現行の`NewPostsSection.tsx`では、`hasOfficialAmebaBlog(member)`（`member.officialSns`にAmebaブログのactiveエントリがあるか）で対象メンバーを絞り込んだ上で、`members`配列の`statusHistory`/`latestStatus`を`AmebaSyncStatus`の同期ウィンドウ（`[前々回の定期実行時刻, 直近の実行時刻]`）でフィルタし、`isRetroactivePost()`で遡及収集を判定する処理を都度実行している。この`hasOfficialAmebaBlog`による事前フィルタは、`sources`内のURLだけで「Ameba投稿」を判定してしまう誤判定（MorningStatusApp #1628）を避けるために必須であり、Route Handlerへの移植時も省略してはならない。

`GET /api/feed/ameba`は、この「members取得＋ウィンドウ計算＋フィルタリング」ロジックを内部にカプセル化する。単純な既存関数のラップでは済まないため、実装時は現行の該当ロジック（`NewPostsSection.tsx`のAmebaセクション、L98-145付近）をRoute Handler側に移植する。

### 4-3. キャッシュ戦略

新設するRoute Handler群は、**独自のキャッシュ戦略を持たせず、内部で呼び出す既存lib関数の`fetch`設定（`next: { revalidate: ... }`）をそのまま継承する**（例: `getDailyNewPostsFromBlob()`は`revalidate: 1800`）。Route Handler側で`cache: "no-store"`等を独自に指定しない。

これは、既存の`GET /api/members`が独自に`cache: "no-store"`を指定した結果、同じBlobデータに対して経路（直接呼び出し vs Route Handler経由）でキャッシュ戦略が異なり、パッチスクリプト実行直後に「片方の画面では反映済み、片方では古いまま」という食い違いが起きる問題が知られているため（開発ノート参照）。同じ混乱を新設APIで再発させないよう、既存関数の設定に統一する。

---

## 5. TanStack Query導入方針

### 5-1. 依存追加

`package.json`に`@tanstack/react-query`を追加する（このプロジェクトで初導入）。

### 5-2. `QueryClientProvider`の設置

`app/providers.tsx`を新規作成し、クライアントコンポーネント（`"use client"`）として`QueryClientProvider`を生成する。`app/layout.tsx`の`<body>`内で`<Providers>{children}</Providers>`のように全体をラップする（ヘッダー・フッターを含む`<body>`全体を対象とし、将来の非SNS領域展開時にも同じProviderを再利用できるようにする）。

```typescript
// app/providers.tsx（イメージ）
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
```

### 5-3. サーバー側プリフェッチ

`app/page.tsx`（`NewPostsSection`の呼び出し元）で、Next.js App Routerの典型パターンに従い、サーバー側で`prefetchQuery`→`dehydrate`→`HydrationBoundary`を用いてクライアントに渡す。これにより初回表示時のちらつき・ローディングスピナーを回避する。クライアント側の`NewPostsSection`（の該当箇所）は`useQuery`でRoute Handlerからフィードを取得・キャッシュする。

---

## 6. 拡張の方向性（SNS以外への展開）

本設計で確立するパターン（Route Handler + `UnifiedFeedItem`的な判別共用体ドメイン型 + `useQuery` + prefetch/hydrate）は、SNS4種に限定しない汎用的なデータ取得基盤として位置づける。

次の展開候補は以下の通り。詳細スキーマはここでは定義せず、実際に拡張する際にこの方針をもとに個別Issueを起票する。

- ライブ情報（Neon DB）: ツアー・フェス出演情報を含む
- リリース情報（Vercel Blob / Neon DB）
- メンバー情報（Vercel Blob）
- ユニット情報（Vercel Blob）
- 将来的には、ラジオ・イベント等、Blob/DBを参照する他の画面領域にも同じ基盤を展開しうる

---

## 7. 関連設計書

| 設計書 | 本書との関係 |
| --- | --- |
| [Amebaブログ機能 データ設計書](/design/sns/ameba-data-design) | `StatusEntry`・`AmebaSyncStatus`の詳細、遡及判定ロジック |
| [Instagram投稿機能 データ設計書](/design/sns/instagram-data-design) | `DailyNewPost`の詳細 |
| [TikTok投稿機能 データ設計書](/design/sns/tiktok-data-design) | `TikTokPost`の詳細 |
| [YouTube投稿一覧機能 データ設計書](/design/sns/youtube-data-design) | `YoutubePost`の詳細 |
| [データ設計書（全体概要）](/design/common/data-design) | エンティティ一覧への登録 |

---

## 改訂履歴

| 版 | 更新日 | 変更内容 |
| --- | --- | --- |
| 1.0 | 2026-09-17 | 初版作成（#90） |
