# Fullstack Example

Ecto（DB）とPubSub（リアルタイム通信）を組み合わせたフルスタックアプリケーションの作成例。

## このガイドで作るもの

**リアルタイム掲示板**:
- 投稿をDB（PostgreSQL）に保存
- 複数のブラウザで同時閲覧時、新規投稿が即座に全員の画面に反映
- 投稿の編集・削除もリアルタイム同期

## 前提条件

- PostgreSQLがインストール済み
- Elixir 1.19以上

---

## ステップ1: プロジェクト作成

```bash
# Dialupディレクトリ内で新規プロジェクト作成
cd /Users/souic/Documents/Dialup
mix new my_app --sup
cd my_app
```

作成されるファイル構成:
```
my_app/
├── lib/
│   ├── my_app.ex          # Applicationモジュール
│   └── my_app/
│       └── application.ex # Supervisor設定（--supで生成）
├── config/
│   └── runtime.exs        # 環境設定
├── mix.exs                # 依存関係
└── ...
```

---

## ステップ2: 依存の追加

`mix.exs` を編集して必要なパッケージを追加：

```elixir
defp deps do
  [
    # Dialup本体（フレームワーク）
    {:dialup, path: "../dialup"},
    
    # Ecto（DB接続）
    {:ecto_sql, "~> 3.0"},
    {:postgrex, ">= 0.0.0"},
    
    # PubSub（リアルタイム通信）
    {:phoenix_pubsub, "~> 2.0"}
  ]
end
```

```bash
# 依存を取得
mix deps.get
```

---

## ステップ3: データベース設定

### 3-1. DB設定ファイルの作成

`config/runtime.exs` を作成（または編集）：

```elixir
import Config

# Ecto用のDB接続設定
config :my_app, MyApp.Repo,
  database: System.get_env("DB_NAME", "my_app_dev"),
  username: System.get_env("DB_USER", "postgres"),
  password: System.get_env("DB_PASS", "postgres"),
  hostname: System.get_env("DB_HOST", "localhost")
```

### 3-2. Repoモジュールの作成

`lib/my_app/repo.ex` を**手動で作成**：

```elixir
defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres
end
```

**補足**: RepoはEctoがDBにアクセスするためのモジュール。`use Ecto.Repo` で自動的に `get`, `insert`, `update` などの関数が使えるようになる。

---

## ステップ4: テーブル作成（マイグレーション）

### 4-1. マイグレーションファイルの作成

マイグレーションは**手動で作成**します。ファイル名には日時を含めるのが一般的：

```bash
# ディレクトリ作成
mkdir -p priv/repo/migrations

# マイグレーションファイル作成
# ファイル名の先頭の数字は実行順序（日時推奨）
touch priv/repo/migrations/20240304000001_create_posts.exs
```

### 4-2. マイグレーション内容の記述

`priv/repo/migrations/20240304000001_create_posts.exs` を編集：

```elixir
defmodule MyApp.Repo.Migrations.CreatePosts do
  use Ecto.Migration

  def change do
    # :posts テーブルを作成
    create table(:posts) do
      add :title, :string, null: false    # タイトル（必須）
      add :content, :text, null: false    # 内容（必須）
      timestamps()                        # inserted_at, updated_at自動追加
    end
  end
end
```

**補足**: `mix ecto.gen.migration` コマンドでも生成できるが、手動作成でも同じ。

### 4-3. DBとテーブルの作成

```bash
# DB作成（初回のみ）
mix ecto.create

# マイグレーション実行（テーブル作成）
mix ecto.migrate
```

実行後、PostgreSQL内に `posts` テーブルが作成される。

---

## ステップ5: Schema（データ構造）の定義

`lib/my_app/post.ex` を**手動で作成**：

```elixir
defmodule MyApp.Post do
  use Ecto.Schema
  import Ecto.Changeset

  # DBのpostsテーブルと対応
  schema "posts" do
    field :title, :string
    field :content, :string
    timestamps()  # inserted_at, updated_at
  end

  # バリデーション用関数
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:title, :content])
    |> validate_required([:title, :content])
  end
end
```

**補足**: 
- `schema` マクロでDBテーブルとElixir構造体のマッピングを定義
- `changeset` はデータの検証・変換を行う（必須項目チェックなど）

---

## ステップ6: Applicationの設定

`lib/my_app.ex` を編集して、RepoとPubSubをSupervisorに登録：

```elixir
defmodule MyApp do
  use Application
  use Dialup, app_dir: __DIR__ <> "/app"

  @impl Application
  def start(_type, _args) do
    children = [
      # DB接続プール
      MyApp.Repo,
      
      # PubSub（リアルタイム通信）
      {Phoenix.PubSub, name: MyApp.PubSub},
      
      # Dialupサーバー
      {Dialup, app: __MODULE__, port: 4000}
    ]

    Supervisor.start_link(children,
      strategy: :one_for_one,
      name: __MODULE__.Supervisor
    )
  end
end
```

**補足**: `children` の順序は重要。Repo → PubSub → Dialup の順で起動。

---

## ステップ7: ページ実装

### 7-1. 共通レイアウト

`lib/app/layout.ex` を**手動で作成**：

```elixir
defmodule MyApp.Layout do
  use Dialup.Layout

  def render(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html>
      <head>
        <title>リアルタイム掲示板</title>
      </head>
      <body style="max-width: 800px; margin: 0 auto; padding: 20px;">
        <h1>📝 Dialup Board</h1>
        {raw(@inner_content)}
      </body>
    </html>
    """
  end
end
```

### 7-2. 一覧ページ（投稿表示・作成）

`lib/app/page.ex` を**手動で作成**：

```elixir
defmodule MyApp.Page do
  use Dialup.Page
  alias MyApp.{Repo, Post}
  alias Phoenix.PubSub

  # ページ表示時に呼ばれる
  def mount(_params, assigns) do
    # PubSubで "posts:all" トピックを購読
    # → 他のユーザーが投稿した時に通知を受け取る
    PubSub.subscribe(MyApp.PubSub, "posts:all")
    
    # DBから投稿一覧を取得（新しい順）
    posts = Repo.all(Post) |> Enum.reverse()
    
    assigns
    |> set_default(%{posts: posts, errors: []})
  end

  # フォーム送信時に呼ばれる
  def handle_event("create", %{"title" => title, "content" => content}, assigns) do
    # 1. DBに保存を試みる
    case %Post{} |> Post.changeset(%{title: title, content: content}) |> Repo.insert() do
      {:ok, post} ->
        # 2. 成功：他のセッションに通知
        PubSub.broadcast(MyApp.PubSub, "posts:all", {:post_created, post})
        
        # 3. 自分の画面も更新（エラー消去）
        {:update, assigns |> overwrite(%{errors: []})}
        
      {:error, changeset} ->
        # バリデーションエラーを表示
        errors = Ecto.Changeset.traverse_errors(changeset, & &1)
        {:update, assigns |> overwrite(%{errors: errors})}
    end
  end

  # 他のセッションからの通知を受信
  def handle_info({:post_created, new_post}, assigns) do
    # 新規投稿を一覧に追加
    posts = [new_post | assigns.posts]
    {:update, assigns |> overwrite(%{posts: posts})}
  end

  def render(assigns) do
    ~H"""
    <!-- 投稿フォーム -->
    <form ws-submit="create" style="margin-bottom: 30px; padding: 15px; background: #f5f5f5;">
      <h3>新規投稿</h3>
      <div style="margin-bottom: 10px;">
        <input name="title" placeholder="タイトル" style="width: 100%; padding: 8px;" />
      </div>
      <div style="margin-bottom: 10px;">
        <textarea name="content" placeholder="内容" style="width: 100%; height: 80px; padding: 8px;"></textarea>
      </div>
      <button type="submit">投稿する</button>
      
      <!-- エラー表示 -->
      <%= for {field, msgs} <- @errors do %>
        <p style="color: red; margin: 5px 0;">{field}: {Enum.join(msgs, ", ")}</p>
      <% end %>
    </form>
    
    <!-- 投稿一覧 -->
    <h2>投稿一覧</h2>
    <div id="posts">
      <%= for post <- @posts do %>
        <div style="border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 4px;">
          <h3><a ws-href={"/posts/#{post.id}"}>{post.title}</a></h3>
          <p>{post.content}</p>
          <small style="color: #666;">投稿日時: {post.inserted_at}</small>
        </div>
      <% end %>
    </div>
    """
  end
end
```

**ポイント解説**:
- `PubSub.subscribe`: このページを開いている間、指定トピックの通知を受け取る
- `PubSub.broadcast`: 同じトピックを購読している全員（自分含む）にメッセージ送信
- `handle_info`: 他のセッションからの通知を処理

### 7-3. 詳細ページ（編集・削除）

ディレクトリ作成とファイル配置：

```bash
mkdir -p lib/app/posts/[id]
```

`lib/app/posts/[id]/page.ex` を**手動で作成**：

```elixir
defmodule MyApp.Posts.Id do
  use Dialup.Page
  alias MyApp.{Repo, Post}
  alias Phoenix.PubSub

  def mount(%{"id" => id}, assigns) do
    # この投稿専用のトピックを購読（編集時のリアルタイム反映用）
    PubSub.subscribe(MyApp.PubSub, "post:#{id}")
    
    # DBから特定の投稿を取得
    post = Repo.get(Post, id)
    
    {:ok, assigns |> overwrite(%{post: post, editing: false})}
  end

  # 削除ボタン押下時
  def handle_event("delete", _value, assigns) do
    # DBから削除
    Repo.delete!(assigns.post)
    
    # 一覧ページに通知（一覧の表示を更新するため）
    PubSub.broadcast(MyApp.PubSub, "posts:all", {:post_deleted, assigns.post.id})
    
    {:update, assigns |> overwrite(%{deleted: true})}
  end

  # 編集モード切り替え
  def handle_event("toggle_edit", _value, assigns) do
    {:update, assigns |> overwrite(%{editing: !assigns.editing})}
  end

  # 編集保存時
  def handle_event("update", %{"title" => title, "content" => content}, assigns) do
    case assigns.post |> Post.changeset(%{title: title, content: content}) |> Repo.update() do
      {:ok, updated_post} ->
        # この投稿を閲覧中の他ユーザーに通知
        PubSub.broadcast(MyApp.PubSub, "post:#{updated_post.id}", {:post_updated, updated_post})
        
        {:update, assigns |> overwrite(%{post: updated_post, editing: false})}
        
      {:error, _} ->
        {:noreply, assigns}
    end
  end

  # 他のユーザーによる編集を受信
  def handle_info({:post_updated, updated_post}, assigns) do
    {:update, assigns |> overwrite(%{post: updated_post})}
  end

  def render(assigns) do
    ~H"""
    <%= if assigns[:deleted] do %>
      <p>削除されました。<a ws-href="/">一覧へ戻る</a></p>
    <% else %>
      <a ws-href="/">← 一覧へ戻る</a>
      
      <%= if @editing do %>
        <!-- 編集フォーム -->
        <form ws-submit="update" style="margin-top: 20px;">
          <input name="title" value={@post.title} style="width: 100%; padding: 8px; font-size: 1.2em;" />
          <textarea name="content" style="width: 100%; height: 150px; padding: 8px; margin-top: 10px;">{@post.content}</textarea>
          <div style="margin-top: 10px;">
            <button type="submit">保存</button>
            <button type="button" ws-event="toggle_edit">キャンセル</button>
          </div>
        </form>
      <% else %>
        <!-- 表示モード -->
        <article style="margin-top: 20px; padding: 20px; border: 1px solid #ddd; border-radius: 4px;">
          <h1>{@post.title}</h1>
          <p style="line-height: 1.6;">{@post.content}</p>
          <small style="color: #666;">投稿日時: {@post.inserted_at}</small>
          
          <div style="margin-top: 20px;">
            <button ws-event="toggle_edit">✏️ 編集</button>
            <button ws-event="delete" style="color: red; margin-left: 10px;">🗑️ 削除</button>
          </div>
        </article>
      <% end %>
    <% end %>
    """
  end
end
```

---

## ステップ8: 起動と動作確認

### 8-1. 起動

```bash
mix run --no-halt
```

### 8-2. 動作確認手順

1. **ブラウザ1**で `http://localhost:4000` を開く
2. **ブラウザ2**（シークレットウィンドウ等）でも同じURLを開く
3. **ブラウザ1**で新規投稿
   - ブラウザ2に即座に表示されることを確認
4. **ブラウザ2**で投稿をクリックして詳細ページへ
5. **ブラウザ1**でも同じ投稿の詳細ページを開く
6. **ブラウザ1**で編集→保存
   - ブラウザ2の内容が即座に更新されることを確認

---

## トラブルシューティング

### DB接続エラー

```
** (DBConnection.ConnectionError) connection not available
```
→ PostgreSQLが起動しているか確認

### マイグレーションエラー

```
** (Postgrex.Error) ERROR 42P01 (undefined_table) relation "posts" does not exist
```
→ `mix ecto.migrate` を実行し忘れている可能性

### PubSubが動作しない

→ 複数ブラウザでテスト。同じセッション（同じクッキー）では動作しない。

---

## まとめ

| コンポーネント | 役割 | Dialupとの関係 |
|-------------|------|--------------|
| **Ecto** | DBアクセス | Dialupは関与せず、普通に使う |
| **Phoenix.PubSub** | プロセス間通信 | Dialupは関与せず、普通に使う |
| **Dialup** | WebSocket + ルーティング | コア機能 |

**重要なポイント**: Dialupはこれらのライブラリを「隠蔽」しない。各ライブラリの標準的な使い方がそのまま活きる。

次のステップ:
- [Deployment](./deployment.md) - 本番環境へのデプロイ方法
