自分の結婚式のために、招待状、出欠の登録、当日の受付を一つの URL で済ませる Web アプリを作った。当日は数十名のゲスト全員が自分のスマホでチケットをもぎって受付を済ませ、芳名帳は使わなかった。コードの大半は Claude に書かせたが、何を作るか、どの案を採るか、どこまでの危険を許すかは私が決め、画面は実機で触って確かめた。
この記事では、その全体を一本の経緯として書く。Zenn に 4 回に分けて載せた内容に、まだ書いていなかった受付の情報の分け方と、本番前のリスクの洗い出しを加えた。判断の芯は Firebase、Firestore、React の使い方にある。
出発点は、フェスのチケットのような招待状
きっかけは、妻と招待状の話をしていて、フェスのチケットのようにできたら楽しいという話になったことである。入口で係の人にチケットの端をもぎってもらう、あの動作を招待状に持ち込みたかった。
その場で Claude に要望を伝え、チケットを上に引っぱるともぎれるアニメーションだけを HTML 1 枚で作らせた。仕様を文章で詰める前に動くものを一つ見たことで、二人のイメージが揃った。続けて既存の Web 招待状サービスを調べ、必須の機能、足りないもの、あれば嬉しいものを書き出した。
分かったのは、招待状の Web 化はすでに普通になっている一方で、当日の受付がアナログのまま残っていることだった。芳名帳を書く待ち時間、お車代の包みを当日に手探りで渡すこと、アレルギーや車椅子の対応が口頭の引き継ぎだけになること、友人に頼んだ受付係の負担などである。そこで、招待状の見た目で競うのではなく、当日の受付までを含めて作ることにした。
構成は次のとおりである。サーバーは自前で持たず、Firebase の上だけで動かし、使った量は無料枠に収まった。実費はドメイン代くらいだった。
| 層 | 使ったもの |
|---|---|
| 画面 | React 19、TypeScript、Vite、Tailwind CSS |
| 裏側 | Firebase(Hosting、Firestore、Authentication、Cloud Functions) |
| テスト | Vitest(Firestore のルールのテストを含む)、Playwright |
使う人は主催者、ゲスト、受付係の 3 種類で、受付係はゲストのうち当日の受付をお願いした人である。ゲストがもぎるとチケットの checkedIn が立ち、受付係が本人を目で確かめて操作すると receptionConfirmed が立つ。もぎっただけでは受付が終わらない 2 段にしたのは、目の前の人と画面の人が同じかどうかを、人が一度見るほうが安心だと考えたからである。
作業は git で管理を始めてからの 23 日間に集中していて、commit は 181 件、いちばん多い日は 1 日で 89 件だった。最終的に画面は 9、デザインのテーマは 14(組み合わせで 22 通り)、Cloud Functions は 5 本、テストは 1,311 件になった。
もぎる動作を一つの部品にする
チケットの上半分を上に引くと、上半分が飛び、手元に残った半券に席の番号と私たちからのメッセージが遅れて出る。席とメッセージは、もぎる前には出さない。
指の動きは useTicketDrag というフック 1 つで追っていて、上に引いた量を progress(0 から 1)にして、持ち上げる量、奥に倒す角度、ミシン目の光り方、振動をすべてこの値から決めている。骨組みは次のとおりである(簡略化している)。
const TEAR_THRESHOLD = -70 // 上に 70px 引いたら、もぎったことにする
function onMove(y: number) {
const dy = y - startY
if (dy > 20) { reset(); return } // 下に 20px 戻したら、やめたと見なす
current = Math.min(0, dy)
const progress = Math.min(1, Math.abs(current) / Math.abs(TEAR_THRESHOLD))
ticket.style.transform =
`perspective(600px) translateY(${current * 0.6}px) rotateX(${progress * 14}deg)`
perf.style.boxShadow = `0 0 ${progress * 28}px ${progress * 8}px ${glow(progress)}`
if (progress >= 0.4 && !fired40) { fired40 = true; navigator.vibrate?.(15) }
if (progress >= 0.7 && !fired70) { fired70 = true; navigator.vibrate?.(35) }
if (current <= TEAR_THRESHOLD) tear()
}
navigator.vibrate は iPhone の Safari では使えないので、iPhone では光と動きだけが手応えになる。最初は引き始められる場所をミシン目の上下 44px の帯に限っていたが、実機で触ると「どこを引けばいいのか分からない」状態だった。最終的には、もぎられる側の上半分のどこからでも引き始められるようにし、スクロールとの両立は touchmove を passive: false で受けて preventDefault() することで保っている。
いちばん大きな作り直しは、同じチケットが 2 か所に別々のコピーとして作られていたことだった。招待状の中に出るチケットと、受付でもぎるときの全画面のチケットである。最初にもぎり画面を作った時点では、チケットが他の場所にも出ることも、テーマが 22 通りまで増えることも見えておらず、思いついた順に作らせていた。実機で見比べて「もぎり画面と招待状でデザインが違う」と気づき、招待状のチケットを正本にして、もぎり画面は同じ部品を通る形に分け直した。
分け直したあとは、もぎり画面のコンポーネントを全テーマで共有し、持たせる仕事を指の追跡、チェックインの書き込み、演出、振動、やめるボタンに絞った。見た目と破れ方はテーマ側から設定として渡す。
interface OverlayTicket {
renderTop: (guest, event) => ReactNode // 上半分(招待状と同じ部品)
renderStub: (guest, event) => ReactNode // 半券(招待状と同じ部品)
renderPerf?: (glowColor) => ReactNode // ミシン目(省略時は点線)
tearRenderer?: { // 破れ方(省略時は共通の動き)
topAnimation: string
stubAnimation: string
}
renderDone?: (guest, event, onClose) => ReactNode
}
追加した項目はすべて省略可能にし、無ければ前の形のまま動くようにした。22 通りを一度に作り直すのは無理で、1 テーマずつ移し、移していないテーマは前の見た目で動き続ける必要があったからである。破れ方を orientation: 'vertical' | 'horizontal' のような選択肢で持たせる案は、斜めに破る、3 つに裂けるといった破れ方を足すたびに共有側を直すことになるので、却下した。テーマ側に CSS の animation をそのまま持たせれば、共有側は破れ方を知らずに済む。
細かい不具合も出た。もぎった直後に演出が飛んで次の画面へ行ったのは、checkedIn の書き込みが自分の画面にすぐ返ってきて、「受付済みなら受付済みの表示にする」判定が先に働いたためで、もぎり画面を開いている間は閉じない判定を先に置いた。CSS の outline で枠を付けていたテーマは、上半分が閉じた箱になって半券の枠が消えたので、上半分は左右と上、半券は左右と下だけに border を付けた。それらしく見せていただけの QR コードは、読み取っても何も起きず受付係を迷わせるので、全テーマから外した。
配布前日に、もぎる前の席が見えていた
招待状を送る前日、スマホで最後の確認をしていると、ゲストが自分の登録内容を確かめるカードに、チケット番号と席が最初から出ていた。仕様書には「席はもぎったあとにだけ見せる」と書いてあり、もぎった瞬間のお楽しみにしていた仕掛けが、最初から種明かしされた状態だった。
調べると、このカードはテーマごとに複製されていて、番号と席を出している 18 か所のどれにも、もぎる前は出さない判定が入っていなかった。テストは 1,000 件以上あって通っていたが、すべてが「出るべきものが出る」ことを確かめていて、「出てはいけないものが出ていない」ことを確かめるテストは 1 件もなかった。コピーが全部同じ形で抜けていたので、見比べても違いが出ず、気づけなかった。
直し方は二つあり、一つは 18 か所を guest.checkedIn && (...) で囲むことだった。もう一つが本命で、テーマの一覧(registry)を順に回し、全テーマ、全種類のカードに同じ確認をするテストを 1 本足した。
const TICKET_NO = 'WF-777' // 画面のほかの文字と偶然一致しない目印の値
const TABLE = 'Z-9'
const VARIANTS = THEMES.flatMap((theme) =>
theme.slots.guestInfo.variants.map((v) => ({ themeId: theme.id, variantId: v.id, Component: v.component })),
)
it('一覧が空でない', () => {
expect(VARIANTS.length).toBeGreaterThan(0)
})
describe('もぎる前は、チケット番号も席も出さない', () => {
for (const { themeId, variantId, Component } of VARIANTS) {
it(`${themeId}/${variantId}`, () => {
const text = renderText(Component, false)
expect(text).not.toContain(TICKET_NO)
expect(text).not.toContain(TABLE)
})
}
})
it('もぎったあとに出すカードが 1 つはある', () => {
const revealing = VARIANTS.filter(({ Component }) => renderText(Component, true).includes(TICKET_NO))
expect(revealing.length).toBeGreaterThan(0)
})
目印の値を使うのは、席を A-1 のようなありふれた値にすると画面の別の文字と一致してしまうためである。一覧が空だと 1 件も確かめずに全部通るので件数の確認を置き、「いつも隠す」方向に壊れても通らないよう、逆向きの確認も 1 本置いた。このテストはテーマを足しても書き足す必要がなく、registry に入れば自動で対象になる。
見つけたのはテストではなく、実機で自分の招待状を開いたことだった。この規模の不具合は、結局どれも実機で見つかっている。
ゲストにアカウントを作らせない認証
最初の版では、登録したゲストに ID を振ってブラウザに覚えさせ、その ID でゲストの記録を読んでいた。読み取りのルールはこうなっていた。
match /guests/{guestId} {
allow get: if true;
}
ID は推測しにくい長い文字列なので問題ないと考えていたが、URL やスクリーンショットから漏れれば、電話番号、住所、アレルギーまでそのまま読める。開発の途中のレビューで指摘され、招待状を送る半月ほど前に作り直した。
採ったのは Firebase の匿名認証と、ゲストの記録への uid の紐づけである。ゲストがページを開くと名前もパスワードも無い仮のアカウントでログインさせ、その uid をゲストの記録の guestUids 配列に入れる。ルールは「guestUids に自分の uid が入っている人と主催者だけが 1 件読める」に変え、create は false にして、記録の作成は Cloud Functions だけに限った。uid はブラウザから送らず、関数の側で検証済みの request.auth.uid を使う。送らせると他人の uid を名乗れるからである。
別の端末から開き直すときは、メールアドレスと姓名で本人を探し、その端末の uid を足す。足せるのは 1 人 3 台までで、4 台目が来たら最も古い端末を外す。
const MAX_UIDS = 3
// トランザクションの外で、最古の端末を先に調べておく
let oldest = null
if (!preUids.includes(uid) && preUids.length >= MAX_UIDS) {
oldest = await findOldestSession(preUids)
}
await db.runTransaction(async (tx) => {
const fresh = (await tx.get(guestRef)).data()
const current: string[] = fresh.guestUids ?? []
if (!current.includes(uid) && current.length >= MAX_UIDS) {
tx.update(guestRef, { guestUids: FieldValue.arrayRemove(oldest.uid) })
tx.update(sessionsRef.doc(oldest.sessionId), { active: false })
}
tx.update(guestRef, { guestUids: FieldValue.arrayUnion(uid) })
tx.set(sessionsRef.doc(newSessionId), { guestUid: uid, issuedAt: now, active: true })
})
最古の端末を外で調べるのは、Firestore ではトランザクションの中で条件を付けた一覧の読み取りができないためで、これを中でやって INTERNAL で落ちたことがある。
比べた案は 4 つの軸で十数個あった。データの守り方では、関数を通してしか読ませない案が、データの変更がすぐ画面に届くリアルタイム更新を使えなくなるので外れた。もぎった瞬間に画面が変わる体験の土台だったからである。署名つき URL は、LINE で転送されたら他の人も読めるので外した。本人確認では、メールのリンクは迷惑メールに入ると当日の受付前に詰み、合言葉は数か月後まで覚えてもらえず、ゲストごとに別の URL を送るのは数十通を手で送ることになるので、メールと姓名にした。この方式はメールと姓名を知っている人のなりすましを許すが、結婚式の招待状では害が限られ、起きても主催者が管理画面から端末を外せば止められると判断した。銀行や病院のアプリなら同じ判断はしない。
工数は想定 10 時間に対して 16 時間で、テストは関数側 31 件と画面側 94 件の計 125 件、ブラウザを自動で動かす E2E を 5 本書いた。それでも本番に出してから落とし穴を 7 つ踏んだ。
| # | 起きたこと | 原因と対処 |
|---|---|---|
| 1 | 匿名ログインが HTTP 400 | コンソールで匿名ログインを有効にしていなかった |
| 2 | ゲストの登録が完全に止まる | 関数とルールだけ出して Hosting を出し忘れ、古い画面が新しいルールに断られた。3 つは必ず一緒に出す |
| 3 | 関数が 500 | IP から地域を調べるライブラリの辞書が約 40MB あり、標準の 256MiB を超えた。512MiB に上げた |
| 4 | ブラウザからの呼び出しが 403 | 関数を呼んでよい権限が一つだけ外れていた。原因は不明で、関数を消して出し直したら直った |
| 5 | トランザクションが INTERNAL | 中で一覧を読んでいた。外で読み、中では 1 件ずつの参照だけにした |
| 6 | 受付係のチェックインが権限エラー | 更新のルールに受付係を入れ忘れていた |
| 7 | 受付画面が「取得に失敗」で止まる | 匿名ログインの期限が切れると uid が変わり、戻り道を作っていなかった |
7 番は、あとから再ログインを促す表示と読み込み直すボタンで塞いだ。ルールのテストは、手元に Java が無くエミュレータが動かなかったため走らせておらず、本番に出してから、ログインしていない状態で直接読みに行って 403 が返ることを確かめた。この状態はいまも変わっていない。
受付係に、画面で隠すのではなく読めなくする
認証を直しても、受付係の問題が残っていた。受付画面にはゲストの全項目を出していなかったが、受付係がブラウザの開発者ツールを開くと、画面に出ていない項目も含めてゲストの全項目が読めた。画面で隠していただけで、読めない状態にはなっていなかった。
そこでゲストの記録を分けた。本人と管理者だけが読める private/contact と、管理者だけが読める private/finance を別のドキュメントにし、Firestore のルールで受付係を遮断した。
| 置き場所 | 読める人 | 入っているもの |
|---|---|---|
| ゲストの記録(本体) | ゲスト本人、主催者、受付係 | 受付に必要な最小限の項目 |
private/contact | 本人、管理者 | 連絡先に当たる項目 |
private/finance | 管理者のみ | お車代など金銭に関わる項目 |
受付係の画面には、お車代の金額ではなく封筒に振った番号(記号)だけを出す。封筒を渡すのに必要なのは番号だけだからである。既存のデータを新しい置き場へ移す移行スクリプトと、戻すためのロールバックスクリプトも用意した。この変更は、設計書を書き、1,471 行の計画にして 15 commit で実装した。実装の途中で型エラーなどを 15 件以上直している。
Firestore のルールは、ここまでで段階的に強くなっている。誰でも管理者になれる状態の指摘から始まり、書き込みを認証必須にし、guestUids に一本化し、この回で機微情報を分けた。checkedIn は false から true への一方向だけを許している。
本番前に、リスクを台帳にした
受付の分離のあと、もう一つの大きな作業は当日のリスクの洗い出しだった。招待状を LINE で一斉に送ると、短い時間にアクセスが集まる。当日も、受付に人が並んだ時間に同時に使われる。
先に直したのは、取得に失敗したときの見本の値だった。4G の実機で招待状を開くと、新規作成用のテンプレートの Name & Name のような仮の名前が一瞬ゲストに見えることがあった。原因は、データが取れなかったときに event ?? テンプレート で見本を描画していたことである。偽の値の描画をやめ、実データか、読み込み中か、エラーのどれかを明示して出すように変えた。一過性のエラーなら前回の値を保って再接続し、恒久的なエラーとは分けている。ゲスト側にも同じ形が潜んでいたので、同じ原則で直した。
その上で、負荷、整合性と並行性、クライアントの堅牢性の 3 つの視点を並列に当て、「リスク、起きる障害、対抗策、得失」を並べた台帳を作った。項目は 12 個で、セキュリティが 3、必須が 5、運用で済ませるものが 4 だった。必須は実装し、運用で済むものは運用に回し、すでに堅牢なものは「対応不要」と書いて過剰な対策を避けた。
セキュリティの 3 件は次のとおりである。
- 登録の関数への大量リクエストで登録を妨害されるのを封じた
- 受付のスタッフモードに入る PIN が、誰でも読める公開のドキュメントに平文で入っていた。サーバー側の関数(
verifyStaffPin)で照合する形に変え、5 回失敗したら 5 分ロックする。このとき Cloud Functions は 4 本から 5 本になった - 受付係が更新できる項目を、チェックインに必要なものだけに絞った
PIN には別の問題もあった。PIN が未設定のとき 0000 で誰でもスタッフモードに入れる動きが残っており、UX レビューで見つけて廃止した。フォールバックの値は、設定し忘れという一番ありそうな失敗のときに働くので、危険になりやすい。台帳の B1(当日朝の関数のウォームアップ)と B3(招待状の分割送付)は運用に回した。
台帳の対策にも不具合が出た。二重チェックインを何もしない扱いにする実装が React のフックの規則に反していて、もぎっている最中に React がクラッシュし、もぎりのアニメーションが壊れた。早期 return をフックの後ろに移して直している。PIN の移行でも、既存の式は管理画面でスタッフ PIN を一度保存し直すまで、受付のスタッフ用ボタンが一時的に出なくなった。対策を入れるたびに、その対策が新しい壊れ方を持ち込んでいた。
もう一つ、台帳とは別に、全ゲスト情報を CSV で出す帳票も作った。席次、お礼状、アレルギー対応に使うためで、30 列、同伴者は 1 行ずつ展開し、Excel で文字化けしないよう UTF-8 の BOM を付け、RFC 4180 に沿った形にした。純粋関数に切り出して 34 件のテストを足している。
当日
招待状は LINE で URL を一斉に送った。登録のあとで数名から取り消しがあり、取り消しの機能は正しく動いた。
当日は、受付の人に受付画面の使い方を教え、ゲストには自分のスマホでチケットをもぎってもらった。数十名のゲスト全員(同伴者を含む)がチェックインした。1 人だけもぎりがうまくいかず、その人の分は受付で代行したが、原因は分かっていない。端末やブラウザによってはもぎりが効かないことがあるという前提で、代わりの手段を用意していたのは役に立った。
作る前に想像していなかった使い方もあった。ゲストが受付に来る時間帯、私たちは控室で着替えなどの準備をしていたが、控室の PC で同じ受付画面を開き、誰が来たかをリアルタイムに見られた。新郎新婦が最も見られない場面を、離れた場所から見られたことになる。ゲストからは、もぎる動作と、もぎったあとにスマホに出るメッセージを喜んでもらえた。受付の人からは「簡単でよかった」と言われ、芳名帳が不要になった分、数千円が浮いた。
ただし、台帳の項目のうち当日朝のウォームアップを実際にやったか、電波などの細かいトラブルがあったかは、記録に残っていない。分かっているのは上に書いた結果までである。
残っていること、止めていること
ルールのテストをエミュレータで走らせていないこと、取り消し期限の判定が JST の境界で 9 時間ずれる可能性、同時に端末を追加したときの順序の競合(発生は極めて稀と見ている)は、記録済みの残課題である。iPhone では振動が鳴らないことも変わらない。
途中から、他の夫婦にも使ってもらえるサービスにするつもりで、複数の式を扱える形に作り直していた。式のあと、ほかにやりたいことが増えたので、サービスとしての開発は止めている。データには触っておらず、消してよいと考えている。
振り返って
壊れたものの共通点は、どれも画面とテストの数字では見えなかったことだった。もぎる前に席が出ていたのも、通信が不安定なときに見本の名前が出たのも、実機で自分の目で見て見つかっている。テストは、見つけたものを二度と起こさないために置くものになった。
もう一つは、見えないものほど先に決めておくべきだったことである。チケットが 2 か所に出ること、テーマが増えること、受付係に何を読ませないことは、作る前に書き出せば避けられた。動くものが速く出てくるほど、全体の形を決めないまま積み上がる。最初の 1 場面を動かしたあとに、いったん手を止めて全体を書き出す手順を、次に作るときは入れるつもりである。
この記事は、Zenn に書いた次の 4 本に、未公開だった受付の情報の分け方と当日のリスクの台帳を加えて、一本の経緯として書き直したものである。コードの全体や画面つきの説明は、元の記事のほうが細かい。