Laravel で2者間の「同意署名」機能をつくる

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、個人サイトを運営していることもあってか少し前からYouTubeでもビジネスに関連する動画をみるようになりました。

そしてそんな中、とある動画の中でテーマになっていた機能で「面白そう❗ぜひこの機能をつくってみたい😊」と思うものがありました。

それは・・・・・・

2者間の「同意署名」機能 

です。

これは、ウェブサイトやアプリを使って「AさんとBさんが同意をした」という証拠が残るようにするという機能で、動画の中ではカップルたちの夜の営みに関するものがテーマとなっていました。(そうです。🐯のYouTubeです!)

でも、そういったケースだけでなく手軽に誰かと同意できて、それが証拠になるのは便利だなと思ったんですね。

そこで❗

今回はこの2者間の「同意署名」機能をLaravel + JavaScriptで実装してみることにしました。

ぜひ何かの参考になりましたら嬉しいです。😊✨

「ハミガキ粉って
もう無くなるな…
からが長いですよね 🤔」

開発環境: Laravel 10.x、PHP 8.2

前提として

今回ご紹介する機能は、基本的な部分のみ作成しているため、例えばセキュリティなどは一切考慮していません。

そのため、(というかこのブログ記事すべてに共通していますが)特にそのまま使用するためのものではないことにご注意ください。m(_ _)m

DB 周りをつくる

では、はじめにデータベースに関係している部分からつくっていきましょう。
以下のコマンドを実行してください。

php artisan make:model Agreement -mc

すると、「モデル」「マイグレーション」「コントローラー」の3ファイルが作成されるので、それぞれ中身を以下のように変更してください。

app/Models/Agreement.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Agreement extends Model
{
    use HasFactory;

    protected $casts = [
        'first_signed_at' => 'datetime',
        'second_signed_at' => 'datetime',
    ];
}

ここでは、マイグレーション内でつくる2つの「署名した時間」を時間クラス(Carbon)に自動変換するようにしています。

database/migrations/****_**_**_******_create_agreements_table.php

// 省略

public function up(): void
{
    Schema::create('agreements', function (Blueprint $table) {
        $table->id();
        $table->uuid('uuid')->comment('UUID');
        $table->text('body')->comment('同意内容');
        $table->dateTime('first_signed_at')->nullable()->comment('一人目の署名日時');
        $table->dateTime('second_signed_at')->nullable()->comment('二人目の署名日時');
        $table->timestamps();
    });
}

// 省略

カラムの内容は次のとおりです。

  • uuid: 他と重複しない(かぶらない)ID。この ID ですべてのデータを識別できるようにします。
  • body: 同意する内容です。改行 OK!
  • first_signed_at: 一人目が署名した日時です。モデルで自動変換するデータです。
  • second_signed_at: 二人目の署名日時です。

※ ちなみに、(たしか)iduuidに変更する方法があったと思うのですが、コードを書ききってから気づいたので😆、それはまた今度の機会にします。

app/Http/Controllers/AgreementController.php

<?php

namespace App\Http\Controllers;

use App\Models\Agreement;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class AgreementController extends Controller
{
    public function create()
    {
        return view('agreement.create');
    }

    public function store(Request $request)
    {
        // 注意: バリデーションは省略しています

        $uuid = Str::uuid(); // 一人目の UUID

        $agreement = new Agreement();
        $agreement->uuid = $uuid;
        $agreement->body = $request->body;
        $agreement->save();

        return to_route('agreement.sign', [
            'agreement' => $uuid,
            'user_type' => 'first',
        ]);
    }

    public function sign(Request $request, Agreement $agreement)
    {
        $user_type = $request->user_type; // first or second

        return view('agreement.sign')->with([
            'agreement' => $agreement,
            'user_type' => $user_type,
        ]);
    }

    public function update(Request $request, Agreement $agreement)
    {
        // 注意: バリデーションは省略しています

        // 署名日時を保存
        $user_type = $request->user_type; // first or second

        if($user_type === 'first') {

            $agreement->first_signed_at = now();

        } else {

            $agreement->second_signed_at = now();

        }

        // 画像を保存
        $signature_data = $request->signature_data;
        list(, $signature_data) = explode(';', $signature_data);
        list(, $signature_data) = explode(',', $signature_data);
        $decoded_signature_data = base64_decode($signature_data);

        $image_path = storage_path('app/public/signatures/'. $agreement->uuid .'_'. $user_type .'.png');
        file_put_contents($image_path, $decoded_signature_data);

        $result = $agreement->save();

        return ['result' => $result];
    }

    public function confirm(Agreement $agreement)
    {
        return view('agreement.confirm')->with([
            'agreement' => $agreement,
        ]);
    }
}

ちなみに、コードとしてはそれほど複雑な部分はないかと思いますが、流れがわかりにくいかもしれませんので、手順がどうなるかをご紹介します。

  1. 「何に同意するのか」の内容を送信する
  2. DB テーブルにデータが追加され、署名ページへリダイレクト
  3. (双方が)それぞれ署名をして送信
  4. 2つの署名画像が保存される
  5. 確認ページへのリンク表示
  6. 移動すると同意内容と画像2つが表示される

忘れてはいけないこと

先ほどのコントローラーと関連してくるんですが、画像を保存するフォルダをつくり、それをサイトで公開できるようにしておく必要があります。

そのため、まずは/storage/app/public/signaturesフォルダをつくり、以下のコマンドを実行してください。

php artisan storage:link

すると、public/storageというシンボリックリンクが作成されるので、「https://*******/storage/******」でウェブ上に公開することができます。

※ ちなみに、ファルダには書き込み権限を与えておいてください。

ビューをつくる

今回必要なビューは以下3つです。

  • 最初に同意内容を送信するビュー
  • 署名をするビュー
  • 署名した内容を確認するビュー

ということで以下のコマンド3つを実行してください。

php artisan make:view agreement.create
php artisan make:view agreement.sign
php artisan make:view agreement.confirm

すると、それぞれファイルが作成されるので、中身を次のようにします。(今回はシンプルさを求めて、すべてCDNからパッケージを呼び出しています👍)

resources/views/agreement/create.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>同意内容の送信フォーム</title>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.16/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-200 h-screen flex justify-center items-center">
<form method="POST" action="{{ route('agreement.store') }}" class="w-full max-w-xs mx-auto">
    @csrf
    <div class="bg-white shadow-md rounded px-4 py-4 mb-4">
        <div class="mb-4">
            <label class="block text-gray-700 text-sm font-bold mb-2">
                同意する内容
            </label>
            <textarea
                class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
                name="body"
                rows="7"></textarea>
        </div>
        <div class="flex items-center justify-between">
            <button class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
                    type="submit">
                送信する
            </button>
        </div>
    </div>
</form>
</body>
</html>

これは同意する内容を送信するためのフォームで、テキストエリアが一つだけある、とてもシンプルな内容になっています。

resources/views/agreement/sign.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>同意の署名</title>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.16/dist/tailwind.min.css" rel="stylesheet">
    <script src="https://cdn.jsdelivr.net/npm/vue@3.2.20/dist/vue.global.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/signature_pad@4.0.0/dist/signature_pad.umd.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/1.5.1/axios.min.js"></script>
</head>
<body class="bg-gray-200 h-screen flex flex-col justify-center items-center">
<div id="app">
    <div class="mb-4 bg-white p-4 text-sm" v-if="isFirstUser">
        <div class="mb-2">
            同意して欲しい人にQRコードを読み取ってもらってください
        </div>
        <div id="qrcode" class="flex justify-center"></div>
    </div>
    <div class="bg-white shadow-md rounded px-4 py-4 mb-4 text-sm">
        同意内容
        <div class="whitespace-pre mb-4 max-h-32 overflow-auto text-xs">{{ $agreement->body }}</div>
    </div>
    <div class="bg-white shadow-md rounded px-4 py-4 mb-4 text-sm">
        以下に署名してください
        <canvas id="canvas" height="150" class="border rounded my-1 w-full"></canvas>
        <div class="text-right">
            <a href="#" @click.prevent="clearSignature" class="text-blue-500 hover:underline">クリア</a>
        </div>
    </div>
    <button @click="save" class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
        署名を保存する
    </button>
    <div class="flex justify-center pt-4">
        <a
            href="{{ route('agreement.confirm', $agreement->uuid) }}"
            class="underline"
            v-if="isCompleted">
            同意内容を確認する
        </a>
    </div>
</div>

<script>
    const app = Vue.createApp({
        data() {

            return {
                uuid: '{{ $agreement->uuid }}',
                userType: '{{ $user_type }}',
                signaturePad: null,
                isCompleted: false,
            };

        },
        methods: {
            showQrcode() {

                const el = document.getElementById('qrcode');
                const url = '{{ route('agreement.sign', [$agreement->uuid, 'user_type' => 'second']) }}';

                new QRCode(el, {
                    text: url,
                    width: 150,
                    height: 150,
                    colorDark : "#000000",
                    colorLight : "#ffffff",
                    correctLevel : QRCode.CorrectLevel.H
                });

            },
            showSignature() {

                const canvas = document.getElementById('canvas');
                canvas.width = canvas.offsetWidth;
                canvas.height = canvas.offsetHeight;
                this.signaturePad = new SignaturePad(canvas);

            },
            clearSignature() {

                this.signaturePad.clear();

                // 本来はここですでに保存された画像を削除する処理を行うべきですが、今回は省略します

            },
            save() {

                if (this.signaturePad.isEmpty()) {

                    alert('署名をしてください');

                } else {

                    const dataURL = this.signaturePad.toDataURL();
                    const url = '{{ route('agreement.update', $agreement->uuid) }}';
                    const params = {
                        _method: 'PUT',
                        uuid: this.uuid,
                        user_type: this.userType,
                        signature_data: dataURL,
                    };

                    axios.post(url, params)
                        .then(response => {

                            if(response.data.result === true) {

                                this.isCompleted = true;

                            }

                        });

                }
            }
        },
        computed: {
            isFirstUser() {

                return this.userType === 'first';

            }
        },
        mounted() {

            // 署名
            this.showSignature();

            // QRコード
            this.showQrcode();

        }
    });

    app.mount('#app');
</script>
</body>
</html>

ここが今回のメインになる「署名」の部分です。

署名は、signature_padというパッケージを使っていて、実際の筆跡のように線を書くことができます。

そして、このパッケージから取得した筆跡データ(キャンバスから取得したdataURL)をAjaxで送信して、それをPHPLaravel)側で保存しているだけです。

resources/views/agreement/confirm.blade.php

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>同意内容の確認</title>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.16/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-200 h-screen flex justify-center items-center">
<form method="POST" action="{{ route('agreement.store') }}" class="w-full max-w-xs mx-auto">
    @csrf
    <div class="bg-white shadow-md rounded px-4 py-4 mb-4">
        <div class="mb-4">
            <label class="block text-gray-700 text-sm font-bold mb-2" for="message">
                同意内容
            </label>
            <div class="whitespace-pre mb-4 max-h-32 overflow-auto text-xs bg-gray-200 p-2 rounded">{{ $agreement->body }}</div>
            <label class="block text-gray-700 text-sm font-bold mb-2" for="message">
                署名
            </label>
            @foreach(['first', 'second'] as $user_type)
                @if(! is_null($agreement->{$user_type . '_signed_at'}))
                    <img src="/storage/signatures/{{ $agreement->uuid }}_{{ $user_type }}.png" alt="署名" class="border rounded my-1 w-full">
                    <div class="text-right text-xs mb-2">
                        {{ $agreement->{$user_type . '_signed_at'}->format('Y年m月d日 H時i分') }}
                    </div>
                @endif
            @endforeach
            <div class="flex justify-center mt-5">
                <a href="" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded w-full text-center">
                    再読込する
                </a>
            </div>
        </div>
    </div>
</form>
</body>
</html>

ここでは、保存されたagreementのデータを表示しているだけで特別なことはしていません。

これで作業は完了です!
お疲れ様でした😊👍

テストしてみる

では、実際にテストしてみましょう。
まず、「https://********/agreement/create」にブラウザでアクセスします。

はい!
フォームが表示されました。

では、このフォームに以下のように入力して送信してみましょう。

すると、以下のように署名ページに移動しました。

では、まずはもう一方の人がQRコードを読み取ってページ移動します。

すると・・・・・・

はい❗
同意内容が表示されて同じく署名するエリアが表示されました。

ということで、まずは片方の人で署名をしてみましょう。

送信します。

どうなるでしょうか・・・・・・

はい❗
署名の送信が完了し、確認ページへのリンクが表示されました。

成功です😊✨

では、もう一方も署名&送信してみましょう。

こちらもうまく保存ができました。

では、最後に確認ページがどうなるか見てみましょう。

うまくいくでしょうか・・・・・・

はい❗
同意内容と署名が2つ表示されました。

これで2人の同意が完了したことになります。
めでたしめでたし✨😊👍

企業様へのご提案

今回のようにいろいろなパッケージを組み合わせることによって、「同意機能」だけでなく様々な機能を作成することができます。

なお、今回はテストなので省略していますが、本番環境では以下のような機能も実装することも可能です。

  • リアルタイムで同意完了したら自動でページ移動(Pusherなどを使う)
  • ログイン機能をつけてよりセキュアなページをつくる
  • 同意内容を暗号化して保存する
  • 確認ページの URL を忘れないようにメールや LINE に送信する
  • 同意した内容を後から破棄、変更する機能

などなど

もしこういった内容をご希望でしたら、また、一緒に個人開発でサービスを構築したい方はぜひお問い合わせからお気軽にお問い合わせください。

お待ちしております。😊✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はLaravelで同意署名機能をつくってみました。

後から問題になるよりは、少しめんどうでも先に同意署名しておくべきですが、書類を用意するのは何かとめんどうです。

それでも、今回のような(シンプルバージョンとは言え)同意署名がないよりはましだと思うので、実装のしかたによってはとても便利なサービスになるかもしれませんね。(また時間ができたら、ホントに個人サービスでやってみようかな🤔)

ぜひみなさんも、今回のようにいろんなパッケージを組み合わせて面白そうなものをつくってみてくださいね。

ではでは〜❗

「どうしても真三國無双の
立花誾千代で
激難がクリアできない・・・😫」

このエントリーをはてなブックマークに追加       follow us in feedly