# 機能設計書 20-エントリ非公開

## 概要

本ドキュメントは、Decap CMSにおける公開済みエントリの非公開化機能の設計を記述する。公開済みエントリを削除して未公開状態（エディトリアルワークフロー管理下）に戻す処理の仕様を網羅する。

### 本機能の処理概要

エントリ非公開機能は、公開済みエントリをメインブランチから削除し、同時にエディトリアルワークフローの未公開エントリとして再登録する機能である。これにより、一度公開したコンテンツをワークフロー管理下に戻すことができる。

**業務上の目的・背景**：公開済みコンテンツに修正が必要な場合や、一時的に非公開にしたい場合に、エントリをワークフロー管理下に戻してレビュープロセスを再実行する必要がある。非公開化により、コンテンツのライフサイクル管理を柔軟に行える。

**機能の利用シーン**：エントリ編集画面で「非公開」ボタンを押下することで利用する。公開済みのエントリを選択し、非公開化操作を行うと、エントリがメインブランチから削除され、Pending Publishステータスの未公開エントリとして再登録される。

**主要な処理内容**：
1. `selectEntry`で公開済みエントリをRedux状態から取得
2. 空のentryDraftを作成（Map().set('entry', entry)）
3. `backend.deleteEntry`でメインブランチからエントリファイルを削除
4. `backend.persistEntry`でPending Publishステータスの未公開エントリとしてブランチに再保存
5. 成功後に`entryDeleted`で公開済みエントリ一覧から削除
6. `loadUnpublishedEntry`で未公開エントリとして再読み込み

**関連システム・外部連携**：Gitバックエンド（GitHub/GitLab/Bitbucket等）のファイル削除API、ブランチ作成API、PR作成APIと連携。削除と再作成の2つのGit操作を連続して実行する。

**権限による制御**：メインブランチからの削除権限とブランチ作成権限の両方が必要。Open Authoringモードではリポジトリ管理者のみが操作可能。

## 関連画面

| 画面No | 画面名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 6 | エントリ編集画面 | 主画面 | 公開済みエントリの非公開化処理。unpublishPublishedEntryアクションによる削除と未公開状態への移行 |

## 機能種別

データ公開制御 - エントリの公開→非公開状態遷移

## 入力仕様

### 入力パラメータ

| パラメータ名 | 型 | 必須 | 説明 | バリデーション |
|-------------|-----|-----|------|---------------|
| collection | Collection (Immutable.Map) | Yes | 対象のコレクション | - |
| slug | string | Yes | エントリスラッグ | - |

### 入力データソース

エントリ編集画面の「非公開」ボタン操作。

## 出力仕様

### 出力データ

| 項目名 | 型 | 説明 |
|--------|-----|------|
| - | void | 非公開処理の結果は状態変更として反映 |

### 出力先

- バックエンドAPI（Git操作: ファイル削除 + ブランチ作成 + ファイル書き込み）
- Redux Store（`entries`から公開済みエントリ削除、`editorialWorkflow`に未公開エントリ追加）
- UIトースト通知（非公開成功/失敗メッセージ）

## 処理フロー

### 処理シーケンス

```
1. ユーザーが「非公開」ボタンを押下
   └─ unpublishPublishedEntryアクションをディスパッチ
2. selectEntryで公開済みエントリをRedux状態から取得
3. 空のentryDraftを作成: Map().set('entry', entry) as EntryDraft
4. UNPUBLISHED_ENTRY_PERSIST_REQUESTをディスパッチ
   └─ isPersisting=trueに設定
5. backend.deleteEntry(state, collection, slug)を実行
   └─ メインブランチからエントリファイルを削除
6. backend.persistEntry({ ..., status: 'pending_publish' })を実行
   └─ Pending Publishステータスでブランチに保存
7. 成功処理:
   └─ unpublishedEntryPersisted → PERSIST_SUCCESSをディスパッチ
   └─ entryDeleted → ENTRY_DELETE_SUCCESSをディスパッチ（公開済み一覧から削除）
   └─ loadUnpublishedEntry → 未公開エントリとして再読み込み
   └─ ui.toast.entryUnpublished通知
8. 失敗処理:
   └─ unpublishedEntryPersistedFail → PERSIST_FAILUREをディスパッチ
   └─ ui.toast.onFailToUnpublishEntryエラー通知
```

### フローチャート

```mermaid
flowchart TD
    A[非公開ボタン押下] --> B[selectEntry - 公開済みエントリ取得]
    B --> C[entryDraft作成]
    C --> D[PERSIST_REQUEST dispatch]
    D --> E[backend.deleteEntry]
    E --> F{削除成功?}
    F -->|Yes| G[backend.persistEntry with status='pending_publish']
    F -->|No| H[エラー処理]
    G --> I{保存成功?}
    I -->|Yes| J[PERSIST_SUCCESS dispatch]
    J --> K[entryDeleted dispatch]
    K --> L[loadUnpublishedEntry]
    L --> M[entryUnpublished通知]
    I -->|No| H
    H --> N[PERSIST_FAILURE dispatch]
    N --> O[onFailToUnpublishEntryエラー通知]
```

## ビジネスルール

### 業務ルール

| ルールNo | ルール名 | 内容 | 適用条件 |
|---------|---------|------|---------|
| BR-20-01 | 2段階操作 | 非公開化はメインブランチからの削除と未公開エントリとしての再登録の2段階で行われる | unpublishPublishedEntry時 |
| BR-20-02 | ステータス設定 | 非公開化されたエントリはPending Publish（pending_publish）ステータスで再登録される | persistEntry時 |
| BR-20-03 | メディア非保持 | 非公開化時のpersistEntryではassetProxies: []（空配列）が渡される | unpublishPublishedEntry時 |
| BR-20-04 | スラッグ未使用 | 非公開化時のpersistEntryではusedSlugs: List()（空リスト）が渡される | unpublishPublishedEntry時 |
| BR-20-05 | 公開済み一覧削除 | 非公開化成功後、entryDeletedで公開済みエントリ一覧から削除される | 成功後 |
| BR-20-06 | 未公開エントリ再読み込み | 非公開化成功後、loadUnpublishedEntryで未公開エントリとして再読み込みされる | 成功後 |

### 計算ロジック

- ステータス: `status.get('PENDING_PUBLISH')` → 'pending_publish'文字列を取得
- entryDraft作成: `Map().set('entry', entry) as unknown as EntryDraft` - 型キャストを使用した簡易的なドラフト作成

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

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

| 操作 | 対象 | 操作種別 | 概要 |
|-----|------|---------|------|
| backend.deleteEntry | Git API | DELETE | メインブランチからのエントリファイル削除 |
| backend.persistEntry | Git API | INSERT | 新規ブランチ作成 + Pending Publishステータスでのファイル書き込み |

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

Gitバックエンドにより実装が異なる。削除コミットと再保存コミットが別々に実行される。

## エラー処理

### エラーケース一覧

| エラーコード | エラー種別 | 発生条件 | 対処方法 |
|------------|----------|---------|---------|
| UNPUBLISHED_ENTRY_PERSIST_FAILURE | 非公開化エラー | deleteEntryまたはpersistEntryの失敗 | エラー通知、isPersisting=false |
| - | 部分的失敗 | deleteEntryは成功したがpersistEntryが失敗した場合 | エントリが公開一覧からもワークフローからも消える危険な状態。手動復旧が必要 |

### リトライ仕様

自動リトライは行わない。部分的失敗の場合はGitリポジトリから直接復旧する必要がある。

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

deleteEntryとpersistEntryは別々のAPI呼び出しであり、アトミックなトランザクションではない。Promise chainで逐次実行される。deleteEntryが成功しpersistEntryが失敗した場合、エントリデータが一時的に失われるリスクがある（部分的失敗）。

## パフォーマンス要件

- 2回のバックエンドAPI呼び出し（削除 + 再保存）が必要なため、単純な保存よりも時間がかかる
- isPersistingフラグでUIのボタン重複押下を防止

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

- メインブランチからの削除権限とブランチ作成権限の両方が必要
- 非公開化操作は不可逆的ではないが、部分的失敗時のデータ復旧が困難な場合がある
- 操作はバックエンドAPI経由で認証される

## 備考

- 非公開化はdelete + persistの2段階操作であるため、エラーハンドリングが重要
- ステータスはPending Publish固定で設定される。Draft等への変更は不可（後からupdateUnpublishedEntryStatusで変更可能）
- assetProxies: []、usedSlugs: List()の簡易設定は、エントリのデータは既に存在するため新規アセットやスラッグ重複チェックが不要であることを意味する

---

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

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

### 推奨読解順序

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

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | publishModes.ts | `packages/decap-cms-core/src/constants/publishModes.ts` | status.get('PENDING_PUBLISH')が'pending_publish'を返すことを確認（14行目） |

**読解のコツ**: unpublishPublishedEntryでは、persistEntryに`status: status.get('PENDING_PUBLISH')`を渡すことで、Pending Publishステータスで未公開エントリを作成している。

#### Step 2: アクションクリエータを理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | editorialWorkflow.ts (action) | `packages/decap-cms-core/src/actions/editorialWorkflow.ts` | unpublishPublishedEntry関数（525-567行目） |

**主要処理フロー**:
1. **527-530行目**: state/backend/entryの取得、entryDraftの簡易作成
2. **531行目**: PERSIST_REQUESTをディスパッチ
3. **533行目**: `backend.deleteEntry(state, collection, slug)` - メインブランチから削除
4. **534-541行目**: `.then(() => backend.persistEntry({ ..., status: status.get('PENDING_PUBLISH') }))` - Pending Publishステータスで再保存
5. **544-547行目**: 成功処理 - unpublishedEntryPersisted、entryDeleted、loadUnpublishedEntry、通知
6. **556-564行目**: 失敗処理 - エラー通知、unpublishedEntryPersistedFail

#### Step 3: 2段階Git操作を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | editorialWorkflow.ts (action) | `packages/decap-cms-core/src/actions/editorialWorkflow.ts` | 533-541行目のPromise chain（deleteEntry → persistEntry） |

**主要処理フロー**:
- **533行目**: `backend.deleteEntry(state, collection, slug)` → メインブランチのファイル削除
- **535-541行目**: `backend.persistEntry({ config, collection, entryDraft, assetProxies: [], usedSlugs: List(), status: status.get('PENDING_PUBLISH') })` → ワークフローブランチに再保存

#### Step 4: 成功後の状態更新を理解する

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | editorialWorkflow.ts (action) | `packages/decap-cms-core/src/actions/editorialWorkflow.ts` | 成功後の4つのディスパッチ（544-553行目） |
| 4-2 | entries.ts (reducer) | `packages/decap-cms-core/src/reducers/entries.ts` | ENTRY_DELETE_SUCCESSケース（242-250行目） |

**主要処理フロー**:
- **545行目**: `unpublishedEntryPersisted(collection, entry)` - editorialWorkflow.entitiesに追加
- **546行目**: `entryDeleted(collection, slug)` - entries.entitiesから削除
- **547行目**: `loadUnpublishedEntry(collection, slug)` - 未公開エントリとして再読み込み

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

```
エントリ編集画面（非公開ボタン）
    │
    └─ unpublishPublishedEntry(collection, slug)
           [actions/editorialWorkflow.ts:525-567]
           │
           ├─ selectEntry(state, collection, slug) - 公開済みエントリ取得
           │
           ├─ dispatch(UNPUBLISHED_ENTRY_PERSIST_REQUEST)
           │
           ├─ backend.deleteEntry(state, collection, slug)
           │      └─ Git API: メインブランチのファイル削除
           │
           ├─ backend.persistEntry({ status: 'pending_publish' })
           │      └─ Git API: ブランチ作成 + ファイル書き込み + PR作成
           │
           ├─ 成功:
           │      ├─ dispatch(unpublishedEntryPersisted)
           │      ├─ dispatch(entryDeleted)  [entries.ts]
           │      ├─ dispatch(loadUnpublishedEntry)
           │      └─ dispatch(entryUnpublished通知)
           │
           └─ 失敗:
                  ├─ dispatch(onFailToUnpublishEntry通知)
                  └─ dispatch(unpublishedEntryPersistedFail)
```

### データフロー図

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

公開済みエントリ            ───▶  backend.deleteEntry             ───▶  メインブランチ:
(collection, slug)                 (メインブランチ削除)                    ファイル削除

公開済みエントリ            ───▶  backend.persistEntry            ───▶  ワークフローブランチ:
(status: pending_publish)          (ブランチ保存)                         ファイル作成 + PR

                                         │
                                         ▼
                                成功後の状態更新:
                                ├─ entries.entities      削除 (ENTRY_DELETE_SUCCESS)
                                ├─ editorialWorkflow.entities  追加 (PERSIST_SUCCESS)
                                └─ loadUnpublishedEntry  再読み込み
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| editorialWorkflow.ts (action) | `packages/decap-cms-core/src/actions/editorialWorkflow.ts` | ソース | unpublishPublishedEntryアクション |
| editorialWorkflow.ts (reducer) | `packages/decap-cms-core/src/reducers/editorialWorkflow.ts` | ソース | PERSIST関連の状態管理 |
| entries.ts (action) | `packages/decap-cms-core/src/actions/entries.ts` | ソース | entryDeleted関数 |
| entries.ts (reducer) | `packages/decap-cms-core/src/reducers/entries.ts` | ソース | ENTRY_DELETE_SUCCESSケース |
| publishModes.ts | `packages/decap-cms-core/src/constants/publishModes.ts` | ソース | ステータス定数（PENDING_PUBLISH） |
| entryDraft.js | `packages/decap-cms-core/src/reducers/entryDraft.js` | ソース | isPersisting管理 |
