Reactの管理画面系メタフレームワーク Refine と FirebaseライクなBaaS　Supabaseを使って図書館の貸し出しシステムっぽいものを作る

# はじめに
管理画面を簡単に作ることがメタフレームワーク Refine とFirebaseのように使えるBaaSのSupabaseを使って図書館の貸出システムを作成しました。


https://bookmanager.kater.jp/


コードは下記にあります。

https://github.com/kater-iam/book-manager


## Refine
Refineは、CRUDが多いWebアプリケーションを開発するためのReactメタフレームワークです。内部ツール、管理パネル、ダッシュボード、B2Bアプリケーションなどの業務システムの構築に向いています。

- 主な機能
Reactの「メタフレームワーク」というだけあって、認証、アクセス制御、ルーティング、ネットワーキング、状態管理、国際化（i18n）などのシステム構築、特に管理画面の構築必要になってくる基本機能を提供しています。
ビジネスロジックがルーティングから分離されています。またUIフレームワークとしてTailwindCSSのようなカスタムデザインやUIフレームワーク、Ant Design、Material UI、Mantine、Chakra UIなど、開発者が好きなUIフレームワークを選ぶことができます。
Reactのメタフレームワークということは変わりませんが、Next.js、Remix、React Native、Electronのプラットフォームを選択することが出来ます。

自分は基本的にはバックエンドエンジニアで、Refineを知ったのは数ヶ月だったのですが非常に興味があり、これをつくってみたい！　ということで採用しました。


# 要件
まずは要件の整理です。Refineを使ってみたい、ということで着手をしようとしたのですが肝心の何を作るかということが決まっていませんでした。日々の中に何かヒントがないか、と思って探して、あることが思い当たりました。
それは自分が図書館に行ったとき、地方の小さな図書館だったのですが、いわゆる最近の図書館システムが導入されていなくて、書籍情報の入力とかが大変そうでした。
そのいわゆる「最近の図書館システム」と言えば、書籍のバーコードや電子タグを機器で読み取り、会員証をかざしたりして貸出の管理を行う、というものです。それがなかったんです。
他にも子どもを通わせている保育園で、週に一度、子どもが保育園から絵本を借りてくるんです。保育園である程度園児がいるとは言え、やはりどうしても小規模なため、手書きでその貸出管理を行っていました。
そういった現場を支えるようなシステムを作ることが出来ないか、ということで作成してみました。


# 設計と構築
まずは要件を元にモデル設計を行いました。

## ER図
今回は図書館システムということで、最低限の「書籍」と「貸出」を作成し、ユーザーの権限設定用の「プロファイル」を作成しました。
適当に関係を結んでER図はこんな感じです。

![Screenshot 2024-05-19 at 22.28.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47327/14705690-d1b7-b084-6a8a-51f2cd5a3a6e.png)

## ポリシー
Supabase（Postgres）には RLSという、ユーザーごとや権限ごとにどの行を見たり更新したりすることができるか、というアクセスコントロールを行う仕組みがあります。
SupabaseではこのRLSを最大限活用してマルチテナントやマルチユーザー向けのシステムを構築することができます。
今回は図書館の貸出システムということで、管理者は全てのユーザーの貸出履歴を見ることができるが、一般ユーザーは他の貸出履歴を見ることができない、というRLSを仕込みました。


## DDL

ER図やポリシーを元に出来たDDLは下記の通りです。

```SQL

create table public.books
(
    id         bigint generated by default as identity
        primary key,
    name       text,
    updated_at timestamp                default now(),
    created_at timestamp with time zone default now() not null,
    user_id    uuid                     default auth.uid(),
    isbn       text                                   not null,
    nfc_id     text
        unique
);

alter table public.books
    owner to postgres;

grant select, update, usage on sequence public.books_id_seq to anon;

grant select, update, usage on sequence public.books_id_seq to authenticated;

grant select, update, usage on sequence public.books_id_seq to service_role;

create policy "Enable read access for all users" on public.books
    as permissive
    for select
    using true;

create policy "Enable insert for authenticated users only" on public.books
    as permissive
    for insert
    to authenticated
    with check true;

create policy "Enable delete for users based on user_id" on public.books
    as permissive
    for delete
    to authenticated
    using true;

create policy "Enable update for authenticated users only" on public.books
    as permissive
    for all
    to authenticated
    with check true;

grant delete, insert, references, select, trigger, truncate, update on public.books to anon;

grant delete, insert, references, select, trigger, truncate, update on public.books to authenticated;

grant delete, insert, references, select, trigger, truncate, update on public.books to service_role;


create table public.lendings
(
    id             bigint generated by default as identity
        primary key,
    book_id        bigint
        references public.books
            on update cascade,
    borrow_user_id uuid                     default auth.uid(),
    created_at     timestamp with time zone default now() not null,
    returned_at    timestamp
);

alter table public.lendings
    owner to postgres;

grant select, update, usage on sequence public.lendings_id_seq to anon;

grant select, update, usage on sequence public.lendings_id_seq to authenticated;

grant select, update, usage on sequence public.lendings_id_seq to service_role;

create policy "A user can select for their own lendings" on public.lendings
    as permissive
    for select
    to authenticated
    using ((SELECT auth.uid() AS uid) = borrow_user_id);

create policy "All users can insert lendings" on public.lendings
    as permissive
    for insert
    to authenticated
    with check true;

create policy "Enable Update for authenticated users only" on public.lendings
    as permissive
    for update
    to authenticated
    using true;

create policy "Admin can do anything(select/update/delete)" on public.lendings
    as permissive
    for all
    to authenticated
    using ((SELECT profiles.role
            FROM profiles
            WHERE (profiles.id = auth.uid())) = 'admin'::text);

grant delete, insert, references, select, trigger, truncate, update on public.lendings to anon;

grant delete, insert, references, select, trigger, truncate, update on public.lendings to authenticated;

grant delete, insert, references, select, trigger, truncate, update on public.lendings to service_role;

create table public.profiles
(
    id         uuid                         not null
        primary key
        references ??? ()
        on delete cascade,
    first_name text,
    last_name  text,
    role       text default 'geleral'::text not null,
    age        bigint
);

alter table public.profiles
    owner to postgres;

create policy "Enable read access for all users" on public.profiles
    as permissive
    for select
    to authenticated
    using true;

grant delete, insert, references, select, trigger, truncate, update on public.profiles to anon;

grant delete, insert, references, select, trigger, truncate, update on public.profiles to authenticated;

grant delete, insert, references, select, trigger, truncate, update on public.profiles to service_role;

```

# フロント
Refineを使ってフロントを構築していきます。

Refineでは既存のモデルを元にCRUDの画面を生成する仕組みがあります。これが非常強力かつ便利で、業務システムの基本となる仕組みを一発で生成することができます。

コマンドは `refine create-resource` です。


```
refine create-resource
(Use `node --trace-deprecation ...` to show where the warning was created)
? Resource Name (users, products, orders etc.) books
? Select Actions (Press <space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
❯◉ list
 ◉ create
 ◉ edit
 ◉ show
 ```

 こんな感じでコマンドを入れると必要なファイルやルーティングを記載してくれます。

 ```tsx:App.tsc
 import { Authenticated, Refine, I18nProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { AuthPage, ThemedLayoutV2 } from "@refinedev/antd";
import { BooksCreate, BooksEdit, BooksList, BooksShow } from "./pages/books";
import { LendingsCreate, LendingsEdit, LendingsList, LendingsShow } from "./pages/lendings";

(抜粋)

          <AppMode>
            <Refine
              dataProvider={dataProvider(supabaseClient)}
              liveProvider={liveProvider(supabaseClient)}
              authProvider={authProvider}
              routerProvider={routerBindings}
              i18nProvider={i18nProvider}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                useNewQueryKeys: true,
                projectId: "45VCnM-1kez3C-I7s8dD",
                liveMode: "auto",
              }}
              resources={[{
                name: "books",
                list: "/books",
                create: "/books/create",
                edit: "/books/edit/:id",
                show: "/books/show/:id",
              }, {
                name: "lendings",
                list: "/lendings",
                create: "/lendings/create",
                edit: "/lendings/edit/:id",
                show: "/lendings/show/:id"
              }]}>
              <Routes>
```

CRUD系も下記のように、Hookを使って更新させるページを作成することができます。

<details>
<summary> 実際のコード </summary>

```tsx:pages/books/edit.tsx

import React, { useEffect } from "react";
import { DeleteButton, Edit, useForm } from "@refinedev/antd";
import { Form, Input, DatePicker } from "antd";
import { useTranslate } from "@refinedev/core";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useNavigate } from "react-router-dom";

dayjs.extend(utc);
dayjs.extend(timezone);

export const BooksEdit = () => {
    const translate = useTranslate();
    const { formProps, saveButtonProps, queryResult } = useForm();
    const booksData = queryResult?.data?.data;
    const navigate = useNavigate()

    useEffect(() => {
        formProps?.form?.setFieldsValue({ updated_at: dayjs().tz('Asia/Tokyo') })
    }, [formProps?.form])

    return (
        <Edit saveButtonProps={saveButtonProps} footerButtons={({ defaultButtons, deleteButtonProps }) => {
            return <>
                {defaultButtons}
                <DeleteButton {...deleteButtonProps} onSuccess={() => navigate("/books")} />
            </>
        }}>
            <Form {...formProps} layout="vertical">
                <Form.Item
                    label={translate("books.fields.id")}
                    name={["id"]}
                    rules={[
                        {
                            required: true,
                        },
                    ]}
                >
                    <Input readOnly disabled />
                </Form.Item>
                <Form.Item
                    label={translate("books.fields.name")}
                    name={["name"]}
                    rules={[
                        {
                            required: true,
                        },
                    ]}
                >
                    <Input />
                </Form.Item>
                <Form.Item
                    label={translate("books.fields.created_at")}
                    name={["created_at"]}
                    rules={[
                        {
                            required: true,
                        },
                    ]}
                    getValueProps={(value) => ({
                        value: value ? dayjs(value) : undefined,
                    })}
                >
                    <DatePicker />
                </Form.Item>
                <Form.Item
                    label={translate("books.fields.updated_at")}
                    name={["updated_at"]}
                    hidden
                    rules={[
                        {
                            required: true,
                        },
                    ]}
                    getValueProps={(value) => ({
                        value: dayjs().tz('Asia/Tokyo')
                    })}
                >
                    <DatePicker />
                </Form.Item>
                <Form.Item
                    label={translate("books.fields.isbn")}
                    name={["isbn"]}
                    rules={[
                        {
                            required: true,
                        },
                    ]}
                >
                    <Input />
                </Form.Item>
                <Form.Item
                    label={translate("books.fields.nfc_id")}
                    name={["nfc_id"]}
                >
                    <Input />
                </Form.Item>
            </Form>
        </Edit>
    );
};
```

</details>

出来上がったページはこんな感じです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47327/bdf6e1c9-d8ba-315b-a44c-1bdd6dd45446.png)


## 書籍情報の取得
書籍に付いているバーコードの
今回DB二保存しているのは書籍名程度ですが、書籍情報はバーコードリーダーを起動した後にAPIで拾って来るようにしました。
バーコードリーダーは後述しますが、スマホで起動したときに実際に書籍のバーコードを読み取って自動入力するようにしています。

ISBNからの検索サービスは下記のサービスを利用させてもらいました。知らなかったのですが大変便利ですね。

https://openbd.jp/

その他、リソース（ER図のモデル）を整備しました。

ここまででWebブラウザ上である程度動くものが完成しました。

# アプリ
Webブラウザ上で動くようにできたので、スマホを貸出端末として使うことで、バーコードでの読み出しが出来たりして実際の図書館貸出システムらしくなりました。

でもせっかくなので、NFCタグを使ってもっとスピーディーに貸出処理を行うことができないか、ということでアプリ化も行いました。

## Flutter
アプリではFlutter（Dart）を使ってiOS/Androidの両方に対応するハイブリッドアプリとしました。

基本的なCRUD機能はWebで提供されていたため、そうった機能はWebViewとして取り込み、NFCタグの読み込み部分のみを担わせることにしました。

## NFCタグの読み込み

上記を踏まえたコードとしては、主に下記の main.dart となりました。

```dart:lib/main.dart
（抜粋）

import 'package:flutter_nfc_kit/flutter_nfc_kit.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:webview_flutter/webview_flutter.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const WebViewApp());
}

class WebViewApp extends ConsumerStatefulWidget {
  const WebViewApp({super.key});

  @override
  WebViewAppState createState() => WebViewAppState();
}

class WebViewAppState extends ConsumerState<WebViewApp> {
  late WebViewController _controller;

  @override
  void initState() {
    super.initState();
    _controller = WebViewManager('https://main.dvxdnhk01b6k.amplifyapp.com/?app_mode=true').controller;

    _controller.addJavaScriptChannel(
      'BarcodeReader',
      onMessageReceived: (JavaScriptMessage message) async {
        final ScanResult result = await BarcodeScanner.scan();
        final String isbn = result.rawContent;
        _controller.runJavaScript("receiveBarcode('${isbn}');");
      },
    );

    _controller.addJavaScriptChannel(
      'NFCReader',
      onMessageReceived: (JavaScriptMessage message) async {
        NFCTag serialNumber = await FlutterNfcKit.poll(
          timeout: const Duration(seconds: 10),
          iosAlertMessage: "NFCタグを近づけてください",
        );
        await FlutterNfcKit.finish();
        _controller.runJavaScript("receiveNfcId('${serialNumber.id}');");
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        resizeToAvoidBottomInset: false,
        appBar: AppBar(
          title: SizedBox(
            width: 30,
            height: 30,
            child: Image.asset('assets/logo.png'),
          ),
        ),
        body: WebViewWidget(
          controller: _controller,
        ),
      ),
    );
  }
}
```

上記の `_controller.addJavaScriptChannel()` の部分でNFCタグの読み込みを行い、読み取ったNFCタグIDをフォームに入力するようになっています。
これで読み取り機能が完成です。

# その他
WebのホスティングはAWS のAmplifyで行いました。CI/CDを組み込んで手軽にビルドができ、SSLの証明書も付きますのでホスティングが大変簡単です。