Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 53 additions & 21 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,17 @@

#### 認証・共有機能

- **閲覧**: ログイン不要、tripId(ハッシュ化)を知っていれば誰でもアクセス可能
- **編集**: Firebase Authenticationによるメール認証(パスワードレス)
- **セッション**: 長期間有効
- **権限管理**:
- 旅程作成者: 削除権限、AI機能利用権限
- 編集参加者: 閲覧画面から「一緒に編集する」ボタンで編集モードに移行
- **閲覧・編集ともログイン不要**: tripId(ハッシュ化)を知っている人は誰でも閲覧・編集可能
- グループ利用(家族/友人)を前提とし、編集の絞り込みは行わない
- URL 共有相手には同等の編集権限を与える設計
- **認可の仕組み**: HttpOnly Cookie に不透明トークン (session_id) を格納し、DB の `user_trip_access` テーブルで trip 単位のアクセス権を管理する(旧 `trip_ids` 配列 JWT からセッションキー方式へ移行、issue #194)
- **セッション**: 長期間有効 (Cookie Max-Age = 30 日、アクセス毎に延長)
- **メール認証** (オプション、Firebase Authentication のパスワードレス方式):
- **バックアップ・追加機能** として位置付ける(編集の必須要件ではない)
- 有効化することで以下ができるようになる:
- デバイス間での旅程一覧同期
- Cookie 消失 (ブラウザデータクリア、機種変更、iOS Safari の ITP 7 日パージ) 時のリカバリ
- 認証時は同一 `firebase_uid` の user に session を紐付けて統合する

### Phase 2

Expand All @@ -73,10 +78,17 @@
- **競合解決**: 同時編集時の後勝ちルール
- **接続状態管理**: 編集参加者の状態可視化

#### プッシュ通知

- **FCM (Firebase Cloud Messaging)** による端末単位の通知
- 出発時刻の直前リマインダー
- 端末 × Trip 単位で購読を管理 (`device_subscriptions` テーブル)

### Phase 3

#### 追加管理機能

- **旅程一覧のアーカイブ**: `user_trip_access.archived` フラグでの一覧非表示
- **外部URL管理**: Walica等の割り勘サービスURL管理
- **旅行メタ情報管理**: 旅行名、期間、参加者、テーマ等
- **予約情報統合管理**: 各施設の予約情報の一元管理
Expand All @@ -96,19 +108,22 @@
- **フレームワーク**: React + TypeScript + Vite
- **UI/UX**: Shadcn/ui + Tailwind CSS
- **ドラッグ&ドロップ**: @dnd-kit
- **認証**: Firebase Authentication
- **認証 (オプション)**: Firebase Authentication (パスワードレス、バックアップ用)

#### バックエンド

- **フレームワーク**: Python + FastAPI
- **データベース**: MySQL
- **データベース**: PostgreSQL
- **認可**: セッションキー方式 (HttpOnly Cookie の JWT に session_id、DB で `user_trip_access` を参照)
- **認証 (オプション)**: Firebase Authentication (Firebase Admin SDK による ID トークン検証)
- **リアルタイム通信**: WebSocket(Socket.io検討)
- **プッシュ通知**: Firebase Cloud Messaging (FCM)

#### インフラ

- **フロントエンド**: Firebase Hosting
- **バックエンド**: Railway
- **データベース**: Railway MySQL
- **バックエンド**: Google Cloud Run
- **データベース**: PostgreSQL (Cloud SQL / Neon 等)

#### 外部API

Expand All @@ -121,17 +136,20 @@
- リアルタイム同期レスポンス: 1秒以内
- 画面遷移: 2秒以内
- モバイル対応
- 認可判定は 1 リクエストあたり 1 クエリで完結する (page/block CRUD は JOIN 1 発)

#### セキュリティ要件

- tripIdのハッシュ化によるURL推測防止
- Firebase Authenticationによる認証
- tripIdのハッシュ化によるURL推測防止 (認可の主軸)
- HttpOnly Cookie による session_id 管理 (JavaScript からのアクセス防止)
- HTTPS通信
- メール認証 (オプション) は Firebase Authentication のパスワードレス方式
- 並列アクセス時のアクセス権付与は `(user_id, trip_id)` 複合 PK + `ON CONFLICT DO NOTHING` で idempotent 化

#### 可用性要件

- サービス稼働率: 99%以上
- コールドスタート回避(Railway使用
- コールドスタート回避(Cloud Run 最小インスタンス構成

## URL設計

Expand All @@ -143,33 +161,44 @@

### アクセスパターン

1. **閲覧モード**: 誰でもアクセス可能
2. **編集モード**: 「一緒に編集する」ボタン → Firebase認証 → 編集モード切り替え
- URL (`/trip/[secureHashId]`) を知っている人は誰でも閲覧・編集可能
- 初回アクセス時に匿名 session と共に `user_trip_access` へアクセス権が付与される
- メール認証 (オプション) を経由すると、複数デバイスで同じ旅程一覧を共有できる

### tripId仕様

- ランダムハッシュ値(推測困難)
- 例: nanoid(16) または crypto.randomUUID() + timestamp

## データ構造(未定)
## データ構造

論理設計の全体像は [db_schema.md](./db_schema.md) を参照。

認可関連の主なテーブル:

- `users` — 認可の主体。Cookie 発行時に匿名 user (`firebase_uid IS NULL`) を自動作成し、Firebase 認証時に `firebase_uid` を埋めて昇格させる
- `sessions` — Cookie に載る `session_id` と `user_id` の紐付け
- `user_trip_access` — `(user_id, trip_id)` 複合 PK の中間表。並列付与の race を `ON CONFLICT DO NOTHING` で idempotent に解消する

## 開発フェーズ

### Phase 1: MVP(最小機能)

- 基本的なブロックUI実装
- ドラッグ&ドロップ機能
- Firebase認証実装
- 閲覧/編集権限機能
- セッションキー方式による認可 (issue #194)
- メール認証 (Firebase Auth) をオプション機能として実装

### Phase 2: 中核機能

- Google Maps API連携
- リアルタイム同期機能
- リアルタイム同期機能 (WebSocket)
- プッシュ通知 (FCM)
- モバイル対応

### Phase 3: 拡張機能

- 旅程一覧のアーカイブ
- 外部URL管理画面
- AI機能実装
- 各種最適化
Expand All @@ -179,7 +208,8 @@
### コスト管理

- Firebase Hosting: 無料枠内運用
- Railway: 月額3-5ドル程度
- Google Cloud Run: 従量課金 (通常運用で月数ドル程度)
- Firebase Authentication: 無料枠内 (パスワードレスメール認証は月 10K auth まで無料)
- Google Maps API: 無料枠内、制限機能実装

### 監視・ログ
Expand All @@ -194,6 +224,7 @@

- Google Maps API無料枠内での運用
- 個人開発のためミニマム構成
- iOS Safari の ITP により、iOS からのアクセスでは HttpOnly Cookie が 7 日程度でパージされうる (Cloud Run + Firebase Hosting 構成の IP prefix 不一致による)。メール認証がオプションで用意されているのはこのリカバリ手段でもある

### ビジネス制約

Expand All @@ -203,4 +234,5 @@
### 設計制約

- リアルタイム同期における競合解決は後勝ちルール
- 認証はメール認証のみ(SNS認証は未対応)
- 認可は URL の秘匿性 + session_id で担保する (認証は必須ではない)
- メール認証 (オプション) はパスワードレス方式のみ (SNS 認証は未対応)
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { isOfflineReadAtom } from './atoms/network';
import { NoIndex } from './components/NoIndex';
import { Title } from './components/Title';
import { useAuthStateSync } from './hooks/useAuth';
import { useForegroundNotificationToast } from './hooks/useForegroundNotificationToast';
import { useNetworkToast } from './hooks/useNetworkToast';
import { usePageTracking } from './hooks/usePageTracking';
Expand All @@ -19,6 +20,7 @@ const App = () => {
useNetworkToast();
usePageTracking();
useForegroundNotificationToast();
useAuthStateSync();

return (
<>
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/atoms/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { User } from 'firebase/auth';
import { atom } from 'jotai';

/**
* Firebase Auth の現在のユーザー。
*
* - `undefined`: 初期化前 (onAuthStateChanged がまだ呼ばれていない)
* - `null`: 未認証 (匿名 session でアプリを利用中)
* - `User`: メール認証済み (バックアップ・同期機能が有効)
*/
export const authUserAtom = atom<User | null | undefined>(undefined);

/** メール認証済みかどうか。初期化前は false 扱い。 */
export const isAuthenticatedAtom = atom(get => get(authUserAtom) != null);
43 changes: 43 additions & 0 deletions frontend/src/components/SyncSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useAtomValue } from 'jotai';
import { MailIcon } from 'lucide-react';
import { useState } from 'react';
import { authUserAtom } from '@/atoms/auth';
import { Button } from '@/components/ui/button';
import { SyncSignInDialog } from '@/dialogs/SyncSignInDialog';

/**
* 旅程一覧ページに常時配置される "同期" セクション。
*
* - 未認証時のみ表示 (認証済みなら非表示)
* - CTA は「同期する」(バックアップ・追加機能の位置づけ、"登録" 感を出さない)
* - iOS Safari の Cookie 7 日パージ制約は明示しない (ポジティブ訴求のみ)
*/
export const SyncSection = () => {
const authUser = useAtomValue(authUserAtom);
const [dialogOpen, setDialogOpen] = useState(false);

// 認証済み・初期化前は表示しない
if (authUser !== null) return null;

return (
<>
<section className='mb-6 rounded-lg border border-teal-200 bg-white p-4 shadow-sm'>
<div className='flex items-start justify-between gap-4'>
<div className='flex flex-1 items-start gap-3'>
<MailIcon className='mt-0.5 size-5 shrink-0 text-teal-600' />
<div>
<h3 className='mb-1 font-semibold text-14px text-teal-800 sm:text-16px'>デバイス間で旅程を同期</h3>
<p className='text-12px text-gray-600 sm:text-14px'>
メール認証で複数デバイスから同じ一覧にアクセス、機種変更でも消えません。
</p>
</div>
</div>
<Button className='shrink-0' onClick={() => setDialogOpen(true)} size='sm'>
同期する
</Button>
</div>
</section>
<SyncSignInDialog onOpenChange={setDialogOpen} open={dialogOpen} />
</>
);
};
118 changes: 118 additions & 0 deletions frontend/src/dialogs/SyncSignInDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { type FormEvent, useEffect, useId, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { sendMagicLink } from '@/hooks/useAuth';

interface SyncSignInDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

type Status = 'idle' | 'sending' | 'sent' | 'error';

/**
* メールアドレスを入力してマジックリンクを送信するダイアログ。
* "同期" セクションから開かれる。認証が完了するのは、届いたメールのリンクを
* 同じデバイスで開いた時 (useAuthStateSync 内の completeMagicLinkSignIn) 。
*/
export const SyncSignInDialog = ({ open, onOpenChange }: SyncSignInDialogProps) => {
const emailId = useId();
const [email, setEmail] = useState('');
const [status, setStatus] = useState<Status>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);

useEffect(() => {
if (open) {
setEmail('');
setStatus('idle');
setErrorMessage(null);
}
}, [open]);

const handleSubmit = async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
if (email.length === 0) return;
setStatus('sending');
setErrorMessage(null);
try {
await sendMagicLink(email);
setStatus('sent');
} catch (err) {
setStatus('error');
setErrorMessage(err instanceof Error ? err.message : '送信に失敗しました。時間をおいて再度お試しください。');
}
};

const isSending = status === 'sending';

return (
<Dialog
open={open}
onOpenChange={next => {
if (isSending) return;
onOpenChange(next);
}}
>
<DialogContent
onInteractOutside={e => {
if (isSending) e.preventDefault();
}}
onEscapeKeyDown={e => {
if (isSending) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>メールで同期を有効にする</DialogTitle>
</DialogHeader>
<DialogBody>
{status === 'sent' ? (
<p className='text-14px text-gray-700 sm:text-16px'>
<span className='font-semibold'>{email}</span> にリンクを送信しました。
<br />
メールを開き、同じデバイスでリンクをタップしてください。
</p>
) : (
<form className='flex flex-col gap-4' id='sync-signin-form' onSubmit={handleSubmit}>
<p className='text-12px text-gray-600 sm:text-14px'>
届いたメールのリンクを同じデバイスで開くと、同期が有効になります。
</p>
<div className='flex flex-col gap-2'>
<Label htmlFor={emailId}>メールアドレス</Label>
<Input
autoComplete='email'
autoFocus
disabled={isSending}
id={emailId}
onChange={e => setEmail(e.target.value)}
placeholder='you@example.com'
required
type='email'
value={email}
/>
</div>
{errorMessage != null && <p className='text-12px text-red-600 sm:text-14px'>{errorMessage}</p>}
</form>
)}
</DialogBody>
<DialogFooter>
{status === 'sent' ? (
<Button onClick={() => onOpenChange(false)} type='button'>
閉じる
</Button>
) : (
<>
<Button disabled={isSending} onClick={() => onOpenChange(false)} type='button' variant='outline'>
キャンセル
</Button>
<Button disabled={isSending || email.length === 0} form='sync-signin-form' type='submit'>
{isSending ? '送信中...' : 'リンクを送信'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
Loading
Loading