はじめに
個人経営の遊漁船オーナー向けに、LINEで予約を受け付けてGoogleカレンダーと自動連携するシステムを1から設計・実装しました。「電話予約が主流な業界をDX化したい」というモチベーションです。
完成した機能は以下のとおりです。
- 顧客はLINEの中だけで予約申請・変更・キャンセルが完結する(LIFF)
- オーナーはブラウザの管理画面から予約を確認・承認・拒否できる
- 承認するとGoogleカレンダーに自動で予約イベントが作られる
- 乗船名簿(氏名・住所・生年月日・緊急連絡先)をデジタルで管理できる
- 月単位・日単位で営業日を設定できる
- 承認済み顧客への一斉LINEメッセージ配信ができる
- 新規予約・変更・キャンセル時にオーナーへGmail通知が届く
こんな感じのができました。
技術スタックはこちらです。
| 分類 | 技術 |
|---|---|
| フロントエンド・API | Next.js 15(TypeScript)+ Cloudflare Pages |
| データベース | Cloudflare D1(SQLite) |
| セッション管理 | Cloudflare KV + jose JWT |
| 顧客向けUI | LINE LIFF |
| 外部連携 | LINE Messaging API、Google Calendar API v3、Gmail API |
本記事では、実装過程で「これは詰まった」「ここは工夫が必要だった」という点を洗いざらい書き残します。
- 詰まりポイント1: Edge Runtimeでは「当たり前のパッケージ」が動かない
- 詰まりポイント2: MongoDBからCloudflare D1(SQLite)への途中移行
- 詰まりポイント3: CloudflareバインディングへのTypeScriptからのアクセス
- 詰まりポイント4: Google Calendar認証をサービスアカウントに移行
- 設計の工夫1: 承認ロジックを4フェーズに明示分解
- 設計の工夫2: 「1日1プラン」をDB制約で担保
- 設計の工夫3: 個人情報は承認後に即null化
- 設計の工夫4: Googleカレンダーの「1日1イベント」管理
- 設計の工夫5: 自動承認機能
- 詰まりポイント5: LINEフリープランの通数超過
- 詰まりポイント6: Gmail通知機能の実装(googleapis 使えない問題)
- 設計の工夫6: 営業日設定UI
- 設計の工夫7: LINEユーザーIDの取得問題
- まとめ: Cloudflare + Next.js + LINEで学んだこと
詰まりポイント1: Edge Runtimeでは「当たり前のパッケージ」が動かない
Cloudflare PagesはEdge Runtimeで動きます。V8 isolatesと呼ばれる軽量な実行環境であり、Node.jsではありません。つまり fs・Buffer・Node.js標準のcrypto等が一切使えません。
最初にはまったのが、JWTライブラリの選定です。定番の jsonwebtoken はNode.jsのcrypto依存があるため、ビルド時にエラーになります。
Error: [unenv] crypto.createSign is not implemented
解決策: jose ライブラリに移行。 これはEdge RuntimeのWeb Crypto APIに対応したJWT実装です。
// NG: Node.js依存(Edge Runtimeで動かない)
import jwt from 'jsonwebtoken'
const token = jwt.sign(payload, secret)
// OK: Web Crypto対応のjose
import { SignJWT, jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(secret)
同様にGoogleの公式SDK googleapis もNode.js依存があって使えません。Google Calendar APIとGmail APIは、fetch でREST APIを直接叩く実装に切り替えました。一見大変そうですが、APIドキュメントが充実しているため実装自体はそれほど難しくありません。
詰まりポイント2: MongoDBからCloudflare D1(SQLite)への途中移行
最初のスキーマ設計はMongoDBで行い、実装中盤でCloudflare D1(SQLite)に移行しました。Edge RuntimeではMongoDB SRVのDNS解決がうまく動かず、接続が不安定だったのが直接の理由です。
移行作業で大変だったのは、主に2点です。
boolean型の変換
SQLiteにはBOOLEAN型がありません。0/1 の整数で扱います。TypeScriptアプリ層では boolean で扱いたいため、変換ヘルパーを専用ファイルに集約しました。
// src/lib/d1-helpers.ts
// D1から取得した値 → TypeScript boolean
export function toBooleanFromD1(value: number | null | undefined): boolean {
return value === 1
}
// TypeScript boolean → D1に保存する値
export function toD1Boolean(value: boolean): number {
return value ? 1 : 0
}
snake_case ↔ camelCaseの変換
SQLiteはフィールド名を snake_case で持つ慣習があります。TypeScriptコードは camelCase で扱いたい。この変換をrepository層で一元管理することで、ビジネスロジック層では常に camelCase のオブジェクトだけ扱えるようにしました。
// D1のrow(snake_case)からTypeScript型(camelCase)に変換
function rowToReservation(row: D1Reservation): Reservation {
return {
id: row.id,
dailyScheduleId: row.daily_schedule_id, // snake → camel
requestedPlanId: row.requested_plan_id,
lineUserId: row.line_user_id,
customerName: row.customer_name,
partySize: row.party_size,
status: row.status,
// ...
}
}
型定義も2つ用意しました。src/types/d1-database.ts(D1テーブルのsnake_case型)と src/types/database.ts(アプリ層のcamelCase型)を分けることで、TypeScriptの型チェックが変換漏れを防いでくれます。
詰まりポイント3: CloudflareバインディングへのTypeScriptからのアクセス
D1やKVには通常の環境変数(process.env)経由ではアクセスできません。Cloudflareバインディングとして取り出す必要があります。
公式ドキュメントには getRequestContext() を使う方法が書かれていますが、@cloudflare/next-on-pages のバージョンによって微妙に動きが異なります。最終的に安定した書き方はこちらです。
// KVへのアクセス(セッション管理)
const kv = (process.env as unknown as { KV: KVNamespace }).KV
// D1へのアクセス(データベース)
const db = (process.env as unknown as { DB: D1Database }).DB
as unknown as という二重キャストが若干気持ち悪いですが、これがEdge Runtimeでの安定した書き方です。
また、ローカル開発時には pnpm dev(Nextのdev server)では本物のバインディングは動きません。pnpm preview(wrangler経由)でないとD1やKVのバインディングが使えないため、開発中にAPI挙動を確認したいときは毎回 pnpm build && pnpm preview が必要でした。これが地味に時間を取られました。
詰まりポイント4: Google Calendar認証をサービスアカウントに移行
最初の設計では、オーナーがGoogleアカウントでOAuthログインし、そのアクセストークンをCloudflare KVのセッションに保存してカレンダー操作する方式でした。
しかしこれには問題がありました。
問題1: リフレッシュトークンの更新タイミング
アクセストークンの有効期限は1時間。リフレッシュトークンで更新が必要ですが、googleapis SDKが使えないため自前で実装しなければなりません。
問題2: 長期間ログインしないとカレンダー操作が失敗する
オーナーが1ヶ月ログインしていない状態で誰かが予約を申請し、承認しようとするとカレンダー同期がエラーになります。
解決策: Googleサービスアカウントに移行。 サービスアカウントはユーザーのログイン状態に依存せず、秘密鍵(PEMファイル)で署名したJWTを生成してアクセストークンを取得します。
// サービスアカウントのJWTをWeb Crypto APIで生成(Edge Runtime対応)
const privateKey = await crypto.subtle.importKey(
'pkcs8',
pemToArrayBuffer(process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign']
)
const jwt = await new SignJWT({
iss: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
scope: 'https://www.googleapis.com/auth/calendar',
aud: 'https://oauth2.googleapis.com/token',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: 'RS256' })
.sign(privateKey)
// トークンエンドポイントをfetchで直接叩く
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
}),
})
Edge RuntimeでもWeb Crypto APIは使えるため、RS256署名は crypto.subtle で実現できます。
設計の工夫1: 承認ロジックを4フェーズに明示分解
予約承認は複数テーブルの更新・外部API連携・LINE通知が絡む複雑なロジックです。当初は1つの関数に全部書いていたのですが、エラー時の挙動が読みにくく、「カレンダー同期に失敗したら予約自体がロールバックされるのか?」が不明確でした。
4つのフェーズに明示的に分割し、それぞれのフェーズのエラーハンドリング方針を決めました。
Phase 1: D1更新(定員チェック → プラン確定 → status=approved → conflict処理)
↳ ここが失敗したら例外を投げる(予約確定せず)
Phase 2: Googleカレンダー upsert(ベストエフォート)
↳ 失敗してもPhase 1はロールバックしない
Phase 3: calendarEventIdをD1に保存 + 個人情報をnullに
↳ ベストエフォート
Phase 4: LINE通知(承認者・conflict者)
↳ ベストエフォート
「ベストエフォート」設計が重要です。カレンダー同期に失敗しても予約自体はキャンセルしません。外部APIへの依存で予約が失敗するのはユーザー体験を損なうからです。
try {
calendarEventId = await calendarService.upsertEvent({ ... })
calendarSynced = true
} catch (err) {
console.error('カレンダー同期失敗(Phase 2):', err)
// 例外を再throwしない → 予約確定は維持される
}
// calendarSynced=false でも reservationは返却する
return {
reservation: finalReservation,
calendarSynced, // 管理画面に同期状態を表示するために返す
}
設計の工夫2: 「1日1プラン」をDB制約で担保
遊漁船は1隻しかないため、1日に複数プランを同時開催できないという業務制約があります。これをアプリ側のロジックだけで担保するのは危険です(並行リクエストでレースコンディションが起きる可能性がある)。
そこで daily_schedules.date にUNIQUE制約を付け、DBレベルから1日1レコードを強制しています。
CREATE TABLE daily_schedules (
id TEXT PRIMARY KEY,
date TEXT NOT NULL UNIQUE, -- 1日1レコードを保証
plan_id TEXT NOT NULL, -- その日に受け付けるプラン
confirmed_plan_id TEXT, -- NULL = 未確定、セット後 = 確定
calendar_event_id TEXT, -- Googleカレンダーイベントのid
...
);
confirmed_plan_id が NULL のとき、その日はまだどのプランも確定していない状態です。最初の承認時に confirmed_plan_id がセットされ、以降は同じプランの予約だけ受け付けられます。
異なるプランへの予約が来た場合は「conflict(競合)」ステータスへ自動変更し、conflictになった顧客にLINE Flex Messageで「プラン変更またはキャンセルしてください」というボタン付き通知を送ります。
設計の工夫3: 個人情報は承認後に即null化
乗船名簿(氏名・住所・生年月日・緊急連絡先)はD1の passenger_manifests テーブルに一時保存し、承認後にGoogleカレンダーの extendedProperties.private にJSON形式で移動します。その後、D1側の個人情報はすべてnullにします。
// Phase 3: カレンダーへ移した後、D1の個人情報をnull化
await updateReservationStatus(approved.id, 'approved', {
calendarEventId,
clearPersonalInfo: true, // customerName, customerPhone を null に
})
理由は2つあります。
理由1: 個人情報の管理場所を一元化
Googleカレンダーに集約することで、オーナーは使い慣れたカレンダーアプリで乗客情報を確認できます。
理由2: Cloudflare D1の容量節約
フリープランのD1には容量制限があります。住所や緊急連絡先のような長い文字列を永続保持すると無駄にストレージを消費します。承認後は識別情報(予約IDやLINEユーザーID)だけ残しておけば十分です。
また、passenger_manifests テーブルには expire_at カラムを持たせ、出航日から一定期間後に自動削除するCleanupワーカーも実装しています。法律上、乗船名簿の保管義務期間が終わったら削除できます。
設計の工夫4: Googleカレンダーの「1日1イベント」管理
複数の乗客が同じ日に予約した場合、カレンダーイベントは1つにまとめる仕様です。船の定員管理として「この日は〇名乗船予定」を一箇所で確認したいからです。
技術的には extendedProperties.private.passengers にJSON配列として全乗客情報を格納し、新しい乗客が承認されるたびに既存イベントをupsertします。
// 既存乗客リストを取得 → 新乗客を追加 → イベントを更新
const existingPassengers = await fetchExistingPassengers(calendarId, eventId)
const updatedPassengers = [...existingPassengers, newPassenger]
await fetch(`${CALENDAR_API_BASE}/calendars/${calendarId}/events/${eventId}`, {
method: 'PATCH',
body: JSON.stringify({
extendedProperties: {
private: {
passengers: JSON.stringify(updatedPassengers),
},
},
}),
})
ここで詰まったのが既存イベントIDの特定です。
最初は reservation.calendarEventId を見ていましたが、2人目の乗客承認時にこのフィールドがまだ更新されていないタイミングがあり、「既存イベントが見つからない → 新規作成」というバグが発生しました。
解決策: daily_schedules.calendar_event_id にイベントIDを保存し、スケジュール単位で管理するように変更。
// Phase 3: スケジュールにもイベントIDを保存する
await updateScheduleCalendarEventId(schedule.id, calendarEventId)
以降の承認時は daily_schedules.calendar_event_id を最優先で参照し、未セットの場合のみ承認済み予約をフォールバックスキャンします。
設計の工夫5: 自動承認機能
確定済みプランへの新規予約リクエストは、オーナーが手動で承認しなくても自動承認される設計にしました。
予約作成(status: pending)
→ confirmed_plan_id が予約プランと一致する?
→ YES → approveReservation()を直接呼ぶ
→ status = approved
→ Googleカレンダー upsert
→ 顧客へLINE承認通知
→ レスポンス: { autoApproved: true }
→ LIFF完了画面: 「予約が確定しました」
オーナーにとって「同じプランへの追加予約なら定員さえ空いていれば承認するだけ」なので、都度通知・承認するのは手間でした。この自動承認でオーナーの作業が大幅に減りました。
詰まりポイント5: LINEフリープランの通数超過
LINE Messaging APIのフリープランは月200通が上限。予約の都度、顧客にもオーナーにも通知を送ると想像以上に消費が早く、上限に達してしまいました。
そこで「ユーザーが自ら操作した直後の結果確認通知」をLINEから削除し、LIFF画面上のトースト表示で代替する方針に変更しました。
| タイミング | 変更前 | 変更後 |
|---|---|---|
| 予約リクエスト受付(未確定プラン) | 顧客へ「受け付けました」通知を送信 | 廃止(LIFF完了画面のテキストで代替) |
| キャンセル完了 | 顧客へ「キャンセルしました」通知を送信 | 廃止(LIFFトーストで代替) |
| 予約変更完了 | 顧客へ「変更しました」通知を送信 | 廃止(LIFFトーストで代替) |
「LINEを開いて通知を確認する」行為はユーザー体験として価値があります。しかし「自分で操作した直後にその結果が来る」通知は、画面上に表示すれば十分です。この割り切りで月の通数を大幅に削減できました。
詰まりポイント6: Gmail通知機能の実装(googleapis 使えない問題)
オーナーへのメール通知にGmail APIを使いたかったのですが、googleapis SDKはEdge Runtimeで動きません。RFC2822形式のメールをBase64エンコードしてGmail APIのREST Endpointに直接POSTする実装を書く必要がありました。
Edge RuntimeにはNode.jsの Buffer がないため、Base64エンコードも自前実装が必要です。
// BufferのないEdge RuntimeでUTF-8文字列をBase64に変換
function utf8ToBase64(str: string): string {
const bytes = new TextEncoder().encode(str)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}
// RFC2822形式のメールを組み立て
function buildRfc2822(params: { from: string; to: string; subject: string; body: string }): string {
return [
`From: ${params.from}`,
`To: ${params.to}`,
`Subject: =?UTF-8?B?${utf8ToBase64(params.subject)}?=`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: base64',
'',
utf8ToBase64(params.body),
].join('\r\n')
}
また、Gmail送信にはユーザーのリフレッシュトークンが必要です。管理画面のGmail連携ボタンからOAuthを通し、取得したリフレッシュトークンを owner_settings テーブルに保存する設計にしました。
ただし、Googleのリフレッシュトークンは prompt=consent を指定したOAuthフローでないと取得できません。通常のログインフローと「Gmail連携ログイン」フローを分け、Gmail連携時のみ prompt=consent を付与するように実装しました。
// gmail.sendスコープ要求時のみconsentを強制してrefresh_tokenを確実に取得
if (params.includeGmailScope) {
url.searchParams.set('prompt', 'consent')
}
設計の工夫6: 営業日設定UI
「いつ予約を受け付けるか」をオーナーが柔軟に設定できる画面です。月単位で営業月を選択し、その月内に特定の休業日を設定できます。
UIの工夫として、月のチェックボックスに「indeterminate(中間)状態」を使いました。年単位の一括チェックボックスが「全部チェック済み・一部チェック・全部未チェック」の3状態を視覚的に表現します。
<input
type="checkbox"
ref={(el) => {
if (el) el.indeterminate = someChecked && !allChecked
}}
checked={allChecked}
onChange={() => toggleYear(months)}
/>
また、営業月からはずした月の休業日設定は保存時に自動削除します。「3月を営業月から外したのに3月の休業日設定が残る」という矛盾を防ぐためです。
// openMonthsから外れた月のclosedDatesは自動削除
const filteredClosedDates = pendingClosedDates.filter(
(d) => openSet.has(d.slice(0, 7)) // "2026-03-15" → "2026-03" で判定
)
設計の工夫7: LINEユーザーIDの取得問題
LIFFを使う場合、顧客のLINEユーザーIDは liff.getProfile() で取得できます。しかし、LIFFアプリが外部ブラウザで開かれた場合(共有URLをiOSのSafariで開くなど)、LINEへの自動ログインが求められます。
初期実装では、外部ブラウザ環境を考慮せずにLIFF初期化コードを書いていたため、一部環境で「LINE IDが取得できない」バグが発生しました。
対策として、LIFFのstatus確認と liff.login() の呼び出しフローを整理しました。
await liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID })
// 外部ブラウザ or 未ログイン状態のとき自動でログインページへ
if (!liff.isLoggedIn()) {
liff.login()
return // ← これを忘れると後続処理がuserIdなしで走る
}
const profile = await liff.getProfile()
また開発時、LINEアプリ上でのLINEユーザーID確認が必要な場面がありました。LINEのWebhookで「友達追加」や「テキストメッセージ」イベントを受け取ったときにユーザーIDをそのまま返信するデバッグ機能を一時的に実装して解決しました。
まとめ: Cloudflare + Next.js + LINEで学んだこと
| 詰まりポイント | 原因 | 解決策 |
|---|---|---|
| jwtVerifyなどのNode.jsライブラリが動かない | Edge Runtime制約 | jose・fetch直接呼び出しに移行 |
| MongoDB接続が不安定 | Edge RuntimeのDNS制約 | Cloudflare D1に全面移行 |
| バインディングの型定義 | process.envと同一構造に見える | as unknown as { KV: KVNamespace } キャスト |
| カレンダー認証の失効 | セッション依存の認証設計 | サービスアカウントに切り替え |
| LINE通数超過 | 全操作に通知を付けすぎ | 自己操作後の通知をLIFF画面表示に代替 |
| カレンダーイベント重複作成 | reservationにイベントID管理を依存 | daily_schedulesでイベントIDを一元管理 |
| Buffer非対応のBase64 | Edge RuntimeにNode.jsのBufferなし | TextEncoder + btoaで自前実装 |
| リフレッシュトークンが取れない | prompt=consentの設定漏れ | Gmail連携OAuthフロー専用に追加 |
Cloudflare Pages + Edge Runtimeは「サーバーレスで安く動かせる」魅力がありますが、「Node.jsと同じ感覚で作れる」わけではありません。「Edge Runtimeで動くライブラリか?」を常に意識しながら技術選定することが、開発スピードを落とさないための最大のコツだと実感しました。
特に外部APIとの連携は googleapis のようなSDKが使えないケースが多いため、REST APIを直接叩く実装力が問われます。逆に言えば、SDKに隠れていたHTTPの中身が見えるようになり、APIの理解が深まる副次効果がありました。
遊漁船業界のDXはまだまだこれからです。このシステムが実際の現場で役立てば、それが一番うれしいです。


コメント