はじめに
管理画面を簡単に作ることがメタフレームワーク Refine とFirebaseのように使えるBaaSのSupabaseを使って図書館の貸出システムを作成しました。
コードは下記にあります。
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図はこんな感じです。
ポリシー
Supabase(Postgres)には RLSという、ユーザーごとや権限ごとにどの行を見たり更新したりすることができるか、というアクセスコントロールを行う仕組みがあります。
SupabaseではこのRLSを最大限活用してマルチテナントやマルチユーザー向けのシステムを構築することができます。
今回は図書館の貸出システムということで、管理者は全てのユーザーの貸出履歴を見ることができるが、一般ユーザーは他の貸出履歴を見ることができない、というRLSを仕込みました。
DDL
ER図やポリシーを元に出来たDDLは下記の通りです。
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
こんな感じでコマンドを入れると必要なファイルやルーティングを記載してくれます。
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を使って更新させるページを作成することができます。
実際のコード
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>
);
};
出来上がったページはこんな感じです。
書籍情報の取得
書籍に付いているバーコードの
今回DB二保存しているのは書籍名程度ですが、書籍情報はバーコードリーダーを起動した後にAPIで拾って来るようにしました。
バーコードリーダーは後述しますが、スマホで起動したときに実際に書籍のバーコードを読み取って自動入力するようにしています。
ISBNからの検索サービスは下記のサービスを利用させてもらいました。知らなかったのですが大変便利ですね。
その他、リソース(ER図のモデル)を整備しました。
ここまででWebブラウザ上である程度動くものが完成しました。
アプリ
Webブラウザ上で動くようにできたので、スマホを貸出端末として使うことで、バーコードでの読み出しが出来たりして実際の図書館貸出システムらしくなりました。
でもせっかくなので、NFCタグを使ってもっとスピーディーに貸出処理を行うことができないか、ということでアプリ化も行いました。
Flutter
アプリではFlutter(Dart)を使ってiOS/Androidの両方に対応するハイブリッドアプリとしました。
基本的なCRUD機能はWebで提供されていたため、そうった機能はWebViewとして取り込み、NFCタグの読み込み部分のみを担わせることにしました。
NFCタグの読み込み
上記を踏まえたコードとしては、主に下記の 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の証明書も付きますのでホスティングが大変簡単です。