【個人開発】野菜の摂取を促進するアプリを作成しました

# はじめに

こんにちは、プログラミングスクールRUNTEQにて学習しておりますEriと申します。

この度、忙しい生活の中で不足しがちな野菜の摂取をお応援するアプリ「おやさいUP」を開発いたしました。

https://www.oyasaiup.com/


https://github.com/erijawa/oyasai_up

[![Image from Gyazo](https://i.gyazo.com/63aaac63bc6403b2d3da5e2d3d16d26f.png)](https://gyazo.com/63aaac63bc6403b2d3da5e2d3d16d26f)

# 開発した理由

自身の妊娠や子育てを通し、栄養バランスに対する意識が上がりましたが、毎日毎食バランスのよい食事を用意するのは気をつけていてもなかなか難しいと感じています。特にランチなど自分1人分の食事を用意する時には、パンやおにぎり、うどんなど用意が簡単な炭水化物のみで済ませてしまいがちです。 かと言って毎食何gの野菜を摂取したかを記録するのは手間がかかる上、1日の必要野菜摂取量を毎日達成しようと意識しすぎると、義務感や達成できていない罪悪感で食事が楽しくなくなってしまいます。 

もっと気軽に楽しく、野菜を食べることへのモチベーションになるようなサービスを作りたいと考え、野菜を食べたかどうかを自己判断で「ゆるっと」記録するという発想に至りました。

# メイン機能(⭐︎はログインが必要な機能)

|トップ画面 |投稿一覧・検索機能|
|:--:|:--:|
|<a href="https://gyazo.com/2db20da43ff2d4497f0d364376539c6a"><img src="https://i.gyazo.com/2db20da43ff2d4497f0d364376539c6a.gif" alt="Image from Gyazo" width="280"/></a>|<a href="https://gyazo.com/4621e45224928533342449d67ce36507"><img src="https://i.gyazo.com/4621e45224928533342449d67ce36507.gif" alt="Image from Gyazo" width="286"/></a>|
|アプリの使用場面とログイン後に使える機能を紹介しています|投稿の一覧から条件を設定し検索できます|

|投稿共有の動的OGP|AIによるレシピ提案|
|:--:|:--:|
|[![Image from Gyazo](https://i.gyazo.com/0ac98f3496644e5b70b353be51638677.png)](https://gyazo.com/0ac98f3496644e5b70b353be51638677)|<a href="https://gyazo.com/b6e00e4773a0795131190a004435eed8"><img src="https://i.gyazo.com/b6e00e4773a0795131190a004435eed8.gif" alt="Image from Gyazo" width="284"/></a>|
|各投稿のシェアを行うとその投稿の写真が動的OGPとして表示されます|投稿されたレシピの食材を入れ替えた代用品レシピをAIが提案します|

|投稿作成・編集⭐︎|おやさいLogへの記録⭐︎|
|:--:|:--:|
|<a href="https://gyazo.com/71571dfe6760f73d7dddaf4be738106f"><img src="https://i.gyazo.com/71571dfe6760f73d7dddaf4be738106f.gif" alt="Image from Gyazo" width="280"/></a>|<a href="https://gyazo.com/84a80f87c938aef25c6182fefb18a607"><img src="https://i.gyazo.com/84a80f87c938aef25c6182fefb18a607.gif" alt="Image from Gyazo" width="282"/></a>|
|レシピを載せるかは選択制です。下書き保存して後から編集することも可能です。|その日のご飯で野菜をどれだけ食べたか、直感で記録します。記録に基づき、カレンダーが緑色に色づきます。|

|LINE通知⭐︎|ブックマーク⭐︎|
|:--:|:--:|
|[![Image from Gyazo](https://i.gyazo.com/cb5c777c89b5190104759f0681e13cd9.jpg)](https://gyazo.com/cb5c777c89b5190104759f0681e13cd9)|<a href="https://gyazo.com/0d587f0351a5e86ed7e91984c0efe0ef"><img src="https://i.gyazo.com/0d587f0351a5e86ed7e91984c0efe0ef.gif" alt="Image from Gyazo" width="256"/></a>|
|おやさいUPのLINE公式アカウントと友達になると、毎日20時におやさいLogのリマインダが受け取れます|気に入った投稿はブックマークに保存し、後で確認できます|

|プロフィール編集⭐︎|AIの提案レシピを下書き保存⭐︎|
|:--:|:--:|
|[![Image from Gyazo](https://i.gyazo.com/f2bb3ffb5de49fadc8e95a18fcd3f892.png)](https://gyazo.com/f2bb3ffb5de49fadc8e95a18fcd3f892)|<a href="https://gyazo.com/8859ff7a24947108bf634c12b2c89e83"><img src="https://i.gyazo.com/8859ff7a24947108bf634c12b2c89e83.gif" alt="Image from Gyazo" width="280"/></a>|
|ユーザー名、アバター画像の変更ができます|AIに生成してもらったレシピを下書きに保存しておくことができます。|


# 主な使用技術

| カテゴリー | 使用技術 |
:----|:----
| フロントエンド | Rails 7.2.1.2 / TailwindCSS / DaisyUI / Javascript|
| バックエンド | Rails 7.2.1.2 (ruby 3.2.3)  |
| インフラ | heroku / AmazonS3 |
| DB | MySQL |
| 開発環境 | Docker |
| 認証 | devise, LINE認証 |
| CI/CD |GitHub Actions|
| Web API | OpenAI API, LINE Messaging API |


# 工夫した点

## おやさいReport投稿機能

### JavaScriptによる動的フォームの実装

投稿フォームでは、レシピの有無が選択できます。

レシピ付きを選択した場合は、材料、作り方の入力欄の増減操作が可能です。

[![Image from Gyazo](https://i.gyazo.com/f1639ec252f108e15f723eed4f19ce75.gif)](https://gyazo.com/f1639ec252f108e15f723eed4f19ce75)

### 下書き保存機能の実装
[![Image from Gyazo](https://i.gyazo.com/a31a945ced643cb88c5d24f2b83fb690.jpg)](https://gyazo.com/a31a945ced643cb88c5d24f2b83fb690)
下書き保存ボタンを押すと、タイトルのみ入力されていれば保存が可能です。

下書き保存ボタンのname属性を`name="draft"`と設定しておき、コントローラーでは`params[:draft]`の有無で処理を分けています。

```ruby
# フォームの送信ボタン抜粋
<%= f.submit t("form.publish"), class: '…' %>
<%= f.submit t("form.draft"), name: "draft", class: '…' %>

# 下のsubmitを押すと、Parameters: {"authenticity_token"=>"[FILTERED]", "post_form"=>{…省略}, "draft"=>"下書き保存"}のようにparamsが送られる
```


ちなみにこちらの投稿フォームはForm Objectを利用しています。Form Objectに関しては、以下の記事にもまとめていますので、よろしければご覧ください！

https://qiita.com/jawajawa/items/da60643087d26dad349e

## おやさいLog機能

### 記録用モーダル

「おやさいLog」は、朝、昼、夜ごはんで野菜をどれだけ食べたか、直感で記録する機能です。

記録用フォームをモーダルとして設置することで、ページ遷移なく手早く入力できるようにしました。

[![Image from Gyazo](https://i.gyazo.com/0441b89a6fa90d821adb5a82ed517bc3.gif)](https://gyazo.com/0441b89a6fa90d821adb5a82ed517bc3)

### おやさいLog記録の仕組み

おやさいLogのデータは、各ユーザーにつき1日1件です。朝、昼、夜ごはんそれぞれの入力された値をcreate、update時に合計し、totalの値も保存します。

```ruby
# app/models/vegetable_log.rb
class VegetableLog < ApplicationRecord
  validates :date, presence: true, uniqueness: { scope: :user_id }
  belongs_to :user

  def calculate_total
    total = breakfast + lunch + dinner
    self.update(total: total)
  end
end

# app/controllers/vegetable_logs_controller.rb
class VegetableLogsController < ApplicationController
  def create
    @vegetable_log = current_user.vegetable_logs.build(vegetable_log_params)
    if @vegetable_log.save && @vegetable_log.calculate_total
      redirect_to user_path(current_user), notice: t("defaults.flash_message.vegetable_log.recorded", item: VegetableLog.model_name.human)
    else
      redirect_to user_path(current_user), notice: t("defaults.flash_message.vegetable_log.not_recorded", item: VegetableLog.model_name.human), status: :unprocessable_entity
    end
  end

  def update
    @vegetable_log = current_user.vegetable_logs.find(params[:id])
    if @vegetable_log.update(vegetable_log_params) && @vegetable_log.calculate_total
      redirect_to user_path(current_user), notice: t("defaults.flash_message.vegetable_log.recorded", item: VegetableLog.model_name.human)
    else
      redirect_to user_path(current_user), notice: t("defaults.flash_message.vegetable_log.not_recorded", item: VegetableLog.model_name.human), status: :unprocessable_entity
    end
  end

  private

  def vegetable_log_params
    params.require(:vegetable_log).permit(:breakfast, :lunch, :dinner).merge(date: Time.zone.today)
  end
end
```

### JSライブラリの使用

GitHubのようなヒートマップを表現するため、Cal-HeatmapというJavaScriptライブラリを使用しました。

https://cal-heatmap.com/

表示したいデータをJSON形式で渡すことで色づきます。

今回は、表示するユーザーに紐づくおやさいLogのデータをJSON形式で渡します。

```ruby
# app/controllers/users_controller.rbより抜粋
class UsersController < ApplicationController
  def show
    @user = User.find(params[:id])
    vegetable_logs = VegetableLog.where(user_id: @user.id)
    @vegetable_logs = vegetable_logs.to_json(only: [ :date, :total ])
  end
end
```

to_jsonメソッドのonlyオプションを使用し、dateとtotalのカラムのみを抽出しています。
表示結果は下の画像のようになります。

[![Image from Gyazo](https://i.gyazo.com/2f4d45d715d5952aaf01d7f28bb085b5.png)](https://gyazo.com/2f4d45d715d5952aaf01d7f28bb085b5)

## API使用時のCSRF対策

openAIのAPIを使用するにあたり、CSRFトークンエラーが発生しました。

`ActionController::InvalidAuthenticityToken (Can't verify CSRF token authenticity.)`

検索するとprotect_from_forgeryをコントローラーに追記するという解決策が出てきますが、それだとCSRF対策を無効化していることになるようです。

https://blog.ingage.jp/entry/2022/12/20/104214

そこで下記の記事を参考に、別途CSRF対策用のメソッドを実装しました。

CSRFトークン方式ではなく、リクエストの出所をOriginヘッダー等で検証しています。

https://speakerdeck.com/corocn/kaigionrails-2024-csrf

```ruby
  class CsrfProtectionError < StandardError; end
  protect_from_forgery  # こちらは無効にした上で、
  before_action :check_csrf # 別途CSRF対策メソッドを用意
  
  private
  
  def check_csrf
    # Originが許可されていない場合はエラー
    allowed_origins = ['https://oyasaiup.com', 'https://www.oyasaiup.com']
    origin = request.headers['Origin']

    if origin.blank? || allowed_origins.exclude?(origin)
      raise CsrfProtectionError
    end

    # Sec-Fetch-Siteが存在していて許可されていない場合はエラー
    allowed_values = ['same-origin', 'same-site']
    sec_fetch_site = request.headers['Sec-Fetch-Site']

    if sec_fetch_site.present? && allowed_values.exclude?(sec_fetch_site.downcase)
      raise CsrfProtectionError
    end
  end
```

# 今後の展望

- PWA
    
    忙しい毎日の合間の時間に使ってもらうことを想定しているアプリのため、スマホからもより使いやすくなるよう、PWAを導入したいと考えています。
    
- RSpec
    
    開発中は一部しかテストが書けていなかったため、テストコードを追加しカバレッジを上げていきたいです。
    

# 終わりに

アプリ開発を支えてくださったRUNTEQの講師の皆様、スクール生の皆様、本当にありがとうございます！

自分で考えた機能を実装する過程は、なかなか解決策が見つからず苦労することもありましたが、解決した時の喜びはひとしおでした。

少しでも誰かの役に立てるアプリとして成長させていけるよう、引き続き開発していきたいと思います。

最後までお読みいただき、ありがとうございました！