# 機能設計書 25-記事コメント

## 概要

本ドキュメントは、Ghostの記事コメント機能の設計仕様を記述する。記事コメント機能は、メンバーが記事に対してコメントを投稿し、他のコメントへの返信を行うことで、コミュニティの交流を促進する。

### 本機能の処理概要

**業務上の目的・背景**：コンテンツに対する読者のエンゲージメントを高め、コミュニティを形成することはパブリッシャーにとって重要な目標である。コメント機能により、読者は記事に対する意見や質問を共有でき、著者とのインタラクションが可能になる。これにより、サイトへの再訪問や購読継続の動機付けとなる。

**機能の利用シーン**：
- メンバーが記事にコメントを投稿
- メンバーが既存コメントに返信を投稿
- メンバーが自分のコメントを編集
- メンバーが自分のコメントを削除（論理削除）
- コメント一覧の閲覧

**主要な処理内容**：
1. コメントの作成（記事への新規コメント）
2. 返信の作成（既存コメントへの返信）
3. コメントの編集
4. コメントの削除（論理削除）
5. コメント一覧の取得（ページネーション付き）
6. 返信一覧の取得

**関連システム・外部連携**：
- DomainEvents（MemberCommentEvent発行）
- CommentsServiceEmails（通知メール送信）

**権限による制御**：
- コメント投稿：メンバーのみ（設定によりfree/paid制限可）
- コメント編集・削除：コメント投稿者本人のみ
- 管理者は全コメントの閲覧・モデレーションが可能

## 関連画面

| 画面No | 画面名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 83 | コメントセクション（埋め込み） | 主機能 | コメント一覧表示・投稿・返信 |

## 機能種別

CRUD / イベント発行

## 入力仕様

### 入力パラメータ（コメント投稿）

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| post_id | string | Yes | 投稿先の記事ID | 存在する記事であること |
| member_id | string | Yes | コメント投稿者のメンバーID | 有効なメンバーであること |
| html | string | Yes | コメント本文（HTML） | 空でないこと、サニタイズ後1000000000文字以内 |

### 入力パラメータ（返信投稿）

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| parent_id | string | Yes | 返信先のコメントID | 存在するコメントであること、トップレベルコメントであること |
| in_reply_to_id | string | No | 特定の返信への言及ID | 同一スレッド内のコメントであること |
| member_id | string | Yes | コメント投稿者のメンバーID | 有効なメンバーであること |
| html | string | Yes | コメント本文（HTML） | 空でないこと |

### 入力パラメータ（コメント編集）

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| id | string | Yes | 編集対象のコメントID | 存在するコメントであること |
| html | string | Yes | 新しいコメント本文（HTML） | 空でないこと |

### 入力データソース

- **comments テーブル**: コメントデータ
- **posts テーブル**: 記事データ
- **members テーブル**: メンバーデータ

## 出力仕様

### 出力データ

| 項目名 | 型 | 説明 |
|--------|-----|------|
| id | string | コメントID |
| post_id | string | 関連記事ID |
| member_id | string | 投稿者メンバーID |
| parent_id | string | 親コメントID（返信の場合） |
| in_reply_to_id | string | 言及先コメントID |
| html | string | サニタイズ済みHTMLコンテンツ |
| status | string | コメント状態（published/hidden/deleted） |
| edited_at | datetime | 最終編集日時 |
| created_at | datetime | 作成日時 |
| count__likes | number | いいね数 |
| count__replies | number | 返信数 |

### 出力先

- **comments テーブル**: コメントデータの永続化
- **DomainEvents**: MemberCommentEventの発行

## 処理フロー

### 処理シーケンス（コメント投稿）

```
1. コメント機能の有効性確認
   └─ settings.comments_enabled をチェック

2. メンバー権限確認
   └─ メンバーのステータス（free/paid）とコメント設定を照合
   └─ 記事へのアクセス権限を確認（有料コンテンツの場合）

3. HTMLサニタイズ
   └─ 許可タグ: p, br, a, blockquote のみ
   └─ リンクに target="_blank" と rel="ugc noopener noreferrer nofollow" を付与
   └─ 先頭・末尾の空パラグラフを除去

4. コメント作成
   └─ comments テーブルに INSERT
   └─ status は 'published' で初期化

5. 通知メール送信
   └─ 記事の著者に通知（notifyPostAuthors）
   └─ 返信の場合は親コメントの投稿者にも通知

6. イベント発行
   └─ MemberCommentEvent を dispatch
```

### フローチャート

```mermaid
flowchart TD
    A[コメント投稿リクエスト] --> B{コメント機能有効?}
    B -->|No| C[MethodNotAllowedError]
    B -->|Yes| D{メンバー権限OK?}
    D -->|No| E[NoPermissionError]
    D -->|Yes| F{記事アクセス権OK?}
    F -->|No| E
    F -->|Yes| G[HTMLサニタイズ]
    G --> H{HTML空?}
    H -->|Yes| I[ValidationError: 空コメント]
    H -->|No| J[comments INSERT]
    J --> K[通知メール送信]
    K --> L[MemberCommentEvent dispatch]
    L --> M[コメントモデル返却]
```

## ビジネスルール

### 業務ルール

| ルールNo | ルール名 | 内容 | 適用条件 |
|---------|---------|------|---------|
| BR-25-01 | コメント有効設定 | comments_enabled が 'off' の場合はコメント不可 | 全操作 |
| BR-25-02 | 有料メンバー限定 | comments_enabled が 'paid' の場合、freeメンバーはコメント不可 | コメント投稿時 |
| BR-25-03 | 返信は1階層まで | 返信への返信（ネスト返信）は禁止 | 返信投稿時 |
| BR-25-04 | 自己コメントのみ編集可 | コメントは投稿者本人のみ編集可能 | コメント編集時 |
| BR-25-05 | 論理削除 | 削除はstatusを'deleted'に更新（物理削除しない） | コメント削除時 |
| BR-25-06 | HTMLサニタイズ | p, br, a, blockquote タグのみ許可 | コメント作成・編集時 |

### 計算ロジック

**返信数のカウント**：
- 親コメントに対する返信数をカウント
- status が 'hidden' または 'deleted' のコメントは除外（管理者は 'deleted' のみ除外）

**いいね数のカウント**：
- comment_likes テーブルの該当コメントに対するレコード数

## データベース操作仕様

### 操作別データベース影響一覧

| 操作 | 対象テーブル | 操作種別 | 概要 |
|-----|-------------|---------|------|
| コメント作成 | comments | INSERT | 新規コメントの登録 |
| コメント編集 | comments | UPDATE | html, edited_at の更新 |
| コメント削除 | comments | UPDATE | status を 'deleted' に更新 |
| コメント取得 | comments | SELECT | 条件に応じた検索 |

### テーブル別操作詳細

#### comments

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| INSERT | id | 自動生成 (24文字) | ObjectID |
| INSERT | post_id | リクエストパラメータ | 外部キー制約 |
| INSERT | member_id | ログインメンバーID | 外部キー制約 |
| INSERT | parent_id | 親コメントID（返信時） | NULL許容 |
| INSERT | in_reply_to_id | 言及先ID | NULL許容 |
| INSERT | html | サニタイズ済みHTML | TEXT型 |
| INSERT | status | 'published' | デフォルト値 |
| INSERT | created_at | 現在日時 | 自動設定 |
| UPDATE | html | 新しいコンテンツ | 編集時 |
| UPDATE | edited_at | 現在日時 | 編集時 |
| UPDATE | status | 'deleted' | 削除時 |

## エラー処理

### エラーケース一覧

| エラーコード | エラー種別 | 発生条件 | 対処方法 |
|------------|----------|---------|---------|
| - | MethodNotAllowedError | コメント機能が無効 | 'Comments are not enabled for this site.' |
| - | NoPermissionError | メンバー権限不足（paid限定時） | 'You do not have permission to comment on this post.' |
| - | NoPermissionError | 記事アクセス権なし | 'You do not have permission to comment on this post.' |
| - | BadRequestError | 返信への返信を試行 | 'Can not reply to a reply' |
| - | BadRequestError | コメントが見つからない | 'Comment could not be found' |
| - | BadRequestError | すでにいいね済み | 'This comment was liked already' |
| - | ValidationError | 空のコメント本文 | 'The body of a comment cannot be empty' |
| - | NoPermissionError | 他人のコメントを編集 | 'You do not have permission to edit comments' |
| - | NotFoundError | メンバーが見つからない | 'Unable to find member' |

### リトライ仕様

- DB操作失敗時のリトライなし
- 通知メール送信失敗はログ出力のみ（コメント作成自体は成功扱い）

## トランザクション仕様

- コメント作成・編集・削除は単一レコード操作のため、暗黙的トランザクション
- 削除操作はsoftDeleteとしてトランザクション内で実行

## パフォーマンス要件

- コメント一覧取得: ページネーション対応（findPage使用）
- 返信の遅延読み込み: 初期表示は3件まで、以降はAPI呼び出し

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

- HTMLサニタイズにより XSS 攻撃を防止
- リンクに rel="ugc noopener noreferrer nofollow" を強制付与
- メンバー認証によるアクセス制御
- 自己所有コメントのみ編集・削除可能

## 備考

- status の値: 'published', 'hidden', 'deleted'
- in_reply_to_id は同一スレッド内かつ published 状態のコメントのみ参照可能
- コメント作成時に MemberCommentEvent が発行され、アクティビティトラッキングに利用される

---

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

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

### 推奨読解順序

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

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | schema.js | `ghost/core/core/server/data/schema/schema.js` | comments テーブル（973-984行）、comment_likes（985-991行）、comment_reports（992-998行）のスキーマ定義 |
| 1-2 | comment.js | `ghost/core/core/server/models/comment.js` | Comment モデルのリレーション定義（33-58行）、バリデーション（94-124行） |

**読解のコツ**: comments テーブルは post_id, member_id, parent_id, in_reply_to_id, status, html を持つ。status は 'published', 'hidden', 'deleted' の3値。

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

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | comments-service.js | `ghost/core/core/server/services/comments/comments-service.js` | CommentsService クラスがメインのサービスクラス |

**主要処理フロー**:
1. **46-52行目**: get enabled() - コメント機能の有効性取得
2. **55-61行目**: checkEnabled() - コメント機能の有効性確認
3. **63-70行目**: checkCommentAccess() - メンバー権限確認
4. **94-121行目**: likeComment() - いいね追加
5. **123-144行目**: unlikeComment() - いいね削除
6. **146-168行目**: reportComment() - 報告
7. **258-306行目**: commentOnPost() - 記事へのコメント投稿
8. **316-394行目**: replyToComment() - コメントへの返信
9. **401-421行目**: deleteComment() - コメント削除（論理削除）
10. **429-453行目**: editCommentContent() - コメント編集

#### Step 3: モデルの詳細を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | comment.js | `ghost/core/core/server/models/comment.js` | HTMLサニタイズ（94-124行）、カウントクエリ（251-296行） |

**主要処理フロー**:
- **14-22行目**: trimParagraphs() - 空パラグラフの除去
- **94-124行目**: onSaving() - 保存前のHTMLサニタイズ
- **152-167行目**: destroy() - 論理削除の実装
- **251-296行目**: countRelations() - replies, likes, reports のカウント

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

```
CommentsService
    │
    ├─ commentOnPost(post, member, comment, options, createdAt)
    │      │
    │      ├─ checkEnabled()
    │      │      └─ settingsCache.get('comments_enabled')
    │      │
    │      ├─ Member.findOne() - メンバー取得
    │      │
    │      ├─ checkCommentAccess() - 権限確認
    │      │
    │      ├─ Post.findOne() - 記事取得
    │      │
    │      ├─ checkPostAccess() - 記事アクセス権確認
    │      │      └─ contentGating.checkPostAccess()
    │      │
    │      ├─ Comment.add() - コメント作成
    │      │      └─ onSaving() - HTMLサニタイズ
    │      │
    │      ├─ sendNewCommentNotifications()
    │      │      ├─ emails.notifyPostAuthors()
    │      │      ├─ emails.notifyParentCommentAuthor({type: 'parent'})
    │      │      └─ emails.notifyParentCommentAuthor({type: 'in_reply_to'})
    │      │
    │      └─ DomainEvents.dispatch(MemberCommentEvent)
    │
    ├─ replyToComment(parent, inReplyTo, member, comment, options, createdAt)
    │      │
    │      ├─ checkEnabled()
    │      ├─ Member.findOne()
    │      ├─ checkCommentAccess()
    │      ├─ getCommentByID() - 親コメント取得
    │      ├─ parent_id チェック（返信への返信禁止）
    │      ├─ Post.findOne()
    │      ├─ checkPostAccess()
    │      ├─ Comment.add()
    │      ├─ sendNewCommentNotifications()
    │      └─ DomainEvents.dispatch(MemberCommentEvent)
    │
    ├─ editCommentContent(id, member, comment, options)
    │      ├─ checkEnabled()
    │      ├─ getCommentByID()
    │      ├─ member_id チェック（所有者確認）
    │      └─ Comment.edit({html, edited_at})
    │
    └─ deleteComment(id, member, options)
           ├─ checkEnabled()
           ├─ getCommentByID()
           ├─ member_id チェック
           └─ Comment.edit({status: 'deleted'})
```

### データフロー図

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

post_id ─────────────────▶ commentOnPost()
member_id                        │
html                             │
                                 ▼
                          checkEnabled() ───────────────▶ MethodNotAllowedError
                                 │
                                 ▼
                          checkCommentAccess() ─────────▶ NoPermissionError
                                 │
                                 ▼
                          checkPostAccess() ────────────▶ NoPermissionError
                                 │
                                 ▼
html ────────────────────▶ sanitizeHtml() ──────────────▶ サニタイズ済みHTML
                                 │
                                 ▼
                          Comment.add() ────────────────▶ comments テーブル
                                 │
                                 ▼
                          sendNewCommentNotifications()
                                 │
                                 ├─ notifyPostAuthors() ──▶ メール送信
                                 │
                                 └─ notifyParentCommentAuthor() ──▶ メール送信
                                 │
                                 ▼
                          DomainEvents.dispatch() ──────▶ MemberCommentEvent
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| comments-service.js | `ghost/core/core/server/services/comments/comments-service.js` | ソース | メインサービスクラス |
| comments-service-emails.js | `ghost/core/core/server/services/comments/comments-service-emails.js` | ソース | 通知メール送信 |
| comments-service-email-renderer.js | `ghost/core/core/server/services/comments/comments-service-email-renderer.js` | ソース | メールテンプレートレンダリング |
| comment.js | `ghost/core/core/server/models/comment.js` | モデル | Commentモデル定義 |
| comment-like.js | `ghost/core/core/server/models/comment-like.js` | モデル | CommentLikeモデル定義 |
| comment-report.js | `ghost/core/core/server/models/comment-report.js` | モデル | CommentReportモデル定義 |
| schema.js | `ghost/core/core/server/data/schema/schema.js` | 設定 | DBスキーマ定義 |
