# 通知設計書 50-post.tag.detached

## 概要

本ドキュメントは、Ghost CMSにおける `post.tag.detached` Webhookイベントの設計仕様を定義する。このWebhookは、投稿からタグが外された際に、登録されたWebhookエンドポイントに通知を送信する機能を提供する。

### 本通知の処理概要

このWebhookは、Ghost CMSで投稿からタグの関連付けが解除された際に、外部システムへリアルタイムで通知を送信する機能を提供する。

**業務上の目的・背景**：投稿からのタグ解除は、コンテンツの分類変更において重要な操作である。タグの解除を外部システム（検索エンジン、コンテンツ推奨システム、分析プラットフォーム、コンテンツ配信ネットワークなど）に通知することで、コンテンツの再インデックス、カテゴリ別集計の更新、関連コンテンツの推奨ロジックの再計算、タグベースのフィルタリングルールの適用などが可能になる。

**通知の送信タイミング**：Postモデル（type='post'の場合）のタグ関連解除処理において、`handleAttachedModels` メソッド内の `tags.once('detached')` イベントハンドラで `post.tag.detached` イベントが発火される。これは投稿の編集時にタグが削除されたタイミング、または投稿自体が削除される際のタグ紐付け解除時に発生する。

**通知の受信者**：Ghost管理画面で設定された `post.tag.detached` イベントに対応するWebhookエンドポイント。これらはIntegration（連携機能）を通じて登録される外部システムのURLである。

**通知内容の概要**：タグが解除された投稿の情報を含むJSONペイロード。投稿の基本情報（ID、タイトル、スラッグなど）に加え、現在関連付けられているタグ情報（解除されたタグを除く）が含まれる。

**期待されるアクション**：受信側システムは、タグ解除の通知を受け取り、検索インデックスの更新、タグベースの集計の再計算、関連コンテンツリストの更新、コンテンツ配信ルールの再適用などの処理を実行することが期待される。

## 通知種別

Webhook（HTTP POST リクエスト）

## 送信仕様

### 基本情報

| 項目 | 内容 |
|-----|------|
| 送信方式 | 非同期（イベント駆動） |
| 優先度 | 中 |
| リトライ | 有・5回（テスト環境では0回） |

### 送信先決定ロジック

1. `webhooks` テーブルから `event = 'post.tag.detached'` に一致するレコードを取得
2. カスタムインテグレーション制限（`customIntegrations` limit）が有効な場合は、内部インテグレーション（`type = 'internal'`）に紐づくWebhookのみを対象とする
3. 各Webhookの `target_url` に対してHTTP POSTリクエストを送信

## 通知テンプレート

### Webhook通知の場合

| 項目 | 内容 |
|-----|------|
| HTTPメソッド | POST |
| Content-Type | application/json |
| Content-Version | v{Ghost APIバージョン} |
| タイムアウト | 2000ms（2秒） |

### ペイロード構造

```json
{
  "post": {
    "current": {
      "id": "投稿ID",
      "uuid": "UUID",
      "title": "投稿タイトル",
      "slug": "投稿スラッグ",
      "status": "published",
      "visibility": "public",
      "html": "HTML本文",
      "plaintext": "プレーンテキスト本文",
      "created_at": "作成日時",
      "updated_at": "更新日時",
      "published_at": "公開日時",
      "tags": [
        {
          "id": "残っているタグID",
          "name": "タグ名",
          "slug": "タグスラッグ"
        }
      ],
      "authors": [...]
    },
    "previous": {}
  }
}
```

### 署名ヘッダー（オプション）

Webhookに `secret` が設定されている場合、以下のヘッダーが追加される：

| ヘッダー名 | 形式 |
|----------|------|
| X-Ghost-Signature | `sha256={HMAC署名}, t={タイムスタンプ}` |

## テンプレート変数

| 変数名 | 説明 | データ取得元 | 必須 |
|--------|------|-------------|-----|
| post.current.id | 投稿の一意識別子 | posts.id | Yes |
| post.current.uuid | 投稿のUUID | posts.uuid | Yes |
| post.current.title | 投稿タイトル | posts.title | Yes |
| post.current.slug | 投稿スラッグ | posts.slug | Yes |
| post.current.status | 投稿ステータス | posts.status | Yes |
| post.current.visibility | 公開範囲 | posts.visibility | Yes |
| post.current.html | HTML本文 | posts.html | No |
| post.current.plaintext | プレーンテキスト本文 | posts.plaintext | No |
| post.current.tags | 関連タグ一覧（解除されたタグは含まない） | posts_tags, tags | Yes |
| post.current.authors | 著者情報 | posts_authors, users | No |
| post.previous | 変更前の値 | - | Yes |

## 送信トリガー・条件

### トリガー一覧

| トリガー種別 | トリガーイベント | 送信条件 | 説明 |
|------------|----------------|---------|------|
| 画面操作 | 投稿編集でタグを削除 | tags.detached発火 | 投稿エディタでタグを削除 |
| API | Admin API経由でタグ削除 | tags.detached発火 | PUT /admin/posts/:id |
| 投稿削除 | 投稿削除時のタグ紐付け解除 | tags.detached発火 | 投稿削除前処理 |

### 送信抑止条件

| 条件 | 説明 |
|-----|------|
| options.importing === true | インポート処理中は送信しない |
| 対象Webhookが存在しない | post.tag.detachedイベントに登録されたWebhookがない場合 |
| type='page' | ページの場合は page.tag.detached として発火 |

## 処理フロー

### 送信フロー

```mermaid
flowchart TD
    A[投稿からタグ削除] --> B[Post.handleAttachedModels]
    B --> C[tags.once'detaching'登録]
    C --> D[tags.detached発火]
    D --> E{typeはpost?}
    E -->|Yes| F[post.tag.detachedイベント発火]
    E -->|No| G[page.tag.detachedイベント発火]
    F --> H[WebhookTrigger.trigger呼び出し]
    H --> I[Webhook.findAllByEvent実行]
    I --> J{対象Webhook存在?}
    J -->|No| Z[処理終了]
    J -->|Yes| K[ペイロード生成]
    K --> L[各Webhookに対してHTTP POST]
    L --> M{送信成功?}
    M -->|Yes| N[last_triggered_at更新]
    M -->|No| O{ステータス410?}
    O -->|Yes| P[Webhook削除]
    O -->|No| Q[last_triggered_error記録]
    N --> Z
    P --> Z
    Q --> Z
    G --> Z
```

## データベース参照・更新仕様

### 参照テーブル一覧

| テーブル名 | 用途 | 備考 |
|-----------|------|------|
| webhooks | 対象Webhookの取得 | event='post.tag.detached' |
| integrations | Webhook連携先の確認 | 内部インテグレーション判定用 |
| posts | 投稿データの取得 | type='post' |
| posts_tags | 投稿とタグの関連 | 解除される関連 |
| tags | タグ情報の取得 | 解除されるタグ |

### テーブル別参照項目詳細

#### webhooks

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| id | Webhook識別 | - |
| event | イベント種別 | event = 'post.tag.detached' |
| target_url | 送信先URL | - |
| secret | 署名生成用シークレット | NULLでない場合のみ使用 |
| integration_id | 連携先の識別 | インテグレーション制限判定用 |

#### posts

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| id, uuid, title, slug | 投稿基本情報 | type = 'post' |
| status | 投稿ステータス | - |
| html, plaintext | 本文コンテンツ | formats指定時 |
| visibility | 公開範囲 | - |

### 更新テーブル一覧

| テーブル名 | 操作 | 概要 |
|-----------|------|------|
| posts_tags | DELETE | タグと投稿の紐付け削除 |
| webhooks | UPDATE | 送信結果の記録 |

## エラー処理

### エラーケース一覧

| エラー種別 | 発生条件 | 対処方法 |
|----------|---------|---------|
| タイムアウト | 2秒以内に応答なし | リトライ実行 |
| 410 Gone | 受信側がWebhook削除を要求 | Webhookレコードを削除 |
| 接続エラー | ネットワーク障害 | リトライ実行、エラーログ記録 |
| 5xx エラー | サーバー側エラー | リトライ実行、エラーログ記録 |

### リトライ仕様

| 項目 | 内容 |
|-----|------|
| リトライ回数 | 5回（本番環境）、0回（テスト環境） |
| リトライ間隔 | @tryghost/requestのデフォルト設定に従う |
| リトライ対象エラー | タイムアウト、接続エラー、5xxエラー |

## 配信設定

### レート制限

| 項目 | 内容 |
|-----|------|
| 1分あたり上限 | 制限なし（イベント発生ごとに送信） |
| 1日あたり上限 | 制限なし |

### 配信時間帯

送信可能な時間帯の制限なし。イベント発生時に即座に送信される。

## セキュリティ考慮事項

- **署名検証**: Webhookに `secret` が設定されている場合、`X-Ghost-Signature` ヘッダーでHMAC-SHA256署名を提供
- **HTTPS推奨**: `target_url` にはHTTPS URLの使用を推奨
- **タイムスタンプ**: 署名にタイムスタンプを含めることでリプレイ攻撃を防止

## 備考

- `detaching`/`detached` イベントはBookshelfの標準機能を利用（post.js 489-494行目）
- `detaching` でタグオブジェクトを捕捉し、`detached` でイベントを発火する二段階処理になっている
- 投稿削除時（onDestroying）にも `handleAttachedModels` が呼び出され、タグ解除イベントが発火される（post.js 476-480行目）
- ペイロードの `tags` には解除後の残りのタグが含まれる（解除されたタグは含まれない）

---

## コードリーディングガイド

本通知を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

Webhookと投稿・タグの関連構造を理解することが重要。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | schema.js | `ghost/core/core/server/data/schema/schema.js` | webhooksテーブル（353-370行目）、postsテーブル（61-105行目）、posts_tagsテーブル（297-305行目）の定義を確認 |
| 1-2 | post.js | `ghost/core/core/server/models/post.js` | Postモデルの構造、tagsリレーション、handleAttachedModelsメソッド |

**読解のコツ**: detaching/detachedの二段階イベント処理パターンを理解する（Bookshelfの制約による）。

#### Step 2: エントリーポイントを理解する

イベント発火の起点を特定する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | post.js | `ghost/core/core/server/models/post.js` | `handleAttachedModels`メソッド（482-516行目）のタグdetach処理 |

**主要処理フロー**:
1. **482行目**: `handleAttachedModels`メソッドの開始
2. **489行目**: `model.related('tags').once('detaching')` でdetachingイベントを監視
3. **490行目**: `tags.once('detached')` でdetachedイベントを監視（detachingのスコープ内）
4. **491行目**: `tag.emitChange('detached')` でタグ側のイベント発火
5. **492行目**: `model.emitChange('tag.detached')` で投稿側のイベント発火

#### Step 3: イベントリスナーの登録を理解する

Webhookシステムがどのようにイベントを購読するか。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | listen.js | `ghost/core/core/server/services/webhooks/listen.js` | WEBHOOKSリスト（10-45行目）と`listen`関数（47-68行目） |

**主要処理フロー**:
- **42行目**: `post.tag.detached` がWEBHOOKSリストに定義
- **59-66行目**: `events.on`でイベントリスナーを登録、`webhookTrigger.trigger`を呼び出し

#### Step 4: 投稿削除時のタグ解除を理解する

投稿削除時にもタグ解除イベントが発火される点を確認。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | post.js | `ghost/core/core/server/models/post.js` | `onDestroying`メソッド（476-480行目） |

**主要処理フロー**:
- **476行目**: `onDestroying`メソッドの開始
- **479行目**: `handleAttachedModels(model)` を呼び出し（投稿削除前にタグ解除イベントを準備）

### プログラム呼び出し階層図

```
Post.onSaving または Post.onDestroying
    │
    └─ handleAttachedModels() (models/post.js:482/479)
           │
           └─ model.related('tags').once('detaching') (models/post.js:489)
                  │
                  └─ tags.once('detached') (models/post.js:490)
                         │
                         ├─ tag.emitChange('detached') (models/post.js:491)
                         │
                         └─ model.emitChange('tag.detached') (models/post.js:492)
                                │
                                └─ events.emit('post.tag.detached') (lib/common/events.js)
                                       │
                                       └─ processWebhookTrigger (services/webhooks/listen.js:59)
                                              │
                                              └─ WebhookTrigger.trigger (webhook-trigger.js:98)
                                                     │
                                                     ├─ getAll('post.tag.detached')
                                                     │
                                                     ├─ payload() (services/webhooks/payload.js)
                                                     │      └─ serialize() (withRelated: ['tags', 'authors'])
                                                     │
                                                     └─ request(url, opts) (HTTP送信)
```

### データフロー図

```
[入力]                    [処理]                         [出力]

投稿からタグ削除         handleAttachedModels
(エディタ/API/削除)────▶ tags.detached発火   ────▶    イベント発火
                              │                         │
                              ▼                         ▼
                        post.tag.detached         WebhookTrigger
                        イベント                       │
                              │                        ▼
                              │                   findAllByEvent
                              │                   (webhooksテーブル)
                              │                        │
                              ▼                        ▼
                        serialize()               HTTP POST
                        (withRelated: tags)       (target_url)
                              │                        │
                              ▼                        ▼
                        {post: {                  外部システム
                          current: {
                            title, slug, status,
                            tags: [残りのタグ...]
                          },
                          previous: {}
                        }}
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| listen.js | `ghost/core/core/server/services/webhooks/listen.js` | ソース | イベントリスナー登録 |
| webhook-trigger.js | `ghost/core/core/server/services/webhooks/webhook-trigger.js` | ソース | Webhook送信制御 |
| payload.js | `ghost/core/core/server/services/webhooks/payload.js` | ソース | ペイロード生成エントリ |
| serialize.js | `ghost/core/core/server/services/webhooks/serialize.js` | ソース | リソースシリアライズ |
| post.js | `ghost/core/core/server/models/post.js` | ソース | Postモデル・タグ関連処理・イベント発火 |
| webhook.js | `ghost/core/core/server/models/webhook.js` | ソース | Webhookモデル定義 |
| events.js | `ghost/core/core/server/lib/common/events.js` | ソース | イベントエミッター |
| schema.js | `ghost/core/core/server/data/schema/schema.js` | 設定 | DBスキーマ定義 |
