遊漁船向けLINE予約システムを作って気づいたこと──Cloudflare + Next.js + LINEの組み合わせで詰まったところ全部書く

Uncategorized

※プロモーションページが含まれる場合があります

はじめに

個人経営の遊漁船オーナー向けに、LINEで予約を受け付けてGoogleカレンダーと自動連携するシステムを1から設計・実装しました。「電話予約が主流な業界をDX化したい」というモチベーションです。

完成した機能は以下のとおりです。

  • 顧客はLINEの中だけで予約申請・変更・キャンセルが完結する(LIFF)
  • オーナーはブラウザの管理画面から予約を確認・承認・拒否できる
  • 承認するとGoogleカレンダーに自動で予約イベントが作られる
  • 乗船名簿(氏名・住所・生年月日・緊急連絡先)をデジタルで管理できる
  • 月単位・日単位で営業日を設定できる
  • 承認済み顧客への一斉LINEメッセージ配信ができる
  • 新規予約・変更・キャンセル時にオーナーへGmail通知が届く

こんな感じのができました。

遊漁船LINE予約システム
遊漁船の予約をLINEで簡単に

技術スタックはこちらです。

分類技術
フロントエンド・APINext.js 15(TypeScript)+ Cloudflare Pages
データベースCloudflare D1(SQLite)
セッション管理Cloudflare KV + jose JWT
顧客向けUILINE LIFF
外部連携LINE Messaging API、Google Calendar API v3、Gmail API

本記事では、実装過程で「これは詰まった」「ここは工夫が必要だった」という点を洗いざらい書き残します。


詰まりポイント1: Edge Runtimeでは「当たり前のパッケージ」が動かない

Cloudflare PagesはEdge Runtimeで動きます。V8 isolatesと呼ばれる軽量な実行環境であり、Node.jsではありません。つまり fsBuffer・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非対応のBase64Edge 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はまだまだこれからです。このシステムが実際の現場で役立てば、それが一番うれしいです。

コメント

タイトルとURLをコピーしました