【個人開発】断捨離アプリ「steteco」を未経験者が開発しました（Rails7）

## はじめに
初めまして。この度、初めて断捨離アプリである個人アプリ「steteco」をリリースいたしました。
まだまだプログラミング学習中の未経験エンジニアであり、技術的な内容などは誤りを含む可能性があります。
そのため、おかしな記述などがあればコメント等で教えていただけたら幸いです!
## stetecoについて
今回のアプリは、不用品の整理をしている際に「もったいないから」「フリマアプリで売れるかもしれないから」と考えてしまい、捨てる決断ができない優柔不断な方向けの、断捨離を後押しするサービスです。

私は、部屋の整理をして不用品が出るたびに、「フリマアプリで売れるかも」と思い、結局物置部屋のものが増えるだけという悪循環を繰り返していました。
同様に優柔不断な性格で整理ができない人が多いと気づき、断捨離を後押ししてくれるツールがあれば便利なのではと考え、「steteco」を作成しました。
- 作成期間: 約3ヶ月
- URL:https://steteco-dansyari.com/
- GitHub:https://github.com/ayaikg/sort_unwanted_items
- 画面遷移図:https://www.figma.com/file/Zzg6RfUUWUZmRSWMpDLzQs/Untitled?type=design&node-id=0-1&mode=design&t=tPnhju2RDm8lFtDM-0

## 機能について
大まかな使い方の流れを説明します。
| ログイン機能 | アイテム管理 |
|:-:|:-:|
|![スクリーンショット 2024-02-22 21.53.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/f713c0f9-1999-a853-c61f-364216226701.png)|![steteco_giita1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/a0545f9f-549f-8e54-a137-a570ee7e8f1e.gif)|
| ログイン方法は、LINEログインです。お試し機能としてゲストログインも用意しています。 | アイテムの管理がカテゴリーごとにできます。また、各カテゴリーページでは、出品中・未出品で振り分けています。 |

| アイテム追加・履歴 | LINE通知 |
|:-:|:-:|
|  ![steteco_giita2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/29280a9b-b383-ebc7-db18-bf3a752e08af.gif)| ![IMG_5654.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/f65a9107-b1ad-afd3-8079-f502bb6c2965.png) |
| アイテム追加ボタンから、作成フォームにアクセスします。一覧表示されている各アイテムをクリックすると詳細ページに遷移します。詳細ページの編集ボタンから編集ページに遷移します。LINE通知に断捨離メッセージが届いたら、編集ページで処分方法を変更し、断捨離済みの状態にします。履歴ページに断捨離済みのアイテムが表示されます。 | アイテム登録時に設定した通知日にLINEで断捨離メッセージが届きます。また、通知日が過ぎても断捨離できていないアイテムがあれば、毎週土曜日にリマインドが届きます。 |

| レコメンド機能 | 投稿機能 |
|:-:|:-:|
| ![steteco_qiita5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/296b1900-4841-1ac9-e7bb-9bd10ab2b5a5.gif) | ![steteco_giita3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/3feb4b6d-93e4-c9d4-d2e2-dd0f10107a23.gif) |
| ログインユーザーの未断捨離アイテムの中から、次に捨てた方が良いものをおすすめします。全ユーザーのアイテム情報をもとにレコメンドするので、断捨離に迷ったときに参考にできます。 | 基本的な投稿のcrud機能を実装しました。投稿にはいいねもできます。いいね一覧ページもあります。Xで投稿内容をシェアできます。 |


| グラフ | 目標設定 |
|:-:|:-:|
| ![スクリーンショット 2024-02-26 10.32.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/7bf49b76-2d1b-3bd7-16c7-d47ba4fd2b43.png) | ![steteco_qiita4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/9d4d2bfa-bcc0-fb6d-876b-f1870ca4b192.gif) |
| 断捨離数を週間ごとにグラフ化しています。現在のアイテム数と、先月の断捨離数も確認できます。 | 断捨離目標数を設定でき、現在の進捗を円グラフを数字で確認できます。フォームはモーダルで表示しています。 |

ユーザー編集機能は投稿機能と同じように編集が可能です。

- LINEログイン
- LINE通知
- マイページ
- 投稿機能
- いいね機能
- 断捨離数の目標設定
- 出品中・未出品別の不用品管理
- カテゴリー分け(不用品がどのカテゴリーに属しているか分ける)
- 断捨離数をグラフ化(週間)
- 断捨離したモノ履歴一覧
- 検索機能（オートコンプリート、インスタントサーチ）
- Xシェア
- 管理機能
- レコメンド機能
- 検索機能とソート機能のSPA化(turbo_frames使用)
### 使用した技術
#### バックエンド
- Ruby on Rails7系
  - Ruby 3.2.2
  - Rails 7.0.6
#### フロントエンド
- Javascript,Hotwire
- JSフレームワーク
  - Stimulus
- 画像系
  - CarrierWave
  - MiniMagick
- CSSフレームワーク
  - Tailwind
  - DaisyUI

今回、フロントエンドではHotwireを使いました。採用理由は、Railsと相性が良い点と、簡単にSPA風の実装ができる点です。
また、今回のアプリは多くのページに検索機能やページネーションを実装していること、管理系のアプリであるためそれらの機能をユーザーが利用する機会が多いことが予想されたため、SPA風にし画面遷移に関するストレスを減らしたいと思いました。
そして、特定の機能だけSPA風にしたかったので、後付けで簡単にSPA風の実装ができるHotwireは今回の個人アプリに向いていると思い採用しました。

CSSフレームワークでは、TailWindとDaisyUIを採用しました。アプリがシンプルかつ差別化できるようなデザインにしたかったので、テーマカラーが細かく決められる点とコンポーネントの雰囲気からDaisyUIが良いと思い採用しました。
#### WebAPI
- LINE Messaging API
- gooラボテキストペア類似度api

アプリのメイン機能がLINE通知になるため、LINE Messaging APIを利用しました。
gooラボテキストペア類似度apiは、レコメンド機能で利用しました。レコメンドの方法として、ユーザーが登録したアイテムと捨てたアイテムの名前をapiを使い類似度を計算しています。
#### インフラ
- Webアプリケーションサーバ
  - Fly.io
- ファイルサーバ
  - AWS S3
- データベースサーバ
  - PostgreSQL(Fly Postgres)

インフラは、まず無料枠がありコマンドが直接的でわかりやすく使いやすいとFly.ioにしました。
ファイルサーバはAWS S3を使用しました。Fly.ioを利用料金を抑えるためと、参考記事が多いことが決め手です。
#### その他
- VCS
  - GitHub
- CI/CD
  - GitHubActions
## テーブル構成
![スクリーンショット 2024-02-17 10.41.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3676271/e24d1355-5b48-4316-e002-fce6113eb038.png)

## 工夫したところ
- LINE通知
  - メッセージのフォーマット
  アイテムを複数ある場合、アイテムごとに通知を送るとユーザーが煩わしく感じてしまうことを防ぐために、一回の通知で断捨離アイテムがわかるようにフォーマットを変更しました。
コードが長いことと別ファイルでも使うので、moduleで分けてrakeファイルに読み込むようにしました。LINE Messaging APIを使用しています。
```ruby:tasks/push_line.rake
namespace :push_line do
  desc "LINEBOT:notify_dateの通知"
  task push_line_message_notify_date: :environment do
    require 'line_message'
    require 'line_client'

    users = User.all
    users.each do |user|
      unless user.authentications.empty?
        limit_items = Item.joins(:notification).where(user_id: user.id).where(disposal_method: :before).where(notification: { notify_date: Date.today })
        unless limit_items.empty?
          names_with_links = limit_items.map do |item|
            LineMessage.item_list(item)
          end
        
          title = "今日の断捨離アイテム"
          description = "今日は通知日設定をしたアイテムがあります。断捨離できたアイテムは、編集ページで処分方法を選択しましょう!"
          message = LineMessage.message_mold(names_with_links, title, description)
          response = LineClient.line_bot_client.push_message(user.authentications.first.uid, message)
          p response
        end
      end
    end
  end
end
```
LINE Messaging APIはあらかじめフォーマットが決まっているテンプレートメッセージとレイアウトが変更できるFlex Messageがあり、今回はFlex Messageを使いレイアウトを変更しました。
JSON形式で書く必要があり、慣れない中で不安でしたが、LINEではFLEX MESSAGE SIMULATORという、メッセージのレイアウトをシュミレートできるサービスを提供しているので、これを使いなんとか実装できました。
```ruby:lib/line_message.rb
module LineMessage
  module_function

  def item_list(object)
    edit_url = "https://steteco-dansyari.com/items/#{object.id}/edit"
    default_url = "https://placehold.jp/15/cccccc/ffffff/80x80.png?text=No%20Image"
    {
      type: "box",
      layout: "horizontal",
      contents: [
        {
          type: "box",
          layout: "vertical",
          contents: [
            {
              type: "image",
              url: object.image.present? ? object.image.url : default_url,
              aspectMode: "cover",
              size: "full"
            }
          ],
          cornerRadius: "100px",
          width: "72px",
          height: "72px"
        },
        {
          type: "box",
          layout: "vertical",
          contents: [
            {
              type: "text",
              contents: [
                {
                  type: "span",
                  text: object.name,
                  weight: "bold"
                }
              ],
              size: "sm",
              wrap: true
            },
            {
              type: "box",
              layout: "horizontal",
              contents: [
                {
                  type: "button",
                  action: {
                    type: "uri",
                    label: "編集ページにいく",
                    uri: edit_url
                  },
                  style: "link",
                  height: "sm"
                }
              ]
            }
          ]
        }
      ],
      spacing: "xl",
      paddingAll: "20px"
    }
  end

  def message_mold(names_with_links, title, description)
    {
      type: 'flex',
      altText: title,
      contents: {
        type: 'bubble',
        header: {
          type: 'box',
          layout: 'horizontal',
          contents: [
            {
              type: 'text',
              text: title,
              wrap: true,
              size: 'md'
            }
          ]
        },
        body: {
          type: 'box',
          layout: 'vertical',
          contents: [
            {
              type: "box",
              layout: "horizontal",
              contents: [
                {
                  type: 'text',
                  text: description,
                  wrap: true,
                  size: 'sm',
                  margin: "lg"
                }
              ]
            },
            {
              type: "box",
              layout: "vertical",
              contents: names_with_links
            }
          ],
          paddingAll: "0px"
        }
      }
    }
  end
end
```
- レコメンド機能
ユーザーが追加したアイテムの中から、全ユーザーが捨てたアイテムと似ているモノを探し、そのアイテムの断捨離をお勧めします。断捨離を後押しする機能を、LINE通知以外にも増やしユーザーの後押しを強めたいという気持ちから実装しました。
usersテーブルとitemsテーブルのデータを使うので、レコメンドはモデルに紐づかないコントローラにコードを記述しました。また、gooラボテキストペア類似度apiというapiを使用しました。
大まかな実装の流れは以下の通りです。
1.ログイン中ユーザーの断捨離前のアイテムと、全ユーザーの捨てた状態になっているアイテムを各々取得する。
2.全ユーザーの捨てた状態になっているアイテムは、同じカテゴリーごとにグループ分けする。
3.ログイン中ユーザーの断捨離前のアイテムと、全ユーザーの捨てた状態になっているアイテムの名前の類似度をapiで算出する。
4.ログイン中ユーザーの断捨離前のアイテムの中から、類似度が高い数値が一番多く出ているアイテムを取得する。
どうロジックを立てれば良いかわからず苦労しましたが、ハッシュの中身をしっかりイメージすることが攻略の鍵になりました。
```ruby:contollers/recommendscontroller
class RecommendsController < ApplicationController
  before_action :set_recommend
  include TextpairApi

  SIMILARITY_THRESHOLD = 0.6

  def show
    return unless @user_items.present? && @grouped_items.present?

    result_hash = calculate_similarity(@user_items, @grouped_items)
    count_hash = change_count_hash(result_hash)
    @recommend_item = fetch_recommend_item(count_hash) if count_hash
  end

  private

  def set_recommend
    @user_items = current_user.items.before_disposal
    category_ids = @user_items.pluck(:category_id)
    similar_items = Item.where(disposal_method: "discard").where(category_id: category_ids)
    @grouped_items = similar_items.group_by(&:category_id).transform_values do |items|
      items.map(&:name)
    end
    @nearest_item = current_user.items.before_disposal.upcoming_notification.first
  end

  def change_count_hash(result_hash)
    change_hash = result_hash.transform_values do |values|
      values.count { |value| value >= SIMILARITY_THRESHOLD }
    end
    return nil if change_hash.values.all?(&:zero?)

    # デバッグ時にreturnがないと値が返らない
    return change_hash
  end

  def fetch_recommend_item(count_hash)
    max_count = count_hash.values.max
    items_with_max_count = count_hash.select { |_key, value| value == max_count }
    return if items_with_max_count.blank?

    item_ids = items_with_max_count.keys
    return current_user.items.random_order(item_ids).first
  end
end
```
apiを使った、類似度計算のコードはモジュールに分けています。
gooラボテキストペア類似度apiでは、`content_type`を`application/json`にしないといけない指定があったり、json形式に変換して、またjson形式から戻す作業があったり、外部のapiを使うのは初めてだったので、戸惑いましたがさまざまな記事やドキュメントを読み込んで実装できました。
apiもjsonを使う作業も個人アプリの制作で初めてでしたが、新しい学びも沢山あり、良い経験になりました。
```ruby:controllers/cencerns/textpair_api.rb
module TextpairApi
  extend ActiveSupport::Concern
  require 'net/https'
  require 'uri'
  require 'json'

  def calculate_similarity(user_items, grouped_items)
    similarity_hash = {}
    user_items.each do |user_item|
      user_item_id = user_item.id
      similarities = []

      grouped_items_names = grouped_items[user_item.category_id] || []
      grouped_items_names.each do |grouped_item_name|
        similarity = post_text(user_item.name, grouped_item_name)
        similarities << similarity if similarity
      end
      # ハッシュオブジェクト[キー] = 値で新しい要素を追加
      similarity_hash[user_item_id] = similarities
    end
    similarity_hash
  end

  def post_text(text1, text2)
    uri = URI.parse("https://labs.goo.ne.jp/api/textpair")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true

    req = Net::HTTP::Post.new(uri.request_uri)
    goo_app_id = Rails.application.credentials.dig(:goo, :app_id)
    content = { "app_id" => goo_app_id, "text1" => text1, "text2" => text2 }
    req.content_type = 'application/json'
    req.body = content.to_json
    begin
      res = http.request(req)
      req_result = JSON.parse(res.body)
      req_result["score"]
    rescue Net::ReadTimeout, Net::OpenTimeout
      nil
    end
  end
end
```
## 今後、追加したい機能
実際に使っていただいたユーザーからの感想をもとに下記の機能を追加したいです。
- フォロー・フォロワー機能
自分が捨てたものを人に知られたくないから、投稿機能を利用しにくい、フォロー・フォロワー機能をつけて友人や家族の間のみなら投稿しやすくなるかもという意見をいただいたためです。
- アンケート機能
家族に自分が持っている不用品が欲しいか聞ける機能があれば、捨てずに譲ることもできるという意見をいただきました。また、いらないと言われたら捨てる決断もできるためです。
- チャット機能
ユーザー同士が直接やり取りできる機能があれば、上記二つの機能もより生きると思ったためです。
- 鍵垢機能
フォロー・フォロワー機能の延長線で、投稿内容を友人間のみか全体公開で選べるようにすれば、投稿機能が利用しやすくなると思ったためです。
## まとめ
初めてポートフォリオでしたが、学ぶことがたくさんありましたし、自分の成長も感じられました。これからもブラッシュアップを頑張っていきます！
最後まで読んでいただきありがとうございました!