0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Discardを使った退会機能の実装

Posted at

この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。

0.前提条件

X(旧Twitter)のクローンサイトの制作過程で退会機能を実装した時に内容を修正した過程についてご紹介したいと思います。

フロントエンド:React(JavaScript) 19.1.0
バックエンド:Ruby on Rails(Ruby) 7.0.0 APIモード
インフラ:Docker
PC;Mac Book Air M2チップ

1.実装概要

アカウント登録済みのWebサービスから退会(利用停止)するための機能を実装します。

  • 退会対象アカウントは物理削除ではなく、論理削除
  • 論理削除機能はdiscardのgemを使って実装する
  • 退会後はWebサービスを利用できないように、退会 → サインアウトの順番で実行する

X(旧Twitter)本家ではユーザーが退会すると、1ヶ月間は論理削除としてデータ復活可能な期間が設けられており、その後に物理削除で完全に消去するようです。
論理削除期間中も対象のユーザーデータは閲覧できないようです。

2.実装内容

discardのgemをインストールしていきます。

Gemfile
gem 'discard', '~> 1.4'

Dockerを利用していたので、下記のコマンドでインストールします。apiはサービス名

docker compose exec api bundle install
userモデルに絡むを加えるマイグレーションファイル
class AddDiscardedAtToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :discarded_at, :datetime
    add_index :users, :discarded_at
  end
end

userモデルのカラムにdiscarded_atを加えており、論理削除の日時を意味しています。
これで論理削除の準備が整いました。
後述するuserモデルに関わるモデルにも同じようにdiscarded_atカラムを追加していますが、内容は同じなので掲載・説明は割愛します。

users_controllerにdiscarを使った論理削除メソッドを追加しました。

users_controller.rbのdiscardメソッド(退会:論理削除を実行)
      def discard
        if current_api_v1_user.discard
          render json: { status: 'SUCCESS', message: 'have soft-deleted user successfully' }
        else
          render json: { status: 'ERROR', message: 'User not soft-deleted' }
        end
      end

以下はuserのモデルファイルに実装したコードを抜粋しています。

models/user.rb
  # discard(gem)を使用できるように設定
  include Discard::Model
  # デフォルトの取得内容を変更。これによりdiscardで論理削除されたデータは含まない。
  default_scope -> { kept }
  # ログインしてくるユーザーが論理削除されていないことを確認
  # これにより論理削除されたユーザーはログインできない
  def active_for_authentication?
    super && kept?
  end

  # groupモデルを除いてユーザーが論理削除されたら関連するデータも論理削除する
  after_discard do
    passive_notifications.discard_all
    active_notifications.discard_all
    relations.discard_all
    entries.discard_all
    bookmarks.discard_all
    comments.discard_all
    favorites.discard_all
    messages.discard_all
    retweets.discard_all
    tweets.discard_all
  end

default_scopeはデータベースから値を取得する時の範囲で、keptはdiscardのメソッドであり、論理削除したデータを対象から外す意味になります。

active_for_notification?はdevise_token_authのメソッドでログインユーザーを認証する時に動きます。既存の内容に加えてkeptかどうか(論理削除されていこと)をログインの条件にしています。

after_discardブロックではdiscardがuserモデルで実行された後に他の関連するモデルも削除するように実装しています。ユーザーだけ論理削除して投稿などの対象ユーザーに関わるデータが残っていると正しく表示できずにエラーの原因となるからです。
groupモデルは対象から外していますが、これは複数のユーザーが属するデータなので、片方のユーザーが論理削除されただけでグループを削除するともう片方のユーザーに影響がでるので残すようにしています。

models/tweet.rb
 # discard(gem)を使用できるように設定
 include Discard::Model
 # デフォルトの取得内容を変更。これによりdiscardで論理削除されたデータは含まない。
 default_scope -> { kept }
 
 # ユーザーデータを取得する時に論理削除されたユーザーを含まない
 scope :from_active_users, -> { joins(:user).merge(User.kept) }

関連するモデルの代表でtweetモデルの実装内容を紹介します。他は同じなので割愛します。
上の2行はuserモデルと同じなので解説は割愛します。
最後の行ではuserを検索するクエリ内容を指定することで、論理削除されたユーザーを省くようにしています。
groupモデルのER図.png

devise_token_authのサインアウト機能をそのまま使っていると論理削除されたユーザーは対象に含まれないので、エラーが発生してしまいます。
というのも、userモデル側で論理削除されたユーザーは省くように実装しているからです。

サインアウトを実行した時のエラーメッセージ
Started DELETE "/api/v1/auth/sign_out" for 192.168.65.1 at 2025-10-07 12:46:22 +0000
Processing by DeviseTokenAuth: :SessionsController#destroy as HTML
User Load (36.7ms) SELECT "users" * FROM "users" WHERE "users" "discarded_at" IS NULL AND "users" "uid" = $1
LIMIT $2 [l"uid", "guy@sample.com"],
["LIMIT"
', 111
Completed 404 Not Found in 69ms (Views: 0.2ms | ActiveRecord: 36.7ms | Allocations: 1266)

WHERE "users" "discarded_at" IS NULLのクエリ条件が肝で、論理削除したユーザーを含まないクエリ検索になってしまっているのがエラーの原因です。
これを受けて、サインアウトを管理するsessions_contorller.rbのdestroyメソッドを上書きすることにしました。

routes.rb
mount_devise_token_auth_for 'User', at: 'auth', controllers: {
 sessions: 'api/v1/sessions'
}

ルーティングで個別に作成するsessions_contollerを使えるように記述。
※この記述がないと、devise_token_authに組み込まれたdestroyメソッドを使うことになります。

sessions_controller.rb
module Api
  module V1
    class SessionsController < DeviseTokenAuth::SessionsController
      def destroy
        client_id = request.headers['client']
        uid = request.headers['uid']
        request.headers['access-token']

        # 論理削除されたユーザーも含むようにwith_discardedを加える
        @user = User.with_discarded.find_by(uid:)

        # トークンはclien_idごとに管理されているので、client_idをキーとしてトークンを探す
        if @user && @user.tokens[client_id]

          # deleteすることでトークン情報のみを全て削除できる
          @user.tokens.delete(client_id)

          # トークン情報を削除した状態で保存
          @user.save!
          render json: { status: 'SUCCESS', message: 'have logged out successfully' }
        else
          render json: { status: 'ERROR', message: 'User not found' }
        end
      end
    end
  end
end

destroyメソッドの中でインスタンス変数@userに対象のユーザーを格納していますが、with_discardedを使うことで論理削除されたユーザーも含んだユーザー群から検索するように実装しています。

WithdrawalConfirmModal.jsx
  // 退会後にサインアウトしてローカルストレージを空にする
  const handleDiscardAccount = async () => {
    // 退会処理としてユーザーを論理削除する
    const response1 = await axiosInstance.delete("/users");
    console.log(response1.data);
    close();
    // 論理削除したユーザーをサインアウトさせる
    const response2 = await axiosInstance.delete("/auth/sign_out");
    console.log(response2.data);
    localStorage.clear();
    navigate("/");
  };

実際に退会処理する場合、ユーザーを論理削除して終わりではなく、サインアウトまで連続して行うように実装しました。実サービスで退会したユーザーが残ってサービスを利用するは好ましくないので、サインアウトまで完了させるようにしています。

以上がユーザー退会機能の実装内容となります。
最後まで読んで頂きありがとうございました。

参考にしたサイト

discard
devise-token-authを用いたログイン・ログアウト機能の作成②
Rails で Devise と論理削除を両立する方法
Railsで論理削除(soft delete)を実装する(discard gem利用)
railsで退会処理を実装する際の注意(discard gem採用

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?