Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a29908c
type setting
amytzeng May 16, 2026
b7fd0aa
local storage and mock authapi
amytzeng May 16, 2026
96573ec
auth context and provider
amytzeng May 16, 2026
7444ced
AppLayout
amytzeng May 16, 2026
76165d3
fake login page
amytzeng May 16, 2026
90a6302
set name: admin
amytzeng May 16, 2026
7beb534
check LINE account
amytzeng May 16, 2026
65186df
route to column_setting
amytzeng May 16, 2026
67d9ddb
router
amytzeng May 16, 2026
a53bbd6
update .env.example, docker.yml
amytzeng May 16, 2026
2f50e31
feat: integrate Supabase authentication and user session management
DDTTYYSS May 16, 2026
30bf411
Merge remote-tracking branch 'origin/main' into albert-oauth
DDTTYYSS Jun 1, 2026
06654de
feat: integrate Supabase authentication and store management
DDTTYYSS Jun 1, 2026
b32c750
feat: enhance store management with LINE integration and provisioning
DDTTYYSS Jun 6, 2026
36329c1
Merge remote-tracking branch 'origin/main' into albert-oauth
DDTTYYSS Jun 6, 2026
793e21b
Merge remote-tracking branch 'origin/main' into page/onboarding
amytzeng Jun 6, 2026
6124f0e
fix: finished merging
DDTTYYSS Jun 6, 2026
6457bd5
Merge remote-tracking branch 'origin/main' into page/onboarding
amytzeng Jun 6, 2026
6f6cd9c
feat: implement multi-store order field configuration and enhance API
DDTTYYSS Jun 6, 2026
5d31b91
feat: transition to OAuth single-store mode for backend and frontend
DDTTYYSS Jun 6, 2026
3e61742
docs: update README with instructions for changing store owner Gmail
DDTTYYSS Jun 6, 2026
039a280
frontend
amytzeng Jun 6, 2026
220b9be
Merge branch 'page/onboarding' into albert-oauth
DDTTYYSS Jun 6, 2026
43d1cff
Potential fix for pull request finding
DDTTYYSS Jun 6, 2026
ca9ec2c
Merge branch 'main' into albert-oauth
amytzeng Jun 6, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# store provision secrets (copy from stores.provision.example.json)
**/stores.provision.json

# .env
**/.env*
**/.venv_ben
Expand Down
188 changes: 155 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,38 +97,159 @@ cp backend/.env.example backend/.env
### Schema 與遷移

- 多租戶表:`store`、`customer`、`chat_room`、`chat_message`、`order`、`order_draft`、`payment`、`payment_method`、`notification` 等。
- 破壞性遷移 `**f4e8bb2a9031**` 會 DROP 舊表後重建,**僅適合新庫或願意清空資料時**執行。
- 套用遷移( `backend`、已啟用 venv):
- 破壞性遷移 **`f4e8bb2a9031`** 會 DROP 舊表後重建,**僅適合新庫或願意清空資料時**執行。
- 套用遷移(**必須用 `backend/venv`**,不要用 conda `(base)` 的 `alembic`,在 macOS 上常會 `malloc: double free` 後 abort):

```bash
cd backend
alembic upgrade head
make migrate
# 或:./venv/bin/alembic upgrade head
```

- Docker 啟動時預設 `**SKIP_ALEMBIC_ON_START=1**`(不自動跑 Alembic),請在 Supabase 上自行確認 revision 或手動執行上述指令。

### 是否有「預設店家」?
### 多店家 LINE(`store.slug` = webhook `destination`)

程式**沒有** `default_store` 欄位或設定檔。新 LINE 顧客會掛到 `**store` 表中 `id` 最小的一筆**(`get_first_store_id()`)。
每個 Official Account 對應一筆 `store`

因此開發/測試前,資料庫裡**至少要有一筆 `store`**。若沒有,種子資料與 LINE 建立顧客會失敗(例如:`資料庫中沒有 store,請先在 Supabase 建立店家資料。`)。
| 欄位 | 說明 |
|------|------|
| `slug` | 與 LINE webhook JSON 的 **`destination`** 相同(channel user id,如 `U4b…`) |
| `line_channel_access_token` | 該店 Messaging API token |
| `line_channel_secret` | Webhook 簽章用 secret |

所有 channel 的 Webhook URL 可相同:`https://<PUBLIC_BASE_URL>/callback`。後端依 `destination` 查 `store.slug` 決定店家與 token。

遷移後請執行 `alembic upgrade head`(revision `f1a2b3c4d5e6` 新增 LINE 欄位)。

後台 API(聊天室、訂單、付款方式等)僅回傳**登入店主**所綁定 `store` 的資料(`get_current_store`)。

在 Supabase SQL Editor 可手動插入(`owner_auth_user_id` 請改成你的 Supabase Auth UUID,或開發用固定 UUID):
開發/測試前資料庫**至少要有一筆 `store`**。若 webhook 的 `destination` 尚無對應 `slug`,且 `.env` 仍有 `LINE_CHANNEL_SECRET`,會暫時 fallback 到 `id` 最小的 store(方便遷移前測試)。

```sql
INSERT INTO public.store (name, slug, timezone, active, created_at, updated_at, owner_auth_user_id)
VALUES (
'開發用店家',
'dev-local',
'Asia/Taipei',
true,
NOW(),
NOW(),
'00000000-0000-4000-8000-000000000001'::uuid
);
### 自動建立/更新店家(JSON → Supabase)

不必手動寫 SQL 或自己查 `destination`:腳本會用你提供的 **Channel access token** 呼叫 LINE `GET /v2/bot/info`,把回傳的 **`userId`** 寫入 `store.slug`(與 webhook `destination` 相同)。

#### 1. 準備 JSON(勿 commit 含 secret 的檔案)

```bash
cd backend
cp config/stores.provision.example.json config/stores.provision.json
```

編輯 [`backend/config/stores.provision.json`](backend/config/stores.provision.json)(此檔已 gitignore):

```json
{
"stores": [
{
"name": "奇美花店",
"owner_email": "owner@gmail.com",
"line_channel_access_token": "貼上 Messaging API Channel access token",
"line_channel_secret": "貼上 Channel secret",
"timezone": "Asia/Taipei",
"active": true
}
]
}
```

**你目前的 Supabase(已連線驗證)**:Alembic `f4e8bb2a9031`,已有 **1 筆 store**(`id=1`,`slug=dev-local`),以及測試用 customer / chat_room / order 等資料;LINE 新用戶會歸到這家店。
| 欄位 | 必填 | 說明 |
|------|------|------|
| `name` | 是 | 店名 |
| `owner_email` | 是 | 店主 Gmail(小寫);**首次用此帳號登入後台**時會自動綁定該 store |
| `line_channel_access_token` | 是 | 用來寫入 DB,並向 LINE 查 bot `userId` → `slug` |
| `line_channel_secret` | 是 | Webhook 簽章驗證 |
| `slug` | 否 | 可省略;若填了必須與 token 查到的 `userId` 一致 |
| `timezone` / `active` | 否 | 預設 `Asia/Taipei` / `true` |

多家店:在 `"stores"` 陣列再加一個物件即可。

#### 2. 確認 `backend/.env` 的 `DATABASE_URL` 指向目標 Supabase

#### 3. 執行指令(請用 `backend/venv`)

先確認 schema 已 migrate:

```bash
cd backend
make migrate
```

**試跑**(只查 LINE、不寫入資料庫):

```bash
PYTHONPATH=. ./venv/bin/python scripts/provision_stores.py \
--file config/stores.provision.json \
--dry-run
```

**寫入 Supabase**:

```bash
make provision-stores
```

或:

```bash
PYTHONPATH=. ./venv/bin/python scripts/provision_stores.py \
--file config/stores.provision.json
```

#### 4. 腳本實際會做什麼

對 JSON 裡每一筆 store:

1. 用 `line_channel_access_token` 呼叫 LINE → 取得 `userId` 當 `slug`
2. 以 `owner_email`(或既有 `slug`)在 `store` 表 **新增或更新** 一列
3. 寫入 `line_channel_access_token`、`line_channel_secret`、`name` 等
4. **不會**建立 Supabase Auth 帳號;`owner_auth_user_id` 仍為空,直到店主用 `owner_email` 登入一次

終端機會印出例如:`created store id=2 slug=Uabc...` 或 `updated store id=1`。

#### 5. 之後

- LINE Developers:各 channel 的 Webhook URL 設為 `https://<PUBLIC_BASE_URL>/callback`(可相同)
- 店主用 **`owner_email`** 的 Google 登入後台 → 只看自己店的聊天與訂單

選用:更新 token 後再跑一次 `make provision-stores`;若要強制店主重新綁定,加上 `--reset-owner-binding`(會清掉 `owner_auth_user_id`)。

#### 6. 更換店主 Gmail

每家店僅允許 **一個** Google 帳號登入後台(`owner_email` ↔ `owner_auth_user_id` 1:1)。若要改為另一個 Gmail 管理同一個 LINE 官方帳號,依是否已有人登入過而定:

**尚未有人登入**(`owner_auth_user_id` 仍為空):

1. 編輯 [`backend/config/stores.provision.json`](backend/config/stores.provision.json),將 `owner_email` 改為新 Gmail
2. 執行 `make provision-stores`
3. 新 Gmail 首次登入後台即可自動綁定該店

**已有人登入過**(store 已綁定舊 Google 帳號):

1. 將 `owner_email` 改為新 Gmail
2. 執行 provision 並加上 `--reset-owner-binding`,以清除舊帳號綁定:

```bash
cd backend
PYTHONPATH=. ./venv/bin/python scripts/provision_stores.py \
--file config/stores.provision.json \
--reset-owner-binding
```

3. 新 Gmail 登入後台一次,完成重新綁定
4. 舊 Gmail 將無法再存取該店

注意事項:

| 項目 | 說明 |
|------|------|
| LINE Bot 不受影響 | 顧客對話、訂單資料不變;僅後台登入帳號改變 |
| 新 Gmail 須唯一 | 每個 `owner_email` 只能對應一家店(`uq_store_owner_email`) |
| 未加 `--reset-owner-binding` | 只改 `owner_email` **不會**撤銷舊帳號;舊 Google 仍可依 `owner_auth_user_id` 登入 |
| Supabase | 不需手動建立新 Gmail 的 Auth 帳號;首次 Google 登入時自動處理 |

手動 SQL 仍可用:[`backend/docs/manual_migration_f1a2b3c4d5e6.sql`](backend/docs/manual_migration_f1a2b3c4d5e6.sql)。

### 測試資料

Expand Down Expand Up @@ -163,31 +284,32 @@ PYTHONPATH=. python app/seeds/seed_all.py

---

## 多店後台 API 與欄位設定
## 後台 API 與欄位設定(OAuth 單店)

### 目前店家(Staff Dashboard)

後台 React 應用在 Navbar 選店,將 `active-store-id` 寫入 `localStorage`,並由 axios 對下列 API 帶 **`X-Store-Id: <storeId>`**(亦可改用 query `?store_id=`,header 優先):
登入後,後端以 **`get_current_store`** 將 Supabase JWT 解析為店主綁定的唯一 `store`(1:1)。前端 Navbar 顯示店名(`GET /stores/me`),**不需**手動選店或帶 `X-Store-Id`。

| 端點 | 說明 |
|------|------|
| `GET /stores` | 選店列表(`id`, `name`, `slug`),**不需** `X-Store-Id` |
| `GET /orders` | 僅該店訂單(經 `order` → `chat_room.store_id`) |
| `GET /chat_rooms` | 僅該店聊天室 |
| `GET /stats` | 僅該店統計 |
| `GET/PUT /stores/{store_id}/order-field-config` | 路徑 `store_id` 必須與 header/query 一致,否則 403 |
| `GET /stores/me` | 登入店主綁定的店家(需 Bearer JWT) |
| `GET /stores` | 全部 active 店列表(管理/開發用,前端不再使用) |
| `GET /orders` | 僅綁定店訂單 |
| `GET /chat_rooms` | 僅綁定店聊天室 |
| `GET /stats` | 僅綁定店統計 |
| `GET/PUT /store/order-field-config/default` | 綁定店的欄位顯示設定(前端使用) |

缺少店家 context 時回 **400**;店家不存在回 **404**。
帳號未綁定店時回 **403**;JWT 無效或過期回 **401**。

開發用舊端點 `GET/PUT /store/order-field-config/default`(取 DB 第一筆 store)仍保留,**正式前端已改打** `/stores/{id}/order-field-config`。
舊多店設計的 `X-Store-Id` header 與 `GET/PUT /stores/{store_id}/order-field-config` 仍保留於後端,但前端已改為 OAuth 單店模式

### DOCX 工單

`GET /orders/{order_id}.docx` 依訂單所屬 `chat_room.store_id` 讀欄位設定;預設模板為 `backend/docs/工單模板.docx`(可用環境變數 `DOCX_TEMPLATE_FILE` 覆寫)。占位符使用 catalog key(例如 `{{ customer_name }}`、`{{ item }}`),隱藏欄位填空字串。若自行編輯 Word 模板,請用 docxtpl 語法 `{{ 變數名 }}`,變數名須與 catalog key 一致。

### 安全說明(重要)
### 安全說明

目前後台**沒有登入/授權**;`X-Store-Id` 屬「信任前端帶入」模式,任何人只要能呼叫 API 即可指定店家。正式上線前應將 `store_id` 與 `owner_auth_user_id`(或 Supabase Auth JWT)綁定。LINE Bot 新顧客仍使用 `get_first_store_id()` 掛到最小 `id` 的 store,與後台選店無關
後台 API 需 Supabase Auth JWT;`get_current_store` 將 `owner_auth_user_id` 與登入帳號綁定,僅能存取自己的店。首次登入時以 Gmail 認領 `owner_email` 相符且尚未綁定的 store。LINE Bot 新顧客仍使用 `get_first_store_id()` 掛到最小 `id` 的 store,與後台無關

### 相關測試

Expand Down Expand Up @@ -250,7 +372,7 @@ docker compose up
ngrok http 8000
```

將 LINE Webhook 指到 `https://<ngrok-id>.ngrok.io/callback`,並把 `PUBLIC_BASE_URL` 改成對應的 https 基底(圖片 URL 用)。
將 LINE Webhook 指到 `https://<ngrok-id>.ngrok.io/callback`(各 channel 可用同一 URL),並把 `PUBLIC_BASE_URL` 改成對應的 https 基底(圖片 URL 用)。記得把 webhook 的 `destination` 寫入該店的 `store.slug`

---

Expand Down Expand Up @@ -306,11 +428,11 @@ pytest tests/test_contract_smoke.py

- Vite、TanStack Query、React Router(`/`、`/orders`、`/messages`、`/stats`、`/settings/order-fields`)
- `frontend/.env`:`VITE_API_BASE_URL`(預設 `http://localhost:8000`)
- `StoreContext` + Navbar 選店 → axios `X-Store-Id`;欄位設定 per-store:`order-display-config:{storeId}`
- `StoreContext` + Navbar 顯示綁定店名(`GET /stores/me`);欄位設定 per-store:`order-display-config:{storeId}`

### 重構後手動 smoke

- Navbar 選店後,Network 中 `/orders`、`/chat_rooms`、`/stats` 帶正確 `X-Store-Id`;切店後列表與統計數字改變
- 登入後 Navbar 顯示正確店名;Network 中 `/orders`、`/chat_rooms`、`/stats`、`/store/order-field-config/default` 皆 200
- 首頁訂單表與統計可載入;刪除訂單後列表刷新。
- `Messages`:切換聊天室、送訊、右側草稿面板、「建立新訂單」。
- DOCX 下載、CSV 瀏覽器下載。
Expand Down
14 changes: 12 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
OPENAI_API_KEY=
# 建議寫入 store 表;以下僅開發 fallback(webhook 尚無 slug 對應時)
LINE_CHANNEL_ACCESS_TOKEN=
LINE_CHANNEL_SECRET=
# 開發用:LINE 傳入「與此字串完全相同」的訊息時,刪除該聊天室與顧客資料。留空則不啟用。
LINE_TEST_RESET_PHRASE=
MEM0_API_KEY=
MEM0_BASE_URL=

# Supabase
DATABASE_ALEM_URL=postgresql+psycopg2://postgres.<your-project-ref>:<your-password>@<your-region>.pooler.supabase.com:5432/postgres?sslmode=require
# Supabase(App 可用 pooler :5432)
DATABASE_URL=postgresql+asyncpg://postgres.<your-project-ref>:<your-password>@<your-region>.pooler.supabase.com:5432/postgres?ssl=require
# Alembic / provision-stores:未設 DIRECT 時會自動把 :5432 改為 transaction pooler :6543
DATABASE_ALEM_DIRECT_URL=postgresql+psycopg2://postgres.<your-project-ref>:<your-password>@db.<your-project-ref>.supabase.co:5432/postgres?sslmode=require
DATABASE_ALEM_URL=postgresql+psycopg2://postgres.<your-project-ref>:<your-password>@<your-region>.pooler.supabase.com:5432/postgres?sslmode=require
# 選填;provision-stores 直連 async(多數本機需 Supabase IPv4 add-on 才能連 db.<ref>.supabase.co)
# DATABASE_DIRECT_URL=

# Supabase Auth(驗證前端登入帶來的 token;見 Project Settings → API)
SUPABASE_URL=https://<your-project-ref>.supabase.co
SUPABASE_ANON_KEY=<your-anon-key>
SUPABASE_JWT_SECRET=<your-jwt-secret>

# 僅在本機不用 Docker、且要連 docker 舊版 db 容器時才需要
# POSTGRES_DB=flower
Expand Down
22 changes: 15 additions & 7 deletions backend/Makefile
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
# Makefile

# 標記這些目標為 phony,不對應到實際檔案
.PHONY: all install migrate run test contract-check
.PHONY: all install migrate provision-stores run test contract-check

# 預設目標:依序執行 install → migrate → run
all: install migrate run

# 安裝相依套件
PYTHON ?= ./venv/bin/python
ALEMBIC ?= ./venv/bin/alembic
export PYTHONPATH := .

# 安裝相依套件(請用 backend/venv,勿用 conda base 跑 alembic,易觸發 macOS double free)
install:
pip install -r requirements.txt
$(PYTHON) -m pip install -r requirements.txt

# 執行 Alembic 資料庫遷移
migrate:
alembic upgrade head
$(ALEMBIC) upgrade head

# 從 config/stores.provision.json 建立/更新 store(含 LINE slug 自動查詢)
provision-stores:
$(PYTHON) scripts/provision_stores.py --file config/stores.provision.json

# 啟動 Uvicorn 開發伺服器
run:
uvicorn app.main:app --reload
$(PYTHON) -m uvicorn app.main:app --reload

# 執行後端測試
test:
pytest tests
$(PYTHON) -m pytest tests

# 契約守門測試(不依賴資料庫)
contract-check:
pytest tests/test_openapi_contract.py
$(PYTHON) -m pytest tests/test_openapi_contract.py
24 changes: 24 additions & 0 deletions backend/alembic/versions/a9f3c2d1e4b7_supabase_bridge_revision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""bridge revision recorded on Supabase (not in earlier repo commits)

Revision ID: a9f3c2d1e4b7
Revises: e2f1a4b5c6d7
Create Date: 2026-06-04

No schema changes — aligns local Alembic graph with existing alembic_version rows.
"""

from alembic import op


revision = "a9f3c2d1e4b7"
down_revision = "e2f1a4b5c6d7"
branch_labels = None
depends_on = None
Comment on lines +13 to +16


def upgrade() -> None:
pass


def downgrade() -> None:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""add store LINE channel credentials

Revision ID: f1a2b3c4d5e6
Revises: e2f1a4b5c6d7
Create Date: 2026-06-04

store.slug holds LINE webhook destination (channel user id).
"""
Comment on lines +1 to +8

from alembic import op
import sqlalchemy as sa


revision = "f1a2b3c4d5e6"
down_revision = "a9f3c2d1e4b7"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
"store",
sa.Column("line_channel_access_token", sa.String(), nullable=True),
)
op.add_column(
"store",
sa.Column("line_channel_secret", sa.String(), nullable=True),
)


def downgrade() -> None:
op.drop_column("store", "line_channel_secret")
op.drop_column("store", "line_channel_access_token")
Loading