Sinatraで作る中高生向け悩み相談Webサービス「Ballon」- 悩み投稿機能の実装とデータベース設計 (Part 3)

## はじめに

Part 1ではアプリケーションの概要と基本設計、Part 2ではユーザー認証システムの実装について解説しました。今回のPart 3では、Ballonの核となる悩み投稿機能の実装とそれに関連するデータベース設計の詳細に焦点を当てます。悩みのCRUD操作（作成、読み取り、更新、削除）の実装方法や、効率的なデータベースクエリの書き方などを詳しく説明します。また、中高生の悩みに対する適切な対応やセンシティブな内容のモデレーションについても触れていきます。

## データベース設計

まず、悩み投稿に関連するテーブルの設計を見てみましょう。

```ruby:db/migrate/YYYYMMDDHHMMSS_create_worries.rb
class CreateWorries < ActiveRecord::Migration[5.2]
  def change
    create_table :worries do |t|
      t.references :user, foreign_key: true
      t.string :title, null: false
      t.text :content, null: false
      t.string :category
      t.boolean :anonymous, default: false
      t.boolean :is_public, default: true
      t.datetime :resolved_at
      t.timestamps
    end
  end
end
```

このマイグレーションファイルで、以下のような構造のworriesテーブルを作成します：

- `user_id`: 悩みの投稿者を示す外部キー
- `title`: 悩みのタイトル（null不可）
- `content`: 悩みの詳細内容（null不可）
- `category`: 悩みのカテゴリ（例：学業、友人関係、家族など）
- `anonymous`: 匿名投稿かどうかを示すフラグ
- `is_public`: 公開/非公開を示すフラグ
- `resolved_at`: 悩みが解決された日時
- `created_at`と`updated_at`: レコードの作成日時と更新日時（timestampsマクロで自動追加）

この設計により、悩みに関する必要な情報をすべて保存でき、かつユーザーとの関連付けも可能になります。

## モデルの実装

次に、Worryモデルを実装します。

```ruby:models.rb
class Worry < ActiveRecord::Base
  belongs_to :user
  has_many :answers, dependent: :destroy

  validates :title, presence: true, length: { maximum: 100 }
  validates :content, presence: true, length: { maximum: 1000 }
  validates :category, presence: true
  
  scope :public_worries, -> { where(is_public: true) }
  scope :unresolved, -> { where(resolved_at: nil) }
  scope :resolved, -> { where.not(resolved_at: nil) }

  before_save :sanitize_content

  def resolve!
    update(resolved_at: Time.current)
  end

  def posted_by?(user)
    self.user_id == user.id
  end

  private

  def sanitize_content
    self.content = Sanitize.fragment(self.content)
  end
end
```

このWorryモデルの各部分について説明します：

1. アソシエーション：
   - `belongs_to :user`: 各悩みは1人のユーザーに属することを示します。
   - `has_many :answers, dependent: :destroy`: 悩みは複数の回答を持ち、悩みが削除されると関連する回答も削除されます。

2. バリデーション：
   - `validates :title, presence: true, length: { maximum: 100 }`: タイトルは必須で、最大100文字まで。
   - `validates :content, presence: true, length: { maximum: 1000 }`: 内容は必須で、最大1000文字まで。
   - `validates :category, presence: true`: カテゴリは必須項目です。

3. スコープ：
   - `scope :public_worries`: 公開されている悩みのみを取得します。
   - `scope :unresolved`: 未解決の悩みを取得します。
   - `scope :resolved`: 解決済みの悩みを取得します。

4. コールバック：
   - `before_save :sanitize_content`: 保存前に内容をサニタイズし、XSS攻撃を防ぎます。

5. インスタンスメソッド：
   - `resolve!`: 悩みを解決済みにマークします。
   - `posted_by?`: 指定されたユーザーが悩みの投稿者かどうかを確認します。

これらのスコープとメソッドにより、悩みの取得や状態の確認が容易になります。

## 悩み管理機能の実装

では、悩みのCRUD操作を実装していきましょう。

```ruby:app.rb
# 悩み一覧表示
get '/worries' do
  @worries = Worry.public_worries.order(created_at: :desc)
  erb :worries_index
end

# 新規悩み投稿フォーム
get '/worries/new' do
  authenticate!
  @worry = Worry.new
  erb :worry_form
end

# 悩み投稿
post '/worries' do
  authenticate!
  @worry = current_user.worries.new(worry_params)
  if @worry.save
    redirect '/worries'
  else
    erb :worry_form
  end
end

# 悩み詳細表示
get '/worries/:id' do
  @worry = Worry.find(params[:id])
  erb :worry_show
end

# 悩み編集フォーム
get '/worries/:id/edit' do
  authenticate!
  @worry = current_user.worries.find(params[:id])
  erb :worry_form
end

# 悩み更新
put '/worries/:id' do
  authenticate!
  @worry = current_user.worries.find(params[:id])
  if @worry.update(worry_params)
    redirect "/worries/#{@worry.id}"
  else
    erb :worry_form
  end
end

# 悩み削除
delete '/worries/:id' do
  authenticate!
  worry = current_user.worries.find(params[:id])
  worry.destroy
  redirect '/worries'
end

# 悩み解決
post '/worries/:id/resolve' do
  authenticate!
  @worry = current_user.worries.find(params[:id])
  @worry.resolve!
  redirect "/worries/#{@worry.id}"
end

private

def worry_params
  params.slice('title', 'content', 'category', 'anonymous', 'is_public')
end

def authenticate!
  redirect '/signin' unless current_user
end
```

各ルーティングについて詳しく説明します：

1. 悩み一覧表示（GET /worries）:
   - `Worry.public_worries`で公開されている悩みのみを取得します。
   - `order(created_at: :desc)`で最新の悩みが先に表示されるようにソートします。

2. 新規悩み投稿（GET /worries/new, POST /worries）:
   - GETリクエストでフォームを表示し、POSTリクエストで新しい悩みを作成します。
   - `current_user.worries.new`を使用して、現在のユーザーに紐づいた悩みを作成します。

3. 悩み詳細表示（GET /worries/:id）:
   - `Worry.find(params[:id])`で特定の悩みを取得します。

4. 悩み編集・更新（GET /worries/:id/edit, PUT /worries/:id）:
   - 編集フォームの表示と更新処理を行います。
   - `current_user.worries.find(params[:id])`を使用して、現在のユーザーの悩みのみを編集可能にします。

5. 悩み削除（DELETE /worries/:id）:
   - `destroy`メソッドで悩みを削除します。

6. 悩み解決（POST /worries/:id/resolve）:
   - `resolve!`メソッドを呼び出して悩みを解決済みにマークします。

7. `authenticate!`メソッド:
   - ユーザーがログインしていない場合、サインインページにリダイレクトします。

8. `worry_params`メソッド:
   - パラメータを安全に取り出すためのプライベートメソッドです。
   - マスアサインメント脆弱性を防ぐために重要です。

## ビューの実装

悩み一覧表示のビューを例として示します。

```erb:views/worries_index.erb
<h2>悩み相談掲示板</h2>

<% if current_user %>
  <a href="/worries/new">新しい悩みを投稿する</a>
<% end %>

<h3>未解決の悩み</h3>
<ul>
  <% @worries.unresolved.each do |worry| %>
    <li>
      <h4><a href="/worries/<%= worry.id %>"><%= h(worry.title) %></a></h4>
      <p>カテゴリ: <%= h(worry.category) %></p>
      <p>
        投稿者: 
        <% if worry.anonymous %>
          匿名
        <% else %>
          <%= h(worry.user.username) %>
        <% end %>
      </p>
      <p>投稿日時: <%= worry.created_at.strftime('%Y-%m-%d %H:%M') %></p>
    </li>
  <% end %>
</ul>

<h3>解決済みの悩み</h3>
<ul>
  <% @worries.resolved.each do |worry| %>
    <li>
      <h4><a href="/worries/<%= worry.id %>"><%= h(worry.title) %></a></h4>
      <p>カテゴリ: <%= h(worry.category) %></p>
      <p>解決日時: <%= worry.resolved_at.strftime('%Y-%m-%d %H:%M') %></p>
    </li>
  <% end %>
</ul>
```

このビューでは、以下の点に注目してください：

1. 未解決の悩みと解決済みの悩みを分けて表示しています。
2. 匿名投稿の場合は投稿者名を「匿名」と表示しています。
3. XSS対策として、ユーザー入力を表示する際に`h`メソッド（HTMLエスケープ）を使用しています。
4. ログインユーザーのみに新規投稿リンクを表示しています。

## センシティブな内容への対応

中高生向けのサービスであるため、センシティブな内容への対応が重要です。以下のような対策を実装することを検討しましょう：

1. キーワードフィルタリング：
   特定のキーワードを含む投稿を自動的にフラグ付けまたはモデレーションキューに入れます。

```ruby:models.rb
class Worry < ActiveRecord::Base
  # 既存のコードに追加
  before_save :check_sensitive_content

  private

  def check_sensitive_content
    sensitive_words = ['自殺', '薬物', '虐待', # その他センシティブな単語]
    if sensitive_words.any? { |word| self.content.include?(word) }
      self.needs_moderation = true
    end
  end
end
```

2. モデレーションシステム：
   フラグ付けされた投稿を管理者が確認できるようにします。

```ruby:app.rb
# 管理者用：モデレーションが必要な悩みの一覧
get '/admin/moderation' do
  authenticate_admin!
  @worries_for_moderation = Worry.where(needs_moderation: true)
  erb :admin_moderation
end

# 管理者用：悩みの承認または削除
post '/admin/moderation/:id' do
  authenticate_admin!
  worry = Worry.find(params[:id])
  if params[:approve]
    worry.update(needs_moderation: false)
  elsif params[:delete]
    worry.destroy
  end
  redirect '/admin/moderation'
end
```

3. 専門家への紹介：
   深刻な悩みの場合、専門家への相談を促すメッセージを表示します。

```erb:views/worry_show.erb
<% if @worry.needs_expert_advice? %>
  <div class="alert">
    この悩みについては、専門家に相談することをおすすめします。
    <a href="/resources">相談窓口一覧はこちら</a>
  </div>
<% end %>
```

## 効率的なデータベースクエリ

悩み管理において、効率的なデータベースクエリは重要です。以下にいくつかの例を示します：

1. N+1問題の回避:
   ```ruby
   @worries = Worry.includes(:user).public_worries.order(created_at: :desc)
   ```
   これにより、悩みを取得する際に関連するユーザー情報も一緒に取得し、データベースへのクエリ回数を減らすことができます。

2. カウンターキャッシュの利用:
   悩みに対する回答の数を
   2. カウンターキャッシュの利用:
   悩みに対する回答の数を頻繁に参照する場合、`answers_count`カラムをworriesテーブルに追加し、カウンターキャッシュを利用することで、パフォーマンスを向上させることができます。

   ```ruby:db/migrate/YYYYMMDDHHMMSS_add_answers_count_to_worries.rb
   class AddAnswersCountToWorries < ActiveRecord::Migration[5.2]
     def change
       add_column :worries, :answers_count, :integer, default: 0
     end
   end
   ```

   そして、Answerモデルに以下を追加します：

   ```ruby:models.rb
   class Answer < ActiveRecord::Base
     belongs_to :worry, counter_cache: true
   end
   ```

3. インデックスの活用:
   頻繁に検索や並べ替えに使用されるカラムにはインデックスを追加します。例えば：

   ```ruby:db/migrate/YYYYMMDDHHMMSS_add_indexes_to_worries.rb
   class AddIndexesToWorries < ActiveRecord::Migration[5.2]
     def change
       add_index :worries, :user_id
       add_index :worries, :category
       add_index :worries, :created_at
       add_index :worries, :is_public
     end
   end
   ```

4. 複雑なクエリの最適化:
   例えば、カテゴリ別の未解決の悩み数を取得する場合：

   ```ruby:app.rb
   get '/worry_stats' do
     @category_stats = Worry.unresolved.group(:category).count
     erb :worry_stats
   end
   ```

## パフォーマンス最適化

1. ページネーション:
   大量の悩みデータを扱う場合、ページネーションを実装することで、パフォーマンスを向上させることができます。`will_paginate` gemを使用する例：

   ```ruby:app.rb
   require 'will_paginate'
   require 'will_paginate/active_record'

   get '/worries' do
     @worries = Worry.public_worries.paginate(page: params[:page], per_page: 20)
     erb :worries_index
   end
   ```

   ```erb:views/worries_index.erb
   <!-- 既存のコードの後に追加 -->
   <%= will_paginate @worries %>
   ```

2. キャッシング:
   頻繁にアクセスされるが、更新頻度の低いデータ（例：カテゴリ一覧）をキャッシュすることで、パフォーマンスを向上させることができます。

   ```ruby:app.rb
   require 'sinatra/cache'

   set :cache_enabled, true
   set :cache_output_dir, Proc.new { File.join(root, 'public', 'cache') }

   get '/categories' do
     cache do
       @categories = Worry.distinct.pluck(:category)
       erb :categories
     end
   end
   ```

## 中高生向けサービスならではの配慮事項

1. 適切な言葉遣いと表現:
   中高生に適した言葉遣いや表現を使用することが重要です。システムメッセージやエラーメッセージなども、親しみやすく、理解しやすい表現を心がけましょう。

   ```ruby:helpers.rb
   helpers do
     def user_friendly_error_message(error)
       case error
       when :not_found
         "お探しの内容が見つかりませんでした。別のキーワードで試してみてください。"
       when :unauthorized
         "この操作を行うには、ログインが必要です。ログインしてみませんか？"
       else
         "エラーが発生しました。もう一度試してみてください。"
       end
     end
   end
   ```

2. 利用時間の制限:
   中高生の健全な生活リズムを守るため、深夜帯の利用を制限することも検討しましょう。

   ```ruby:app.rb
   before do
     if Time.now.hour >= 22 || Time.now.hour < 6
       @late_night = true
     end
   end
   ```

   ```erb:views/layout.erb
   <% if @late_night %>
     <div class="alert">
       夜遅くまでの利用は健康に影響を与える可能性があります。十分な睡眠をとることをおすすめします。
     </div>
   <% end %>
   ```

3. ポジティブな環境作り:
   悩みを共有し合うコミュニティであるため、ポジティブな雰囲気作りが重要です。励ましのメッセージや、解決に向けたアドバイスを促す機能を実装しましょう。

   ```ruby:models.rb
   class Answer < ActiveRecord::Base
     # 既存のコードに追加
     validates :content, presence: true, positivity: true
   end

   class PositivityValidator < ActiveModel::EachValidator
     def validate_each(record, attribute, value)
       negative_words = ['馬鹿', 'ダメ', '無理', # その他ネガティブな単語]
       if negative_words.any? { |word| value.include?(word) }
         record.errors.add(attribute, 'はもう少し前向きな表現に変えてみましょう')
       end
     end
   end
   ```

4. 教育的要素の組み込み:
   悩みの解決過程を通じて、問題解決スキルや感情管理スキルを学べるような仕組みを取り入れましょう。

   ```ruby:app.rb
   get '/worries/:id/solve' do
     @worry = Worry.find(params[:id])
     @steps = [
       "問題を明確にする",
       "可能な解決策を考える",
       "それぞれの解決策のメリット・デメリットを考える",
       "最適な解決策を選ぶ",
       "実行する",
       "結果を評価する"
     ]
     erb :problem_solving_steps
   end
   ```

## まとめ

Part 3では、中高生向け悩み相談Webサービス「Ballon」の核となる悩み投稿機能の実装とデータベース設計について詳しく解説しました。CRUD操作の実装、効率的なデータベースクエリの書き方、そしてセンシティブな内容への対応など、実践的な内容を多く含んでいます。

また、中高生向けサービスならではの配慮事項についても触れ、適切な言葉遣いや表現、利用時間の制限、ポジティブな環境作り、教育的要素の組み込みなど、ユーザーの年齢層に合わせた機能や工夫について説明しました。

次回のPart 4では、回答機能と「いいね」機能の実装、そしてより高度なセキュリティ対策について焦点を当てます。また、中高生のメンタルヘルスに配慮したコンテンツモデレーションの手法についても触れる予定です。

ご質問やフィードバックがありましたら、コメント欄にてお待ちしています！

## 参考リンク

- [Active Record クエリインターフェイス](https://guides.rubyonrails.org/active_record_querying.html)
- [Active Record アソシエーション](https://guides.rubyonrails.org/association_basics.html)
- [Active Record バリデーション](https://guides.rubyonrails.org/active_record_validations.html)
- [Will Paginate](https://github.com/mislav/will_paginate)
- [Sinatra Cache](https://github.com/kematzy/sinatra-cache)