Skip to content

Commit 2c78708

Browse files
committed
テーブル定義とER図をまとめた docs/database.md を追加する
スキーマ本体とマイグレーションをたどらなくても全体像が読めるようにする。 権威は従来どおり db/init/01_schema.sql で、この文書はそれを読める形に 写したもの。DBに変更を入れたら同じコミットでこの文書も更新する (CLAUDE.md のスキーマ変更のルールに追記)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 21dd4dd commit 2c78708

2 files changed

Lines changed: 265 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ npm run build # 本番ビルド(型
2525

2626
DB定義は`db/init/01_schema.sql`の1ファイルにすべてまとまっている(テーブル・索引・トリガー・既定のスポット種別の投入まで)。**このファイルが「現在あるべきスキーマの唯一の定義」**で、追加分を`02_...`のような別の初期化ファイルに切り出す方式は取らない。スキーマを変えるときは常にこのファイルだけを編集すること。
2727

28+
テーブル定義の読める形の一覧とER図は[docs/database.md](docs/database.md)にまとめてある。**DBに変更を入れたら、同じコミットでこの文書も更新すること**(README等と同じく実装に追従させる対象)。
29+
2830
あわせて、**テーブルに変更を加えた場合は同じコミットで`db/migrations/`に移行スクリプトを追加し、本番DBを既存データを保持したまま移行可能にすること**(本番には利用者の訪問記録・写真が入るため、`db/data/`を捨てる運用はできない)。ファイル名は`<連番>_<内容>.sql`で、ファイル名がそのまま`schema_migrations.version`になる。**`begin`/`commit``schema_migrations`へのinsertはスクリプトに書かない**(どちらも`db/entrypoint.sh`が受け持つ)。全文idempotentにすること — 新規DBに対しても一度は実行される。詳細は`db/migrations/README.md`
2931

3032
適用は`docker compose up`で自動的に行われる(手で流す必要はない。下記「DBの初期化・マイグレーションの流れ」参照)。

docs/database.md

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
# データベース定義
2+
3+
PostgreSQL のテーブル定義と、テーブル間の関係をまとめた文書。**スキーマ本体と
4+
マイグレーションをたどらなくても全体像が分かるようにするため**に置いている。
5+
6+
定義の**権威は [`db/init/01_schema.sql`](../db/init/01_schema.sql)**(テーブル・索引・
7+
トリガー・初期データのすべて。「現在あるべきスキーマの唯一の定義」)。この文書はそれを
8+
読める形に写したもので、列ごとの細かい経緯は SQL のコメントに書いてある。
9+
[`db/migrations/`](../db/migrations/) は既存 DB を保持したまま移行するためのスクリプトで、
10+
最終形はマイグレーションではなくスキーマ本体の側が持つ。
11+
12+
**DB に変更を入れたら、同じコミットでこの文書も更新する**(手順は末尾)。
13+
14+
## 全体像
15+
16+
中心は `spots`(スポット)で、種別(`spot_types`)がスポットの名前空間になる。
17+
ユーザーごとの記録(訪問・非表示・予定・口コミ)はすべて `users` × `spots` に紐づく。
18+
19+
```mermaid
20+
erDiagram
21+
spot_types ||--o{ spot_type_settings : "種別ごとの設定"
22+
spot_types ||--o{ spots : ""
23+
spot_types ||--o{ spot_deletions : ""
24+
spot_types ||--o{ spot_routes : ""
25+
spot_types ||--o{ visit_plan_lists : ""
26+
app_settings }o--|| spot_types : "active_spot_type_id"
27+
28+
users |o--o{ spots : "created_by"
29+
users |o--o{ spot_routes : "created_by"
30+
users |o--o{ spot_deletions : "deleted_by"
31+
users ||--o{ visits : ""
32+
users ||--o{ spot_hides : ""
33+
users ||--o{ visit_plans : ""
34+
users ||--o{ visit_plan_lists : ""
35+
users ||--o{ reviews : ""
36+
37+
spots ||--o{ spot_route_points : ""
38+
spots ||--o{ visits : ""
39+
spots ||--o{ spot_hides : ""
40+
spots ||--o{ visit_plans : ""
41+
spots ||--o{ visit_plan_list_items : ""
42+
spots ||--o{ reviews : ""
43+
44+
spot_routes ||--o{ spot_route_points : "seq 順"
45+
visit_plan_lists ||--o{ visit_plan_list_items : "seq 順"
46+
```
47+
48+
このほかに、どのテーブルとも関係を持たない `schema_migrations`(適用済み
49+
マイグレーションの記録。[`db/entrypoint.sh`](../db/entrypoint.sh) が作る)がある。
50+
51+
## 横断的な決めごと
52+
53+
- **主キーは `uuid`**(`gen_random_uuid()`、pgcrypto 拡張)。例外は複合キーの
54+
`spot_type_settings`(種別×キー)と `spot_route_points`(ルート×seq)、
55+
singleton の `app_settings`
56+
- **全テーブルに `created_at` / `updated_at`** を持ち、`updated_at` は共有の
57+
`set_updated_at()` トリガーが自動更新する。日時はすべて `timestamptz`
58+
- **列挙は数値ではなく文字列 + `check` 制約**(`users.role``spots.status`
59+
`spots.origin``reviews.visibility`)。`psql` で覗いたときに読めるほうを優先
60+
- **外部キーは実際に張ってある**。削除時の挙動で役割を分けている:
61+
**記録・従属データは `on delete cascade`**(スポットを消せば訪問記録も消える)、
62+
**作成者への参照は `on delete set null`**(ユーザーを消しても作ったスポット・
63+
ルートは残る)
64+
- **公開状態(`status`)は `spots``spot_routes` で共通の4値**
65+
`published`(全員に見える)/ `pending`(承認待ち。本人 + moderator 以上)/
66+
`rejected`(却下)/ `private`(作成者本人のみ。誰でも作れる非公開スポット)
67+
- **「ユーザー×スポットで1件まで」のトグル型テーブル**(`spot_hides` /
68+
`visit_plans`)は `unique (user_id, spot_id)` で構造ごと保証する
69+
- **写真の実体は DB に入れない**`visits.photos``text[]` の相対パス
70+
(`<ユーザーID>/<年>/<月>/<uuid>.<拡張子>`)で、実体は bind マウントされた
71+
`photos/` に置く(`lib/photos.ts`)。DB と `photos/` は一緒にバックアップする
72+
- **`visits.visited_on` の null は「時期不明」**(訪問した日を覚えていない)。
73+
`unvisited = true` は「未訪問記録」で、訪問済みの判定には数えない
74+
75+
## テーブル
76+
77+
### 種別と設定
78+
79+
```mermaid
80+
erDiagram
81+
spot_types {
82+
uuid id PK
83+
text key UK "機械可読キー(例 tourist)"
84+
text label "表示名(例 観光地)"
85+
}
86+
spot_type_settings {
87+
uuid spot_type_id PK, FK
88+
text key PK "設定名(lib/types.ts に既知キー)"
89+
text value "boolean 相当は 'true'/'false' の文字列"
90+
}
91+
app_settings {
92+
boolean singleton PK "check(singleton) で常に1行"
93+
uuid active_spot_type_id FK "ルート(/)のリダイレクト先"
94+
}
95+
spot_types ||--o{ spot_type_settings : ""
96+
app_settings }o--|| spot_types : ""
97+
```
98+
99+
- **`spot_type_settings` は EAV 形式**。設定を追加するたびに `spot_types` に列を
100+
増やさずに済む。**行が無いキーは設定ごとの既定値**として扱う(口コミの有効化・
101+
管理者限定閲覧のような boolean のほか、`series_styles``categories`
102+
`region_scope` など JSON・文字列値のキーもある)
103+
- 画面・API の対象種別は常に URL の `/[type]/...` で決まる。`app_settings`
104+
「ログイン後に自動で開く種別」を決めるためだけの 1 行
105+
106+
### アカウント
107+
108+
```mermaid
109+
erDiagram
110+
users {
111+
uuid id PK
112+
text email UK
113+
text password_hash "google_id とどちらかは必須(check)"
114+
text google_id UK "Googleログイン用"
115+
text role "admin / spot_admin / moderator / user"
116+
text nickname "口コミ等の表示名。未設定は「匿名」"
117+
}
118+
```
119+
120+
- 自由サインアップは無く、管理者が作成する。**最初の1アカウントだけ**セットアップ
121+
画面から作成でき、自動的に admin になる
122+
123+
### スポットとルート
124+
125+
```mermaid
126+
erDiagram
127+
spots {
128+
uuid id PK
129+
uuid spot_type_id FK
130+
text key "種別内で一意(部分ユニーク索引)。CSV・ルートからの参照用"
131+
text name
132+
text name_kana
133+
double lat
134+
double lng
135+
text region "region_scope 設定で意味が変わる(都道府県・州・国名)"
136+
text series "1スポット1つ。色分け・ルートとの突き合わせの単位"
137+
text_array categories "0個以上(GIN索引)"
138+
text description
139+
text status "published / pending / rejected / private"
140+
text origin "csv / manual。travel-log-data への還元抽出に使う"
141+
uuid created_by FK "on delete set null"
142+
}
143+
spot_deletions {
144+
uuid id PK
145+
uuid spot_type_id FK
146+
text key "削除時点の値のコピー(墓標)"
147+
text name
148+
double lat
149+
double lng
150+
text region
151+
uuid deleted_by FK
152+
}
153+
spot_routes {
154+
uuid id PK
155+
uuid spot_type_id FK
156+
text name "種別内でユニーク"
157+
text series "spots.series と同じ値空間。矢印の色"
158+
text description
159+
text status "spots と同じ4値"
160+
uuid created_by FK
161+
}
162+
spot_route_points {
163+
uuid route_id PK, FK
164+
integer seq PK "昇順が巡った順"
165+
uuid spot_id FK "on delete cascade(点だけ抜ける)"
166+
text description "次の経由地への区間の説明。最終地点は null"
167+
}
168+
spot_types ||--o{ spots : ""
169+
spot_types ||--o{ spot_deletions : ""
170+
spot_types ||--o{ spot_routes : ""
171+
spots ||--o{ spot_route_points : ""
172+
spot_routes ||--o{ spot_route_points : ""
173+
```
174+
175+
- **`spots.key` は自然キー(名前)ではなく明示キー**。改名・座標修正で
176+
ルート CSV からの参照が壊れないようにするため。不要なスポットは null でよい
177+
- **`spot_deletions` は削除の墓標**。CSV 由来の公開スポットを画面から個別削除した
178+
ときだけ記録し、travel-log-data 側の `exclude.txt` へ追記する候補として
179+
還元用エクスポートに出す(行が消えるので値をコピーして残す)
180+
181+
### ユーザーごとの記録
182+
183+
```mermaid
184+
erDiagram
185+
visits {
186+
uuid id PK
187+
uuid user_id FK
188+
uuid spot_id FK
189+
timestamptz visited_on "null = 時期不明"
190+
text memo
191+
text_array photos "photos/ 配下の相対パス"
192+
boolean unvisited "true = 未訪問記録(訪問済みに数えない)"
193+
}
194+
spot_hides {
195+
uuid id PK
196+
uuid user_id FK
197+
uuid spot_id FK "ユーザー×スポットでユニーク(トグル)"
198+
}
199+
visit_plans {
200+
uuid id PK
201+
uuid user_id FK
202+
uuid spot_id FK "ユーザー×スポットでユニーク(トグル)"
203+
}
204+
visit_plan_lists {
205+
uuid id PK
206+
uuid user_id FK
207+
uuid spot_type_id FK
208+
text title
209+
text description
210+
date start_date
211+
date end_date
212+
}
213+
visit_plan_list_items {
214+
uuid id PK
215+
uuid list_id FK
216+
uuid spot_id FK "リスト×スポットでユニーク"
217+
integer seq "リスト内の並び順"
218+
}
219+
reviews {
220+
uuid id PK
221+
uuid user_id FK
222+
uuid spot_id FK
223+
text body
224+
text visibility "public / private"
225+
}
226+
visit_plan_lists ||--o{ visit_plan_list_items : ""
227+
```
228+
229+
- **`visits` は同一スポットへの複数回訪問を許容する**(トグルではない)
230+
- **`visit_plans`(1スポットの予定)と `visit_plan_lists`(順序付きの旅程)は独立**
231+
訪問を記録すると `visit_plans` からは自動で消える
232+
- **`reviews` は掲示板方式**(1ユーザーが同じスポットに何件でも書ける)。機能自体の
233+
ON/OFF は種別ごとに `spot_type_settings``reviews_enabled` で切り替える。
234+
シリーズ表示ロジックには `reviews` を一切参照させない
235+
236+
### 索引
237+
238+
ユニーク制約・主キー由来のものを除いた、検索用の索引。
239+
240+
| テーブル | 索引 | 何のため |
241+
|---|---|---|
242+
| spots | `region` / `series` / `spot_type_id` | 地図・一覧の絞り込み |
243+
| spots | `categories`(GIN) | 複数カテゴリの包含検索(`&&`) |
244+
| spots | `(spot_type_id, key)` ユニーク(key が null 以外) | CSV・ルートからの参照キー |
245+
| spot_deletions | `spot_type_id` | 還元用エクスポートの抽出 |
246+
| spot_routes | `series` | シリーズ絞り込みとの連動 |
247+
| spot_route_points | `spot_id` | スポットからの逆引き |
248+
| visits / spot_hides / visit_plans | `user_id` / `spot_id` | ユーザーの記録の取得と逆引き |
249+
| visit_plan_lists | `user_id` / `spot_type_id` | 旅程の一覧 |
250+
| visit_plan_list_items | `list_id` / `spot_id` | 旅程の中身と逆引き |
251+
| reviews | `spot_id` | スポット詳細の口コミ一覧 |
252+
253+
## 変更手順
254+
255+
1. **[`db/init/01_schema.sql`](../db/init/01_schema.sql) を直す**(唯一の定義。
256+
追加分を別の初期化ファイルに切り出さない)
257+
2. **同じコミットで [`db/migrations/`](../db/migrations/) に移行スクリプトを足す**
258+
(`<連番>_<内容>.sql`。全文 idempotent にする。`begin`/`commit`
259+
`schema_migrations` への insert は書かない —— `db/entrypoint.sh` が受け持つ。
260+
詳細は [`db/migrations/README.md`](../db/migrations/README.md))
261+
3. **この文書も同じコミットで更新する**(テーブル・列・索引・制約・関係の変更)
262+
4. 適用は `docker compose up` で自動(`db-migrate` サービス)。現物を確かめるなら
263+
`docker compose exec db psql -U travel_log -d travel_log -c '\d+ spots'`

0 commit comments

Comments
 (0)