【個人開発】忘れっぽい人向けに、日用品の購入時期をLINEに通知するアプリを開発しました


# 初めに
初めまして、タケ(@Take_nakaji)と申します。
2024年6月に、プログラミングスクールRUNTEQに入学し、エンジニア転職を目指しています。
この度、登録した日用品の残量が少なくなる日時を計算し、ユーザーへLINE通知をしてくれるアプリ「[StockMate](https://stockmate.dev)」を開発しました。
<br>
[![Image from Gyazo](https://i.gyazo.com/35fe0ff728869a55b1d286e5e8c3bc71.png)](https://gyazo.com/35fe0ff728869a55b1d286e5e8c3bc71)
<br>

今回はこちらのサービス紹介記事となります。具体的にはREADMEの内容に解説などを追加したものとなります。
<br>

https://stockmate.dev


- Githubはこちら

https://github.com/nakajima-takeshi/stockmate


# 目次
- [概要](#概要)
- [開発背景](#開発背景)
- [サービス紹介](#サービス紹介)
- [使用技術](#使用技術)
- [ER図](#er図)
- [通知タイミングの計算](#通知タイミングの計算)
- [MVPリリース](#MVPリリース) 
- [本リリース](#本リリース)
- [LINE Bot機能](#LINEBot機能)
- [今後の改善点](#今後の改善点)
- [最後に](#最後に)

# 概要
- 登録された日用品の消耗具合を予測して、減ってきたタイミングで通知してくれます
- 通知をLINE履歴で確認できるので、「何買えばいいんだっけ？」とわからなくなることを防止することができます

# 開発背景
実家を出て一人暮らしを始めた当初、以下のようなことを経験して苦労したことがありました。

*普段使っているシャンプーの残量が少なくなってきていることに気付き、明日の仕事帰りにドラッグストアへ寄ることにした。しかし次の日、帰宅後に買って帰ることを忘れていたことを、シャワーを浴びてから気付いた。 休日になり、ドラッグストアで在庫を購入し帰宅したところ、今度は食器用洗剤の量が少なくなっていることに気づいた。*

こういった日用品の買い忘れや買い漏らしを防ぐため、次はいつ、どの日用品が切れそうかをアプリから管理できたら便利だなと考え、制作を決めました。

# サービス紹介
### 新規作成・編集
[![Image from Gyazo](https://i.gyazo.com/d6d1bcce33b92a95273aaba79f154978.png)](https://gyazo.com/d6d1bcce33b92a95273aaba79f154978)

### 詳細ページ
[![Image from Gyazo](https://i.gyazo.com/23fd9412c04772f32265aafda93fc518.png)](https://gyazo.com/23fd9412c04772f32265aafda93fc518)
- 一覧ページのカテゴリーアイコンをタップすると確認できます

### 通知予定日の編集
[![Image from Gyazo](https://i.gyazo.com/63eccb049f39540b59ffd2fb9ef74fef.png)](https://gyazo.com/63eccb049f39540b59ffd2fb9ef74fef)

### LINE画面での操作
[![Image from Gyazo](https://i.gyazo.com/c9a4e87a9cb61580c04477fd6183cd9a.png)](https://gyazo.com/c9a4e87a9cb61580c04477fd6183cd9a)


# 使用技術
| カテゴリ       | 技術 |
|--------------|-----------------------|
| バックエンド  | Ruby on Rails 7.1.4, Ruby 3.2.2 |
| フロントエンド | JavaScript, Stimulus |
| CSSフレームワーク | TailwindCSS, DaisyUI |
| 環境構築     | Docker |
| CI/CD       | GitHub Actions |
| インフラ     | Heroku, Cloudflare |
| データベース | MySQL |
| 認証        | Devise |
| API         | LINE Messaging API, LINE Developers|

### 一部を紹介
- LINE Messaging API & LINE Developers(公式サイト)

https://developers.line.biz/ja/

LINE Messaging APIは、アプリでLINEに関するサービスを利用する際に使用するAPIです。Gemfileに"line-bot-api"を記述してインストールします。
LINE DevelopersはLINEヤフー株式会社が提供している、LINEのサービスと連携したアプリケーションを開発するためのツールや管理画面を提供しているサイトです。
本サービスのLINEに関わるサービスの通信設定や、採用しているLINE Botリッチメニューを作成しています。

ユーザーへの通知送信方法として、LINEのBot機能を利用することでサービスの大半をLINE上で完結させることができると考えて採用しました。

- Heroku

https://jp.heroku.com/

本サービスのプラットホームに採用しています。理由は、Herokuにある”Scheduler”という無料アドオンを使いたかったからです。Schedulerとは従来のサーバー環境での cron​ と同様に、アプリ上でジョブを定期的な時間間隔​で実行するための無料のアドオン​です。
これを用いることで、一日に一回、次回通知日が今日となっている日用品を検索して取得し、ユーザーに通知を行う機能を簡単に実装できます。

- Cloudflare

https://www.cloudflare.com/ja-jp/

Cloudflareはインターネットの接続やセキュリティ、開発者向けのサービスを提供するクラウドサービスです。今回のドメイン取得はこちらで行なっています。
Cloudflareの優れた点として、コスト、パフォーマンス、セキュリティ、Herokuとの連携のしやすさといった面が挙げられます。特にセキュリティ対策として付属されている、無料のDDoS保護とSSL証明書があります。本サービスではLINE連携を行うサービスなので、DDoS保護などのセキュリティ対策は特に重要と考えて採用しました。

DDos攻撃とは

https://www.skyseaclientview.net/media/article/1583/

また、CSRF攻撃に対する対策として、Gem "omniauth-rails_csrf_protection"をインストールしています。
LINEのプラットホームではAOuth2.0のCSRF保護機能を採用しているのですが、このGemを利用することでさらに堅牢な設定となっています。

https://securesky-plus.com/engineerblog/1848/


# ER図

[![Image from Gyazo](https://i.gyazo.com/1c93de0eb2549a3c930d66a3f4ae99ea.png)](https://gyazo.com/1c93de0eb2549a3c930d66a3f4ae99ea)
### 各テーブルの解説
- Usersテーブル
  - uid: LINEに登録されているユーザーID
  - provider: 外部認証の種類

LINEログイン機能とLINE通知機能を実装する観点から、プロバイダー情報とユーザーIDを保存するためにprovider,uidカラムを追加しています。
<br>

- Itemsテーブル
  - category: カテゴリー
  - name: 商品名
  - volume: 内包量
  - used_count_per_weekly: 一週間の使用回数
  - memo: メモ

ユーザーが登録した日用品情報を保存します。当初は次回通知日のデータも同じテーブルにあったのですが、責務の分離の観点から次回通知日を独立させた方が合理的と判断しました。
<br>

- Notificationsテーブル
  - next_notification_day: 通知予定日
  - last_notification_day: 前回通知日
  - notification_interval: 通知予定日までの間隔

算出された通知日を保存します。在庫補充された際、通知日の再設定をする際に使用する前回通知日やインターバル情報を保存するため、通知関連はItemsテーブルから独立させたテーブルに保存します。

# 通知タイミングの計算
新規作成・編集で入力されたデータを元に、以下のコードで次回通知日を算出しています。算出したものをデータベースに保存し、通知日になったデータと紐付いている日用品の登録内容をユーザーに送信します。

### 当初の課題点
- **次回通知予定日の精度**
最初の登録時点では、一回の使用量がユーザーごとに大きく異なるため、通知予定日を算出しても実際にその通りになるかは未知でした。
そこで、初回の通知予定日を保存し、2回目以降はユーザーがカスタマイズできるようにすることで、使うほど精度が高まる設計にしました。
<br>

- **毎日使用しない日用品の計算が難しい**
当初は「1日の使用回数」を入力する方式でしたが、例えば「3日に1回使用する」といった場合、ユーザーが小数点を求めて入力する必要があり、登録が煩雑になる問題がありました。
そこで、「1週間の使用回数」を入力してもらい、それを7分割することで1日の使用回数を自動算出できるようにしました。


### 計算式 ###

以下の部分で、新規登録・編集時に次回通知日を算出しています。

```models/item.rb

    # 1回の平均使用量（メーカーなどのHPを参照）
    AVERAGE_USAGE = {
        "shampoo" => 6,
        "body_soap" => 6,
        "laundry_detergent_powder" => 20,
        "laundry_detergent_liquid" => 25,
        "fabric_softeners" => 25,
        "dishwashing_detergent" => 4,
        "lotion" => 5,
        "serum" => 3,
        "moisturising_cream" => 2,
        "face_wash" => 1,
        "sunscreen" => 2,
        "others" => 10
    }

    def calculate_next_notification_day
        # その他の登録時は、自動で次回通知日を14日後に設定
        if self.category == "others"
            return Date.today + 14.days
        end

        daily_usage = calculate_daily_usage　# 一日の消費量
        notification_volume = (self.volume * 1.0 / 3).ceil(2) # 通知するタイミングの内包量（内包量の1/3に設定）

        days = 0
        max_days = 365 # 次回通知日が一年以内に収まるようにする
        # 次回通知日を計算
        while days < max_days
            # 登録した内包量から経過した日数分の消費量を引く
            reminding_volume = self.volume - (daily_usage * days)
            # 内包量が1/3を下回ったタイミングでループを終了
            if reminding_volume <= notification_volume
                break
            else
                days += 1
            end
        end
        # 日付を返す
        Date.today + days
    end

pravate
        # 一日の消費量を算出
    def calculate_daily_usage
        average_usage = AVERAGE_USAGE[self.category] || 0 # 上記の一回の平均消費量
        used_count_per_weekly = self.used_count_per_weekly.to_i
        average_usage * ((used_count_per_weekly / 7.0).ceil(2)) # 一週間の平均消費量を7分割して一日の消費量を算出。
    end
    
```
<br>

___[通知タイミング(何日後か) = (内包量 - 1日の推定消費量) <= 内包量 * 1/3]___

**1日の推定消費量** = ( (1回の平均使用量 or メーカー等が推奨する１回の適量) * 1日の推定使用回数 * 使用日数 )  で算出します。
**内包量**: ユーザーが入力します。
**1日の推定使用回数**: ユーザーが入力した「一週間の使用回数」 / 7.0 の小数点第二位までの値を使用します。

#### 解説 ####

**1日の平均使用量**: インターネットで検索した、メーカーやまとめサイトなどから一人暮らしの平均消費量を１日あたりに換算して採用します。  
**メーカー等が推奨する１回の適量**: ユーザーごとの消費量に大きく差がある日用品（化粧品）などはこちらを元に計算します。  
**１日の推定使用回数**: ユーザーは一週間に何回ぐらい使用するかを入力します。そうすることで毎日使用しない日用品に対する計算の精度を向上させました。
**使用日数**: 計算時にループ処理で+1ずつ、条件を満たすまで日数を追加してきます。

ユーザーが任意の日用品等を登録できるようにその他というカテゴリーを設けていますが、こちらは計算を行わずに自動的に通知予定日を2週間後とするように設定しています。

また、前回の通知間隔を参考に次回通知予定日だけ編集できるようにすることで、ユーザーが自身なりにカスタマイズできるようにしました。


# MVPリリース
MVPでは、ユーザーが登録した内容から通知予定日を算出し、LINEで受け取れるところまでを実装しました。
- ユーザー登録・ログイン
  - ユーザー名,パスワード
  - LINE連携ログイン  **入力必須**
  - LINEに友達追加
- 日用品の登録機能
  - ジャンル選択(シャンプー、ティッシュBOXなど):選択したジャンルごとにフォーマット用意し、そのページに遷移
  - 商品名入力
    - 空欄でもOK（フリーメモと統合するかもしれません）
  - 内包量
    - フォーマットごとに単位が変わる。 **入力必須**
  - １日の使用回数  **入力必須**
  - フリーメモ
    - ユーザーは自由に書き込めます。内容はLINE通知の際、一緒に表示されます
- 登録した日用品一覧の表示、詳細、編集、削除
  - ユーザーはトップページに上記登録内容が表示されます
  - 通知予定日時が一覧、詳細画面に確認できます
    - 通知予定日時をユーザーが任意で変更できます
- LINEへの通知
  - 通知を受け取れます

# 本リリース
- LINEリッチメニュー機能
  - LINE上のメニューから、現在登録されている内容の確認や更新が行えます
  - アプリにリンクから簡単にアクセスできます
- OGP
- Google fonts
- 使い方
- プライバシーポリシー・利用規約・お問い合わせフォーム
- お友達追加QRコード

# LINE Bot機能

今回のサービスでは、LINE上から操作することで現在の登録内容と在庫補充による通知再設定を行うことができます。

### 登録確認
1. LINEに「登録確認」と入力される
2. 現在の登録されている日用品とその内容を全て取得し、ユーザーにメッセージで返す

### 在庫補充と通知再設定
現状では在庫補充と入力されても、どの日用品の在庫を補充したのか判定できません。そこでステップを設けてLINEBotに応答させるようにしました
1. LINEに「在庫補充」と入力される
2. 第一ステップをtrueの状態にする
3. LINEから「【商品名】を入力してください」と返答
4. 補充した商品の登録名を入力する
5. データベースに存在するものと一致した場合、それの次回通知日を計算して設定する


これらの処理のうち、LINE Botの応答を処理している部分を一部抜粋して解説します。

``` controllers/linebot_controllers.rb
class LinebotController < ApplicationController
    require "line/bot" # LINE APIを呼び出し
    skip_before_action :verify_authenticity_token

    # 在庫補充のステップを保存するハッシュ（後述）
    MESSAGE_STEP = {}

    def callback
        # gemのメソッドであるparse_events_from(body)でevents配列を取得
        events = client.parse_events_from(request_body)
        # 取得した配列を繰り返し処理（引数として処理にデータを渡す）
        events.each do |event|
            # ユーザーにメッセージを配信する記述２つ目の引数にあるmessageの中身を返す。
            client.reply_message(event["replyToken"], message(event))
        end
    end

    private

    def request_body
        # LINEからのイベントデータを取得
        request.body.read
    end

    def message(event)
        # caseメソッドでeventの種類の条件をwhenで指定し、種類ごとに進める処理を決定
        # 今回のアプリではテキストの時のみ処理をする
        case event
        when Line::Bot::Event::Message
            case event.type
            when Line::Bot::Event::MessageType::Text
                # 条件を満たした場合のみメソッドを呼び出す
                handle_message_event(event)
            end
        end
    end

    # 応答内容を決定する
    def handle_message_event(event)
        # user_idを取得（ユーザー特定に使用）
        user_id = event["source"]["userId"]
        # "text"が一致した場合、処理を行う。合わない場合はメニュー操作を促すメッセージを返す。
        case event.message["text"]
        when "登録確認"
            {
                type: "text",
                # 登録されているデータを一覧で取得するメソッドを呼び出す
                text: get_inventory_list(event)
            }
        when "在庫補充"
            # 第一ステップをtrueにする
            # MESSAGE_STEP{:user_id => true}
            MESSAGE_STEP[user_id] = true
            message = {
                type: "text",
                # ユーザーに返すメッセージ
                text: "登録している【商品名】を入力してください。"
            }
        else
            # 第一ステップがtrueであることを条件にすることで、入力された商品名のデータをメソッドに渡す
            if MESSAGE_STEP[user_id] == true
                result = handle_message_item_name(event)
                # MESSAGE_STEP{:user_id => false}に上書き
                MESSAGE_STEP[user_id] = false
                # 結果を返す
                result
            else
                # 上記のいずれでもない場合に返す
                message = {
                    type: "text",
                    text: "まずはメニューから操作を選択してください。"
                }
            end
        end

        def client
            # インスタンス変数が空の場合はLine::Bot::Clientのインスタンスを代入
            # 環境変数を利用してチャンネルシークレットとトークンをインスタンスに渡す。
            @client ||= Line::Bot::Client.new { |config|
                config.channel_secret = ENV["LINE_BOT_CHANNEL_SECRET"]
                config.channel_token = ENV["LINE_BOT_CHANNEL_TOKEN"]
            }
        end
    end


```
最初から「商品名」を入力することで再設定するという仕様にすることも考えましたが、ステップを踏んだ応答にした方がユーザーにとってわかりやすいと思い、このような設定にしました。

# 今後の改善点
現時点では、以下の実装を検討しています。

### 登録した日用品の並べ替え機能
現在は、登録した順に表示されています。「同カテゴリー」「次回通知予定日」「登録・更新日時」の順番を選択して並び替えることができるようになります。**※実装しました。**

### ローディングアニメーション
最初のトップページにアクセスした際や、ログイン後のページ遷移時に真っ白な画面が一瞬ですが表示されることがあります。
この間のロード中にnow loadingのアニメーションを挟めれば、さらなるUI/UXの向上が図れるのではと考えています。

<br>
また、実装とは関係ない部分ですが現在、LINEへの送信メッセージを作成するメソッドがcontrollerやmodelに点在している状態です。これをservisesディレクトリ配下に配置し、責務の分離を図る予定です。


# 終わりに
今回の開発を通じて、カリキュラムでの開発だけでなく、実際に手を動かして開発を行うことで知識の定着が図れるのだなと痛感しました。
今後も一層と学習と開発に励み、転職を成功させたいと思います。これ以外にも詳細な実装に関する記事は、時間があれば作成してみたいと思います。
誰かの役に立てば幸いです。ここまでご覧いただき、ありがとうございました。


# 参考

https://developers.line.biz/ja/docs/messaging-api/getting-started/

https://developers.line.biz/ja/reference/messaging-api/

https://devcenter.heroku.com/ja/articles/scheduler

https://qiita.com/tkhero555/items/4af4c7cd48a757974685

https://qiita.com/tkhero555/items/35a6330725c9e673ccdc

https://zenn.dev/yoshiki105/articles/eb093bf603e728

https://line-sm.com/blog/richmenu_design_manual/