# 通知設計書 52-page.tag.detached

## 概要

本ドキュメントは、Ghostにおける `page.tag.detached` Webhookイベントの設計仕様を記述する。このWebhookは、ページからタグが外された際に、登録されたWebhookエンドポイントへHTTPリクエストを送信する仕組みである。

### 本通知の処理概要

`page.tag.detached` イベントは、Ghostの管理者がページからタグを削除した際に自動的にトリガーされるWebhook通知である。外部システムとの連携や、コンテンツ管理ワークフローの自動化を可能にする。

**業務上の目的・背景**：コンテンツ管理システムにおいて、ページのカテゴリ分類（タグ）の変更は重要な運用イベントである。タグの削除（detach）を外部システムに通知することで、コンテンツの再分類、検索インデックスの更新、マーケティングセグメントの調整など、自動化されたワークフローを実現できる。例えば、特定のタグが外されたページを自動的にニュースレターから除外する、外部CMSの分類を同期するといったユースケースが想定される。

**通知の送信タイミング**：管理画面またはAdmin APIを通じて、ページからタグが外された（detach）直後に送信される。具体的には、ページの編集時にタグが削除され、保存が完了したタイミングでイベントが発火する。

**通知の受信者**：このイベントに対してWebhookを登録したインテグレーション（外部システム）のエンドポイントURL。Webhook登録時に指定したtarget_urlに対してHTTP POSTリクエストが送信される。

**通知内容の概要**：変更されたページの現在の状態（current）と変更前の状態（previous）を含むJSONペイロード。currentにはタグ削除後の状態が、previousにはタグ削除前の状態（削除されたタグを含む）が含まれる。

**期待されるアクション**：受信側システムは、ページとタグの関連付け解除を反映するための処理を実行することが期待される。例えば、外部データベースの更新、検索インデックスの再構築、カテゴリ分類の更新などが考えられる。

## 通知種別

Webhook（HTTP POST）

## 送信仕様

### 基本情報

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

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

1. イベント発生時に `Webhook` モデルから `event = 'page.tag.detached'` に一致するWebhook設定を全件取得
2. `customIntegrations` の制限がある場合、内部インテグレーション（`type = 'internal'`）のWebhookのみ送信対象
3. 各Webhookの `target_url` に対してHTTP POSTリクエストを送信

## 通知テンプレート

### Webhook通知の場合

| 項目 | 内容 |
|-----|------|
| 送信先URL | Webhook設定の `target_url` |
| HTTPメソッド | POST |
| Content-Type | application/json |
| 形式 | JSON |

### ヘッダー

| ヘッダー名 | 値 | 説明 |
|-----------|-----|------|
| Content-Type | application/json | コンテンツタイプ |
| Content-Length | {ペイロードのバイト数} | ペイロードサイズ |
| Content-Version | v{Ghost APIバージョン} | Ghost APIバージョン |
| X-Ghost-Signature | sha256={署名}, t={タイムスタンプ} | Webhook秘密鍵が設定されている場合のみ |

### 本文テンプレート

```json
{
  "page": {
    "current": {
      "id": "{ページID}",
      "uuid": "{UUID}",
      "title": "{ページタイトル}",
      "slug": "{スラッグ}",
      "status": "{ステータス: draft/published/scheduled}",
      "visibility": "{公開範囲: public/members/paid/tiers}",
      "html": "{HTMLコンテンツ}",
      "plaintext": "{プレーンテキストコンテンツ}",
      "tags": [],
      "authors": [
        {
          "id": "{著者ID}",
          "name": "{著者名}",
          "slug": "{著者スラッグ}",
          "email": "{著者メール}"
        }
      ],
      "primary_tag": null,
      "primary_author": { ... },
      "tiers": [ ... ],
      "created_at": "{作成日時}",
      "updated_at": "{更新日時}",
      "published_at": "{公開日時}",
      "url": "{ページURL}"
    },
    "previous": {
      "tags": [
        {
          "id": "{削除されたタグID}",
          "name": "{削除されたタグ名}",
          "slug": "{削除されたタグスラッグ}",
          "visibility": "{タグの可視性}"
        }
      ]
    }
  }
}
```

### 添付ファイル

なし

## テンプレート変数

| 変数名 | 説明 | データ取得元 | 必須 |
|--------|------|-------------|-----|
| page.current.id | ページの一意識別子 | posts.id | Yes |
| page.current.uuid | ページのUUID | posts.uuid | Yes |
| page.current.title | ページタイトル | posts.title | Yes |
| page.current.slug | URLスラッグ | posts.slug | Yes |
| page.current.status | ページステータス | posts.status | Yes |
| page.current.tags | 現在のタグ一覧（detach後は空または減少） | posts_tags, tags | Yes |
| page.current.authors | 著者一覧 | posts_authors, users | Yes |
| page.previous.tags | 変更前のタグ一覧（削除されたタグを含む） | model._previousAttributes | Yes |

## 送信トリガー・条件

### トリガー一覧

| トリガー種別 | トリガーイベント | 送信条件 | 説明 |
|------------|----------------|---------|------|
| モデルイベント | page.tag.detached | Webhook登録あり | ページからタグがdetachされた時点で発火 |

### 送信抑止条件

| 条件 | 説明 |
|-----|------|
| インポート中 | `options.importing = true` の場合、イベントは発火しない |
| customIntegrations制限 | プラン制限により外部Webhookが制限されている場合、内部インテグレーション以外は送信されない |
| Webhook未登録 | 該当イベントに対するWebhook登録がない場合は送信されない |

## 処理フロー

### 送信フロー

```mermaid
flowchart TD
    A[ページ編集リクエスト] --> B[Post.edit / Post.destroy]
    B --> C[onSaving/onDestroying: handleAttachedModels]
    C --> D{タグの削除あり?}
    D -->|Yes| E[tags.detaching イベント]
    E --> F[tags.detached イベント]
    F --> G[model.emitChange'tag.detached']
    G --> H[page.tag.detached イベント発火]
    H --> I[WebhookTrigger.trigger]
    I --> J[Webhook.findAllByEvent]
    J --> K{Webhook登録あり?}
    K -->|Yes| L[ペイロード生成]
    L --> M[HTTP POSTリクエスト送信]
    M --> N{送信成功?}
    N -->|成功| O[last_triggered更新]
    N -->|失敗| P{リトライ?}
    P -->|Yes| M
    P -->|No| Q[エラーログ記録]
    O --> R[終了]
    Q --> R
    K -->|No| R
    D -->|No| R
```

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

### 参照テーブル一覧

| テーブル名 | 用途 | 備考 |
|-----------|------|------|
| webhooks | 登録済みWebhook取得 | event = 'page.tag.detached' |
| integrations | Webhookの所属インテグレーション | 内部/外部の判定 |
| posts | ページ情報取得 | type = 'page' |
| posts_tags | ページ-タグ関連 | 変更されたタグ情報 |
| tags | タグ情報 | タグの詳細情報 |
| users | 著者情報 | ペイロードに含める |

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

#### webhooks

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| id | Webhook識別 | - |
| event | イベントフィルタ | event = 'page.tag.detached' |
| target_url | 送信先URL | - |
| secret | 署名生成用秘密鍵 | 任意 |
| integration_id | インテグレーション参照 | - |

### 更新テーブル一覧

| テーブル名 | 操作 | 概要 |
|-----------|------|------|
| webhooks | UPDATE | 送信結果の記録 |

#### webhooks（送信結果記録）

| 操作 | 項目（カラム名） | 更新値 | 備考 |
|-----|-----------------|-------|------|
| UPDATE | last_triggered_at | Date.now() | 送信日時 |
| UPDATE | last_triggered_status | HTTPステータスコード | 成功/失敗時のステータス |
| UPDATE | last_triggered_error | エラーメッセージ | 失敗時のみ |

## エラー処理

### エラーケース一覧

| エラー種別 | 発生条件 | 対処方法 |
|----------|---------|---------|
| 送信失敗 | ネットワークエラー、タイムアウト | リトライ後、エラーログ記録 |
| 410 Gone | 送信先が410を返した | Webhookを自動削除 |
| 宛先不正 | target_urlが無効 | エラーログ記録、Webhook更新 |

### リトライ仕様

| 項目 | 内容 |
|-----|------|
| リトライ回数 | 最大5回（テスト環境では0回） |
| リトライ間隔 | @tryghost/requestのデフォルト動作に従う |
| リトライ対象エラー | ネットワークエラー、一時的なサーバーエラー |

## 配信設定

### レート制限

| 項目 | 内容 |
|-----|------|
| リクエストタイムアウト | 2秒 |
| 1分あたり上限 | なし（システム側の制限なし） |
| 1日あたり上限 | なし |

### 配信時間帯

制限なし（イベント発生時に即時送信）

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

- **署名検証**: Webhook設定で `secret` が設定されている場合、`X-Ghost-Signature` ヘッダーにHMAC-SHA256署名が付与される
- **署名形式**: `sha256={ペイロード+タイムスタンプのHMAC署名}, t={タイムスタンプ}`
- **タイムスタンプ**: リプレイ攻撃対策として、受信側でタイムスタンプの検証が推奨される
- **HTTPS推奨**: target_urlにはHTTPSの使用が推奨される

## 備考

- このイベントは `post.tag.detached` と同等のロジックで動作し、`type = 'page'` のコンテンツに対して発火する
- ページ削除時にタグが存在する場合も、ページ削除の前に `page.tag.detached` が発火する
- 複数のタグを同時に削除した場合、各タグに対して個別にイベントが発火する
- `page.tag.attached` とは異なり、detachではdetachingイベントのスコープ内でdetachedハンドラが設定される特殊な実装になっている

---

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

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

### 推奨読解順序

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

Webhookペイロードの構造とデータベーススキーマを理解することが重要。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | schema.js | `ghost/core/core/server/data/schema/schema.js` | webhooksテーブル（353-370行目）とposts_tagsテーブル（297-305行目）の定義を確認 |
| 1-2 | webhook.js | `ghost/core/core/server/models/webhook.js` | Webhookモデルの構造、findAllByEventメソッド（46-52行目） |

**読解のコツ**: schema.jsではテーブル定義がオブジェクトリテラルで記述されており、各カラムの型、nullable、参照先を確認できる。

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

イベントリスナーの登録とイベント発火の仕組みを理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | listen.js | `ghost/core/core/server/services/webhooks/listen.js` | Webhookシステムのエントリーポイント、WEBHOOKSリスト（10-45行目）でpage.tag.detachedが定義されている |
| 2-2 | events.js | `ghost/core/core/server/lib/common/events.js` | EventRegistryクラス、イベントバスの実装 |

**主要処理フロー**:
1. **10-45行目**: サポートされるWebhookイベントの定義（page.tag.detachedは44行目）
2. **47-68行目**: listen関数でイベントリスナーを登録
3. **59-66行目**: processWebhookTriggerハンドラでWebhookTrigger.triggerを呼び出し

#### Step 3: イベント発火元を理解する

ページモデルでのタグ削除時のイベント発火を理解する。

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

**主要処理フロー**:
- **489-494行目**: tags.detachingイベントリスナーでdetachedイベントの発火を準備（重要：detachingのスコープでtagを保持）
- **490-493行目**: tags.detachedイベントハンドラで`tag.emitChange('detached')`と`model.emitChange('tag.detached')`を呼び出し
- **356-366行目**: emitChangeメソッドで`page.tag.detached`イベント名を構築して発火

**読解のコツ**: detachingとdetachedの2段階イベント構造に注意。Bookshelfのdetachedイベントでは削除されたモデルにアクセスできないため、detachingのスコープでモデル参照を保持している。

#### Step 4: Webhook送信処理を理解する

WebhookTriggerクラスでの実際のHTTP送信処理を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | webhook-trigger.js | `ghost/core/core/server/services/webhooks/webhook-trigger.js` | triggerメソッド（98-143行目）でWebhook送信を実行 |
| 4-2 | payload.js | `ghost/core/core/server/services/webhooks/payload.js` | ペイロード生成の入口 |
| 4-3 | serialize.js | `ghost/core/core/server/services/webhooks/serialize.js` | ペイロードのシリアライズ処理、currentとpreviousの構築 |

**主要処理フロー**:
- **98-102行目**: triggerメソッドの定義、onSuccess/onErrorコールバック設定
- **104行目**: getAll(event)でWebhook設定を取得
- **108-142行目**: 各Webhookに対してペイロード生成→署名生成→HTTP送信を実行
- **122-124行目**: secretが設定されている場合のX-Ghost-Signature生成

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

```
Admin API (pages.js edit/destroy)
    │
    └─ models/post.js
           │
           ├─ onSaving() / onDestroying()
           │      └─ handleAttachedModels()
           │             └─ tags.detaching イベント設定
           │                    └─ tags.detached ハンドラ設定
           │
           └─ Bookshelf relations (detaching/detached発火)
                  │
                  └─ tags.detached ハンドラ実行
                         │
                         ├─ tag.emitChange('detached')
                         │
                         └─ model.emitChange('tag.detached')
                                │
                                └─ events.emit('page.tag.detached')
                                       │
                                       └─ services/webhooks/listen.js
                                              └─ processWebhookTrigger()
                                                     │
                                                     └─ WebhookTrigger.trigger()
                                                            │
                                                            ├─ Webhook.findAllByEvent()
                                                            │
                                                            ├─ payload() → serialize()
                                                            │
                                                            └─ request(url, opts)
                                                                   │
                                                                   ├─ onSuccess() → Webhook.edit()
                                                                   │
                                                                   └─ onError()
                                                                          ├─ 410 → Webhook.destroy()
                                                                          └─ その他 → Webhook.edit()
```

### データフロー図

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

ページ編集リクエスト ───▶ Post.edit() / Post.destroy()
     │
     ▼
タグ削除データ ─────────▶ handleAttachedModels()
     │                        │
     │                        ▼
     │                   bookshelf-relations
     │                   (detaching)
     │                        │
     │                        │ tagモデルを保持
     │                        ▼
     │                   bookshelf-relations
     │                   (detached)
     │                        │
     │                        ▼
     │                   emitChange('tag.detached')
     │                        │
     │                        ▼
     │                   events.emit('page.tag.detached')
     │                        │
     ▼                        ▼
Postモデル ─────────────▶ WebhookTrigger.trigger()
(current/previous)            │
                              ▼
                         serialize() ─────────▶ JSON ペイロード
                              │                 (previousにタグあり)
                              ▼
                         request(url) ─────────▶ HTTP POST
                                                  │
                                                  ▼
                                             外部Webhookエンドポイント
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| listen.js | `ghost/core/core/server/services/webhooks/listen.js` | ソース | Webhookリスナー登録、イベントハンドラ |
| 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` | ソース | モデルのシリアライズ、current/previous構築 |
| post.js | `ghost/core/core/server/models/post.js` | ソース | Postモデル、タグイベント発火元 |
| webhook.js | `ghost/core/core/server/models/webhook.js` | ソース | Webhookモデル、findAllByEvent |
| events.js | `ghost/core/core/server/lib/common/events.js` | ソース | EventRegistry、イベントバス |
| schema.js | `ghost/core/core/server/data/schema/schema.js` | 設定 | DBスキーマ定義 |
| pages.test.js | `ghost/core/test/e2e-webhooks/pages.test.js` | テスト | E2Eテスト、イベント発火確認 |
