この記事では、自分の学習ログも兼ねて「デジタル名刺アプリ」を作ったときの流れを書きます。
GitHub Actions のクーロンジョブで毎朝6時にデータを削除するバッチ も実装したので、そのあたりも紹介します。
完成イメージ
- 自分で決めた ID を入力すると名刺ページを表示できる
- 名刺の内容は Supabase の DB に保存される
- 登録したユーザー情報は翌朝 6 時に自動削除される
使った技術
- フロントエンド: Vite + React (TypeScript)
- バックエンド: Supabase(PostgreSQL/認証)
- デプロイ: Firebase Hosting
- バリデーション: React Hook Form
- テスト: Jest, React Testing Library
- CI/CD: GitHub Actions
- パッケージ管理: npm
- その他: Makefile(手動デプロイ補助)
1. Supabase にテーブルを作成
まず Supabase の DB に以下のテーブルを作りました。
users テーブル
Name | Type | Default Value |
---|---|---|
id |
varchar | NULL |
name |
varchar | NULL |
description |
text | NULL |
github_id |
varchar | NULL |
qiita_id |
varchar | NULL |
x_id |
varchar | NULL |
created_at |
timestamptz | now() |
user_skill テーブル
ユーザー ID とスキル ID を結ぶ中間テーブル
Name | Type | Default Value |
---|---|---|
id |
varchar | NULL |
user_id |
varchar | NULL |
skill_id |
int8 | NULL |
created_at |
timestamptz | now() |
skills テーブル
プログラミングの技術を保存しておくテーブル
Name | Type | Default Value |
---|---|---|
id |
int8 | NULL |
name |
varchar | NULL |
created_at |
timestamptz | now() |
2. 名刺登録ページを作る
React + Chakra UI でフォームを作ります。
React Hook Form を使って必須項目のバリデーションも追加しました。
<FormControl isInvalid={!!errors.userId}>
<FormLabel>ID(この ID で名刺ページにアクセスできます)</FormLabel>
<Input
placeholder="半角英数字・ハイフン・アンダースコアが使用可"
_placeholder={{ fontSize: "sm" }}
{...register("userId", {
required: "IDは必須です",
pattern: {
value: /^[A-Za-z0-9_-]+$/,
message:
"ID は半角英数字・ハイフン・アンダースコアのみ使用できます",
},
minLength: { value: 3, message: "3文字以上で入力してください" },
maxLength: { value: 32, message: "32文字以内で入力してください" },
})}
/>
<FormErrorMessage>{errors.userId?.message}</FormErrorMessage>
</FormControl>
- ID は半角英数字とハイフン、アンダースコア
- 名前は必須
- 技術は最低 1 つ選ばないとエラー
登録するとusers
とuser_skill
にデータを保存します。
登録成功後は/cards/:id
に遷移して名刺ページを表示します。
3. 名刺ページを作る
ユーザー情報とスキルを取得して、カードっぽく表示します。
{/* 好きな技術 */}
{user?.skills?.length ? (
<Box>
<Heading size="sm" mb={2} color="brand.500">
好きな技術
</Heading>
<Divider mb={3} borderColor="brand.200" />
<HStack wrap="wrap" spacing={2}>
{user.skills.map((s) => (
<Tag key={s.id} size="sm" variant="soft" px={3} py={1}>
{s.name}
</Tag>
))}
</HStack>
</Box>
) : null}
{/* Links */}
{(user?.github_id || user?.qiita_id || user?.x_id) && (
<Box>
<Heading size="sm" mb={1} color="brand.500">
Links
</Heading>
<Divider mb={2} borderColor="brand.200" />
<HStack spacing={4}>
{user.github_id && (
<Tooltip label="GitHub">
<IconButton
as="a"
href={user.github_id}
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
icon={<FaGithub />}
variant="ghost"
fontSize="24px"
/>
</Tooltip>
)}
{user.qiita_id && (
<Tooltip label="Qiita">
<IconButton
as="a"
href={user.qiita_id}
target="_blank"
rel="noopener noreferrer"
aria-label="Qiita"
icon={<SiQiita />}
variant="ghost"
fontSize="24px"
/>
</Tooltip>
)}
{user.x_id && (
<Tooltip label="X (Twitter)">
<IconButton
as="a"
href={user.x_id}
target="_blank"
rel="noopener noreferrer"
aria-label="X (Twitter)"
icon={<FaXTwitter />}
variant="ghost"
fontSize="24px"
/>
</Tooltip>
)}
</HStack>
</Box>
)}
SNS(GitHub / Qiita / X)はアイコンリンクを横並びで表示するようにしました。
登録してない場合は非表示になります。
Home ページを作る
トップページから ID を入力して名刺ページに遷移できるようにしました。
-
ID が未入力 -> 「IDを入力してください」とエラー
-
存在しない ID ->「このIDの名刺は存在しません」とエラー
-
「名刺を登録する」リンクから /cards/register に遷移できる
5. テストを書く
安心してデプロイできるようにページごとにテストを用意しました。
-
CardDetailPage.test.tsx
名前・自己紹介・スキル・SNSアイコンが表示されるか -
CardRegisterPage.test.tsx
必須項目のバリデーション
登録成功後に /cards/:id に遷移するか -
HomePage.test.tsx
ID未入力や存在しないIDでエラーが出るか
「名刺を登録する」リンクで遷移できるか
Supabase はモック化して本番のDBに影響しないようにしました。
6. 毎朝6時にデータを削除するバッチ処理
batch/index.ts
を作成
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
async function main() {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
console.info(
`[INFO] Deleting records created between ${yesterday.toISOString()} ~ ${now.toISOString()}`
);
const { data: users, error } = await supabase
.from("users")
.select("id")
.gte("created_at", yesterday.toISOString())
.lte("created_at", now.toISOString());
if (error) {
console.error("[ERROR] users select:", error);
return;
}
if (!users?.length) {
console.info("[INFO] Target users: 0");
return;
}
const ids = users.map((u) => u.id);
await supabase.from("user_skill").delete().in("user_id", ids);
await supabase.from("users").delete().in("id", ids);
console.log("[SUCCESS] Cleanup completed.");
}
main();
ここではservice_role key
を使って削除しています。
- GitHub Actions (
cleanup.yml
)
name: Cleanup Batch
on:
schedule:
- cron: "0 21 * * *" # JST 6時 = UTC 21時
workflow_dispatch:
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run cleanup batch
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
TZ: Asia/Tokyo
TS_NODE_PROJECT: tsconfig.batch.json
TS_NODE_COMPILER_OPTIONS: '{"module":"NodeNext","moduleResolution":"NodeNext"}'
run: node --loader ts-node/esm ./batch/index.ts
-
cron:
"0 21 * * *"
-> UTC 21時に実行(日本時間の朝6時) -
手動実行(workflow_dispatch)も可能
-
.env の値は GitHub Secrets に設定
学んだこと
- Supabase はやっぱり使いやすい
- Chakra UI で UI を作るとレスポンシブ対応も楽
- React Hook Form のバリデーションは useState を消せるので見通しが良くなる
- GitHub Actions でバッチ処理を組むと定期実行できる
おわりに
同じように「Supabase + GitHub Actionsで定期バッチを動かしたい」という人の参考になれば嬉しいです
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼