diff --git a/.gitignore b/.gitignore index 4b2eade..0d9ead8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# store provision secrets (copy from stores.provision.example.json) +**/stores.provision.json + # .env **/.env* **/.venv_ben diff --git a/README.md b/README.md index cab732b..151ba31 100644 --- a/README.md +++ b/README.md @@ -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:///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:///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)。 ### 測試資料 @@ -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: `**(亦可改用 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,與後台無關。 ### 相關測試 @@ -250,7 +372,7 @@ docker compose up ngrok http 8000 ``` -將 LINE Webhook 指到 `https://.ngrok.io/callback`,並把 `PUBLIC_BASE_URL` 改成對應的 https 基底(圖片 URL 用)。 +將 LINE Webhook 指到 `https://.ngrok.io/callback`(各 channel 可用同一 URL),並把 `PUBLIC_BASE_URL` 改成對應的 https 基底(圖片 URL 用)。記得把 webhook 的 `destination` 寫入該店的 `store.slug`。 --- @@ -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 瀏覽器下載。 diff --git a/backend/.env.example b/backend/.env.example index f3e8d5b..96545b9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,5 @@ OPENAI_API_KEY= +# 建議寫入 store 表;以下僅開發 fallback(webhook 尚無 slug 對應時) LINE_CHANNEL_ACCESS_TOKEN= LINE_CHANNEL_SECRET= # 開發用:LINE 傳入「與此字串完全相同」的訊息時,刪除該聊天室與顧客資料。留空則不啟用。 @@ -6,9 +7,18 @@ LINE_TEST_RESET_PHRASE= MEM0_API_KEY= MEM0_BASE_URL= -# Supabase -DATABASE_ALEM_URL=postgresql+psycopg2://postgres.:@.pooler.supabase.com:5432/postgres?sslmode=require +# Supabase(App 可用 pooler :5432) DATABASE_URL=postgresql+asyncpg://postgres.:@.pooler.supabase.com:5432/postgres?ssl=require +# Alembic / provision-stores:未設 DIRECT 時會自動把 :5432 改為 transaction pooler :6543 +DATABASE_ALEM_DIRECT_URL=postgresql+psycopg2://postgres.:@db..supabase.co:5432/postgres?sslmode=require +DATABASE_ALEM_URL=postgresql+psycopg2://postgres.:@.pooler.supabase.com:5432/postgres?sslmode=require +# 選填;provision-stores 直連 async(多數本機需 Supabase IPv4 add-on 才能連 db..supabase.co) +# DATABASE_DIRECT_URL= + +# Supabase Auth(驗證前端登入帶來的 token;見 Project Settings → API) +SUPABASE_URL=https://.supabase.co +SUPABASE_ANON_KEY= +SUPABASE_JWT_SECRET= # 僅在本機不用 Docker、且要連 docker 舊版 db 容器時才需要 # POSTGRES_DB=flower diff --git a/backend/Makefile b/backend/Makefile index 4bfd67c..8ac5d2a 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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 diff --git a/backend/alembic/versions/a9f3c2d1e4b7_supabase_bridge_revision.py b/backend/alembic/versions/a9f3c2d1e4b7_supabase_bridge_revision.py new file mode 100644 index 0000000..e1d2356 --- /dev/null +++ b/backend/alembic/versions/a9f3c2d1e4b7_supabase_bridge_revision.py @@ -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 + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/backend/alembic/versions/f1a2b3c4d5e6_add_store_line_credentials.py b/backend/alembic/versions/f1a2b3c4d5e6_add_store_line_credentials.py new file mode 100644 index 0000000..319e338 --- /dev/null +++ b/backend/alembic/versions/f1a2b3c4d5e6_add_store_line_credentials.py @@ -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). +""" + +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") diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..e1ce9eb --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from fastapi import Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.deps import get_current_user +from app.models.chat import ChatRoom +from app.models.order import Order +from app.models.payment import PaymentMethod +from app.models.store import Store + +# 放在獨立模組(非 deps.py)以避免 deps → database → deps 的循環匯入。 + + +async def _store_by_auth_uuid(db: AsyncSession, auth_uuid: str) -> Store | None: + result = await db.execute(select(Store).where(Store.owner_auth_user_id == auth_uuid)) + return result.scalar_one_or_none() + + +async def get_current_store( + user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Store: + """將登入的 Google 帳號解析為其專屬 Store(1:1)。 + + 流程: + 1. 以 Supabase auth user id 直接查(已綁定者走的快取路徑)。 + 2. 查不到 → 以 email 認領一筆尚未綁定(owner_auth_user_id IS NULL)的 store, + 寫入 owner_auth_user_id 完成「首次登入綁定」。 + 3. 仍查不到 → 403(管理者尚未在 Supabase 指派此 Gmail)。 + """ + auth_uuid = user.get("id") + email = (user.get("email") or "").strip().lower() + if not auth_uuid: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth user") + + store = await _store_by_auth_uuid(db, auth_uuid) + if store is not None: + return store + + # 首次登入:以 email 認領一筆未綁定的 store + result = await db.execute( + select(Store).where( + Store.owner_email == email, + Store.owner_auth_user_id.is_(None), + ) + ) + store = result.scalar_one_or_none() + if store is not None: + store.owner_auth_user_id = auth_uuid + try: + await db.commit() + await db.refresh(store) + return store + except IntegrityError: + # 並發首次登入:另一個請求已搶先綁定,回退改走 UUID 查詢 + await db.rollback() + store = await _store_by_auth_uuid(db, auth_uuid) + if store is not None: + return store + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account not linked to a store. Contact your administrator.", + ) + + +async def get_chat_room_for_store( + db: AsyncSession, room_id: int, store: Store +) -> ChatRoom: + from app.services.message_service import get_chat_room_by_room_id + + room = await get_chat_room_by_room_id(db, room_id) + if not room: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Chat room not found", + ) + if room.store_id != store.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Chat room does not belong to your store", + ) + return room + + +async def get_order_for_store(db: AsyncSession, order_id: int, store: Store) -> Order: + from app.repositories.order_repository import get_order_by_id + from app.services.message_service import get_chat_room_by_room_id + + order = await get_order_by_id(db, order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + room = await get_chat_room_by_room_id(db, order.room_id) + if not room or room.store_id != store.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Order does not belong to your store", + ) + return order + + +def assert_payment_method_for_store(method: PaymentMethod, store: Store) -> None: + if method.store_id != store.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Payment method does not belong to your store", + ) diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index aa4e8b1..7c286d4 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -2,11 +2,16 @@ from functools import lru_cache +import httpx +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer from linebot import LineBotApi, WebhookHandler from openai import OpenAI from app.core.settings import Settings, load_settings +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token") + @lru_cache(maxsize=1) def get_settings() -> Settings: @@ -30,3 +35,22 @@ def get_line_webhook_handler() -> WebhookHandler: settings = get_settings() return WebhookHandler(settings.line_channel_secret) + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict: + settings = get_settings() + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{settings.supabase_url}/auth/v1/user", + headers={ + "Authorization": f"Bearer {token}", + "apikey": settings.supabase_anon_key, + }, + timeout=5.0, + ) + except Exception: + raise HTTPException(status_code=503, detail="Auth service unavailable") + if resp.status_code != 200: + raise HTTPException(status_code=401, detail="Invalid or expired token") + return resp.json() + diff --git a/backend/app/core/line_client.py b/backend/app/core/line_client.py new file mode 100644 index 0000000..f1576c7 --- /dev/null +++ b/backend/app/core/line_client.py @@ -0,0 +1,42 @@ +"""Per-store LINE Messaging API clients.""" + +from __future__ import annotations + +from linebot import LineBotApi, WebhookHandler + +from app.core.settings import load_settings +from app.models.store import Store + + +def store_has_line_config(store: Store) -> bool: + return bool( + (store.slug or "").strip() + and (store.line_channel_access_token or "").strip() + and (store.line_channel_secret or "").strip() + ) + + +def _access_token(store: Store) -> str: + token = (store.line_channel_access_token or "").strip() + if not token: + token = (load_settings().line_channel_access_token or "").strip() + if not token: + raise ValueError(f"Store {store.id} has no LINE access token") + return token + + +def _channel_secret(store: Store) -> str: + secret = (store.line_channel_secret or "").strip() + if not secret: + secret = (load_settings().line_channel_secret or "").strip() + if not secret: + raise ValueError(f"Store {store.id} has no LINE channel secret") + return secret + + +def line_bot_api_for_store(store: Store) -> LineBotApi: + return LineBotApi(_access_token(store)) + + +def webhook_handler_for_store(store: Store) -> WebhookHandler: + return WebhookHandler(_channel_secret(store)) diff --git a/backend/app/core/settings.py b/backend/app/core/settings.py index 2ed5c9f..b80d269 100644 --- a/backend/app/core/settings.py +++ b/backend/app/core/settings.py @@ -17,6 +17,11 @@ class Settings: line_test_reset_phrase: str | None # 建置圖片給對外 URL(LINE 推圖、後台顯示本機上傳圖);ngrok/正式網域請改此值 public_base_url: str + # Supabase Auth:驗證前端帶來的 Bearer token(見 deps.get_current_user) + supabase_url: str | None + supabase_anon_key: str | None + # 預留:未來本機驗證 JWT(HS256)時使用,目前未用到 + supabase_jwt_secret: str | None def _postgres_connection_params() -> tuple[str, str, str, str, str]: @@ -66,12 +71,46 @@ def resolve_database_url() -> str: return "sqlite+aiosqlite:///messages.db" +def resolve_provision_database_url() -> str: + """ + Async URL for one-off CLI (provision_stores). + Prefers DATABASE_DIRECT_URL; else auto-rewrites pooler → db..supabase.co. + """ + load_dotenv() + from app.core.supabase_db_url import pooler_session_to_transaction, pooler_url_to_direct + + direct = os.getenv("DATABASE_DIRECT_URL", "").strip() + if direct: + url = direct + if url.startswith("postgres://"): + url = url.replace("postgres://", "postgresql+asyncpg://", 1) + if "postgresql+asyncpg" in url: + url = url.replace("sslmode=require", "ssl=require") + return url + + base = resolve_database_url() + return ( + pooler_session_to_transaction(base, for_asyncpg=True) + or pooler_url_to_direct(base, for_asyncpg=True) + or base + ) + + def resolve_database_alem_url() -> str: - """Alembic 用同步 driver;DATABASE_ALEM_URL 優先。""" + """Alembic 用同步 driver;直連優先,否則自動將 pooler 改為 db..supabase.co。""" load_dotenv() + from app.core.supabase_db_url import pooler_session_to_transaction, pooler_url_to_direct + + direct = os.getenv("DATABASE_ALEM_DIRECT_URL", "").strip() + if direct: + return direct explicit = os.getenv("DATABASE_ALEM_URL", "").strip() if explicit: - return explicit + return ( + pooler_session_to_transaction(explicit, for_asyncpg=False) + or pooler_url_to_direct(explicit, for_asyncpg=False) + or explicit + ) if any( os.getenv(k) for k in ("POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "POSTGRES_HOST", "POSTGRES_PORT") @@ -81,9 +120,13 @@ def resolve_database_alem_url() -> str: if fallback.startswith("sqlite+aiosqlite"): return "sqlite:///" + fallback.split("sqlite+aiosqlite:///", 1)[-1] if fallback.startswith("postgresql+asyncpg://"): - return fallback.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1) + fallback = fallback.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1) if fallback: - return fallback + return ( + pooler_session_to_transaction(fallback, for_asyncpg=False) + or pooler_url_to_direct(fallback, for_asyncpg=False) + or fallback + ) return "sqlite:///messages.db" @@ -99,6 +142,8 @@ def load_settings() -> Settings: phrase = os.getenv("LINE_TEST_RESET_PHRASE", "").strip() pub = os.getenv("PUBLIC_BASE_URL", "").strip().rstrip("/") public_base_url = pub if pub else "http://localhost:8000" + # deps 會組 f"{supabase_url}/auth/v1/user",故去掉尾端斜線避免雙斜線 + supabase_url = (os.getenv("SUPABASE_URL") or "").strip().rstrip("/") or None return Settings( openai_api_key=os.getenv("OPENAI_API_KEY"), line_channel_access_token=os.getenv("LINE_CHANNEL_ACCESS_TOKEN"), @@ -106,5 +151,8 @@ def load_settings() -> Settings: database_url=database_url, line_test_reset_phrase=phrase or None, public_base_url=public_base_url, + supabase_url=supabase_url, + supabase_anon_key=os.getenv("SUPABASE_ANON_KEY") or None, + supabase_jwt_secret=os.getenv("SUPABASE_JWT_SECRET") or None, ) diff --git a/backend/app/core/supabase_db_url.py b/backend/app/core/supabase_db_url.py new file mode 100644 index 0000000..0dd70e2 --- /dev/null +++ b/backend/app/core/supabase_db_url.py @@ -0,0 +1,91 @@ +"""Helpers to use Supabase direct DB host instead of session pooler (EMAXCONNSESSION).""" + +from __future__ import annotations + +import re +from uuid import uuid4 + + +def supabase_project_ref_from_url(url: str) -> str | None: + """Extract project ref from user like postgres. in connection URL.""" + m = re.search(r"postgres\.([a-zA-Z0-9]+)", url) + return m.group(1) if m else None + + +def is_supabase_pooler_url(url: str) -> bool: + return "pooler.supabase.com" in url + + +def is_supabase_transaction_pooler_url(url: str) -> bool: + return is_supabase_pooler_url(url) and ":6543" in url + + +def asyncpg_connect_args_for_url(url: str) -> dict: + """ + PgBouncer transaction mode (:6543) cannot reuse asyncpg prepared statements. + See: DuplicatePreparedStatementError / statement_cache_size=0. + """ + if "postgresql+asyncpg" in url and is_supabase_transaction_pooler_url(url): + return { + "statement_cache_size": 0, + "prepared_statement_cache_size": 0, + "prepared_statement_name_func": lambda: f"__asyncpg_{uuid4()}__", + } + return {} + + +def pooler_session_to_transaction(url: str, *, for_asyncpg: bool) -> str | None: + """ + Session pooler (:5432) caps concurrent clients (EMAXCONNSESSION). + Transaction pooler (:6543) suits short CLI / Alembic runs on the same host. + """ + if not is_supabase_pooler_url(url) or ":6543" in url: + return None + if ":5432" not in url: + return None + out = url.replace("pooler.supabase.com:5432", "pooler.supabase.com:6543", 1) + if for_asyncpg: + if out.startswith("postgres://"): + out = out.replace("postgres://", "postgresql+asyncpg://", 1) + if out.startswith("postgresql+psycopg2://"): + out = out.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1) + out = out.replace("sslmode=require", "ssl=require") + else: + if out.startswith("postgres://"): + out = out.replace("postgres://", "postgresql+psycopg2://", 1) + if out.startswith("postgresql+asyncpg://"): + out = out.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1) + out = out.replace("ssl=require", "sslmode=require") + return out + + +def pooler_url_to_direct(url: str, *, for_asyncpg: bool) -> str | None: + """ + Rewrite pooler host to db..supabase.co for migrations/CLI. + Returns None if URL is not a Supabase pooler URL or ref cannot be parsed. + """ + if not is_supabase_pooler_url(url): + return None + ref = supabase_project_ref_from_url(url) + if not ref: + return None + + out = re.sub( + r"@[^/]*pooler\.supabase\.com:\d+", + f"@db.{ref}.supabase.co:5432", + url, + count=1, + ) + if for_asyncpg: + if out.startswith("postgres://"): + out = out.replace("postgres://", "postgresql+asyncpg://", 1) + if out.startswith("postgresql+psycopg2://"): + out = out.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1) + out = out.replace("sslmode=require", "ssl=require") + else: + if out.startswith("postgres://"): + out = out.replace("postgres://", "postgresql+psycopg2://", 1) + if out.startswith("postgresql+asyncpg://"): + out = out.replace("postgresql+asyncpg://", "postgresql+psycopg2://", 1) + out = out.replace("ssl=require", "sslmode=require") + return out diff --git a/backend/app/models/store.py b/backend/app/models/store.py index 2a676f9..b86b4ba 100644 --- a/backend/app/models/store.py +++ b/backend/app/models/store.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, String +from sqlalchemy import Boolean, DateTime, String, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -13,17 +13,24 @@ class Store(Base): __tablename__ = "store" + __table_args__ = (UniqueConstraint("owner_email", name="uq_store_owner_email"),) id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String, nullable=False) + # LINE webhook destination (channel user id, e.g. U4b…); must match JSON "destination" slug: Mapped[str | None] = mapped_column(String, unique=True, nullable=True) + line_channel_access_token: Mapped[str | None] = mapped_column(String, nullable=True) + line_channel_secret: Mapped[str | None] = mapped_column(String, nullable=True) timezone: Mapped[str] = mapped_column(String, nullable=False, default="Asia/Taipei") active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=now_taipei_naive) updated_at: Mapped[datetime] = mapped_column( DateTime, default=now_taipei_naive, onupdate=now_taipei_naive ) - owner_auth_user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + # 管理者指派的店主 Gmail(小寫);首次登入時用來認領 store + owner_email: Mapped[str] = mapped_column(String, nullable=False) + # 首次登入時由 Supabase auth user id 綁定;未綁定前為 NULL(partial-unique 由 DB 端建立) + owner_auth_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) customers = relationship("Customer", back_populates="store") chat_rooms = relationship("ChatRoom", back_populates="store") diff --git a/backend/app/repositories/chat_repository.py b/backend/app/repositories/chat_repository.py index 3495fa1..3a2c2fb 100644 --- a/backend/app/repositories/chat_repository.py +++ b/backend/app/repositories/chat_repository.py @@ -23,13 +23,15 @@ async def get_latest_chat_message(db: AsyncSession, room_id: int) -> Optional[Ch return result.scalar_one_or_none() -async def list_chat_rooms(db: AsyncSession, store_id: int) -> list[ChatRoom]: +async def list_chat_rooms(db: AsyncSession, store_id: int | None = None) -> list[ChatRoom]: stmt = ( select(ChatRoom) .options(joinedload(ChatRoom.customer)) .where(ChatRoom.store_id == store_id) .order_by(ChatRoom.updated_at.desc()) ) + if store_id is not None: + stmt = stmt.where(ChatRoom.store_id == store_id) result = await db.execute(stmt) return result.scalars().all() diff --git a/backend/app/repositories/order_repository.py b/backend/app/repositories/order_repository.py index 0288117..a3f1a99 100644 --- a/backend/app/repositories/order_repository.py +++ b/backend/app/repositories/order_repository.py @@ -29,19 +29,31 @@ async def get_order_by_id(db: AsyncSession, order_id: int) -> Optional[Order]: return result.scalar_one_or_none() -async def list_active_orders(db: AsyncSession, store_id: int) -> list[Order]: - stmt = ( - select(Order) - .join(ChatRoom, Order.room_id == ChatRoom.id) - .where(Order.status != OrderStatus.CANCELLED, ChatRoom.store_id == store_id) - ) +async def list_active_orders( + db: AsyncSession, store_id: int | None = None +) -> list[Order]: + from app.models.chat import ChatRoom + + stmt = select(Order).where(Order.status != OrderStatus.CANCELLED) + if store_id is not None: + stmt = stmt.join(ChatRoom, Order.room_id == ChatRoom.id).where( + ChatRoom.store_id == store_id + ) result = await db.execute(stmt) return result.scalars().all() -async def list_all_orders(db: AsyncSession) -> list[Order]: +async def list_all_orders( + db: AsyncSession, store_id: int | None = None +) -> list[Order]: """All orders including CANCELLED (for dashboard「所有訂單」).""" + from app.models.chat import ChatRoom + stmt = select(Order) + if store_id is not None: + stmt = stmt.join(ChatRoom, Order.room_id == ChatRoom.id).where( + ChatRoom.store_id == store_id + ) result = await db.execute(stmt) return result.scalars().all() diff --git a/backend/app/repositories/payment_repository.py b/backend/app/repositories/payment_repository.py index 0c90f5b..36171c9 100644 --- a/backend/app/repositories/payment_repository.py +++ b/backend/app/repositories/payment_repository.py @@ -6,8 +6,12 @@ from app.models.payment import Payment, PaymentMethod -async def list_payment_methods(db: AsyncSession) -> list[PaymentMethod]: +async def list_payment_methods( + db: AsyncSession, store_id: int | None = None +) -> list[PaymentMethod]: stmt = select(PaymentMethod) + if store_id is not None: + stmt = stmt.where(PaymentMethod.store_id == store_id) result = await db.execute(stmt) return result.scalars().all() diff --git a/backend/app/repositories/store_repository.py b/backend/app/repositories/store_repository.py index 6460d7b..fdd58f2 100644 --- a/backend/app/repositories/store_repository.py +++ b/backend/app/repositories/store_repository.py @@ -1,8 +1,12 @@ from __future__ import annotations +import json + +from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.settings import Settings, load_settings from app.models.store import Store @@ -18,15 +22,160 @@ async def get_first_store_id(db: AsyncSession) -> int | None: async def get_seed_store_id(db: AsyncSession) -> int | None: - """Fake data default store: id=1 (store1), else slug 'store1', else smallest id.""" - result = await db.execute(select(Store.id).where(Store.id == 1).limit(1)) - store_id = result.scalar_one_or_none() - if store_id is not None: - return store_id + """Fake data default store: match env LINE credentials, else real slug from token, else smallest id.""" + settings = load_settings() + + secret = (settings.line_channel_secret or "").strip() + if secret: + result = await db.execute( + select(Store.id).where(Store.line_channel_secret == secret).limit(1) + ) + store_id = result.scalar_one_or_none() + if store_id is not None: + return store_id + + token = (settings.line_channel_access_token or "").strip() + if token: + result = await db.execute( + select(Store.id).where(Store.line_channel_access_token == token).limit(1) + ) + store_id = result.scalar_one_or_none() + if store_id is not None: + return store_id - result = await db.execute(select(Store.id).where(Store.slug == "store1").limit(1)) - store_id = result.scalar_one_or_none() - if store_id is not None: - return store_id + from app.utils.line_bot_info import fetch_line_bot_user_id + + try: + slug = await fetch_line_bot_user_id(token) + store = await get_store_by_slug(db, slug) + if store is not None: + return store.id + except ValueError: + pass return await get_first_store_id(db) + + +async def get_store_by_id(db: AsyncSession, store_id: int) -> Store | None: + result = await db.execute(select(Store).where(Store.id == store_id)) + return result.scalar_one_or_none() + + +async def get_store_by_slug(db: AsyncSession, slug: str) -> Store | None: + """Lookup by store.slug (= LINE webhook destination).""" + key = (slug or "").strip() + if not key: + return None + result = await db.execute(select(Store).where(Store.slug == key)) + return result.scalar_one_or_none() + + +def parse_webhook_destination(body_str: str) -> str: + try: + payload = json.loads(body_str) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid webhook JSON", + ) from e + destination = (payload.get("destination") or "").strip() + if not destination: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Missing destination in webhook body", + ) + return destination + + +async def resolve_store_for_webhook(db: AsyncSession, body_str: str) -> Store: + """ + Map LINE destination -> store.slug. + Dev fallback: if no row matches but env LINE_CHANNEL_SECRET verifies, use first store. + """ + from app.core.line_client import store_has_line_config + + destination = parse_webhook_destination(body_str) + store = await get_store_by_slug(db, destination) + if store is not None: + if not store.active: + raise HTTPException( + status_code=status.HTTP_503_FORBIDDEN, + detail="Store is inactive", + ) + settings = load_settings() + has_env = bool((settings.line_channel_secret or "").strip()) + if not store_has_line_config(store) and not has_env: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Store LINE credentials not configured", + ) + return store + + settings: Settings = load_settings() + secret = (settings.line_channel_secret or "").strip() + if secret: + store_id = await get_first_store_id(db) + if store_id is not None: + fallback = await get_store_by_id(db, store_id) + if fallback is not None: + return fallback + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No store for LINE destination: {destination}", + ) + + +async def upsert_store_from_provision( + db: AsyncSession, + *, + name: str, + owner_email: str, + slug: str, + line_channel_access_token: str, + line_channel_secret: str, + timezone: str = "Asia/Taipei", + active: bool = True, + reset_owner_binding: bool = False, +) -> tuple[Store, bool]: + """ + Insert or update store by owner_email, else by slug. + Returns (store, created). + """ + email = owner_email.strip().lower() + slug_key = slug.strip() + + result = await db.execute(select(Store).where(Store.owner_email == email)) + store = result.scalar_one_or_none() + if store is None: + result = await db.execute(select(Store).where(Store.slug == slug_key)) + store = result.scalar_one_or_none() + + if store is None: + store = Store( + name=name, + owner_email=email, + slug=slug_key, + line_channel_access_token=line_channel_access_token, + line_channel_secret=line_channel_secret, + timezone=timezone, + active=active, + owner_auth_user_id=None, + ) + db.add(store) + await db.flush() + return store, True + + store.name = name + store.owner_email = email + store.slug = slug_key + store.line_channel_access_token = line_channel_access_token + store.line_channel_secret = line_channel_secret + store.timezone = timezone + store.active = active + if reset_owner_binding: + store.owner_auth_user_id = None + await db.flush() + return store, False + + diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index 5c91864..6b83d39 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -22,8 +22,13 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[Customer]: return result.scalar_one_or_none() -async def get_user_by_line_uid(db: AsyncSession, line_uid: str) -> Optional[Customer]: - stmt = select(Customer).where(Customer.line_uid == line_uid) +async def get_user_by_line_uid( + db: AsyncSession, line_uid: str, store_id: int +) -> Optional[Customer]: + stmt = select(Customer).where( + Customer.line_uid == line_uid, + Customer.store_id == store_id, + ) result = await db.execute(stmt) return result.scalar_one_or_none() diff --git a/backend/app/routes/export_docx.py b/backend/app/routes/export_docx.py index a1f596e..cad8ee8 100644 --- a/backend/app/routes/export_docx.py +++ b/backend/app/routes/export_docx.py @@ -7,11 +7,13 @@ from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.auth import get_current_store, get_order_for_store from app.core.database import get_db +from app.models.store import Store from app.services.message_service import get_chat_room_by_room_id from app.services.order_field_config_service import get_effective_order_field_config from app.services.order_field_values import build_docx_render_context_full_catalog -from app.services.order_service import get_order, get_order_out_by_id +from app.services.order_service import get_order_out_by_id api_router = APIRouter() _DOCS_DIR = Path(__file__).resolve().parents[2] / "docs" @@ -21,10 +23,12 @@ @api_router.get("/orders/{order_id}.docx") -async def export_order_docx(order_id: int, db: AsyncSession = Depends(get_db)): - order_row = await get_order(db, order_id) - if not order_row: - raise HTTPException(status_code=404, detail="Order not found") +async def export_order_docx( + order_id: int, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + order_row = await get_order_for_store(db, order_id, store) order = await get_order_out_by_id(db, order_id) if not order: diff --git a/backend/app/routes/linebot.py b/backend/app/routes/linebot.py index 37f0d6c..ded88ab 100644 --- a/backend/app/routes/linebot.py +++ b/backend/app/routes/linebot.py @@ -4,17 +4,13 @@ from linebot.exceptions import InvalidSignatureError from linebot.models import MessageEvent, TextMessage, ImageMessage, StickerMessage, FollowEvent -from app.core.deps import get_line_webhook_handler from app.core.database import get_db -from app.repositories.store_repository import get_first_store_id -from app.services.user_service import get_user_by_line_uid, create_user -from app.services.message_service import get_chat_room_by_user_id, create_chat_room +from app.core.line_client import line_bot_api_for_store, webhook_handler_for_store +from app.models.store import Store +from app.repositories.store_repository import resolve_store_for_webhook from app.schemas.customer import CustomerCreate - -api_router = APIRouter() - -handler = get_line_webhook_handler() - +from app.services.message_service import get_chat_room_by_user_id, create_chat_room +from app.services.user_service import get_user_by_line_uid, create_user from app.usecases.linebot_flow import ( handle_incoming_text_message, handle_incoming_image_message, @@ -22,6 +18,8 @@ run_bot_flow, ) +api_router = APIRouter() + @api_router.post("/callback") async def callback(request: Request, db: AsyncSession = Depends(get_db)): @@ -32,64 +30,41 @@ async def callback(request: Request, db: AsyncSession = Depends(get_db)): body = await request.body() body_str = body.decode("utf-8") + store = await resolve_store_for_webhook(db, body_str) + try: - events = get_line_webhook_handler().parser.parse(body_str, signature) + events = webhook_handler_for_store(store).parser.parse(body_str, signature) except InvalidSignatureError: raise HTTPException(status_code=400, detail="Invalid signature") for event in events: - if not isinstance(event, MessageEvent): - continue - msg = event.message - if isinstance(msg, TextMessage): - await dispatch_line_text_message(event, db) - elif isinstance(msg, ImageMessage): - await dispatch_line_image_message(event, db) - elif isinstance(msg, StickerMessage): - await dispatch_line_sticker_message(event, db) + if isinstance(event, FollowEvent): + await handle_follow(event, store, db) + elif isinstance(event, MessageEvent): + msg = event.message + if isinstance(msg, TextMessage): + await handle_incoming_text_message(event, store, db) + elif isinstance(msg, ImageMessage): + await handle_incoming_image_message(event, store, db) + elif isinstance(msg, StickerMessage): + await handle_incoming_sticker_message(event, store, db) return PlainTextResponse("OK") -@handler.add(MessageEvent, message=TextMessage) -async def dispatch_line_text_message(event: MessageEvent, db: AsyncSession): - await handle_incoming_text_message(event, db) - - -@handler.add(MessageEvent, message=ImageMessage) -async def dispatch_line_image_message(event: MessageEvent, db: AsyncSession): - await handle_incoming_image_message(event, db) - - -@handler.add(MessageEvent, message=StickerMessage) -async def dispatch_line_sticker_message(event: MessageEvent, db: AsyncSession): - await handle_incoming_sticker_message(event, db) - - -@handler.add(FollowEvent) -async def handle_follow(event: FollowEvent, db: AsyncSession): - """ - 1. get or create user - 2. get or create chat room - 3. send welcome template message - """ - +async def handle_follow(event: FollowEvent, store: Store, db: AsyncSession): user_line_id = event.source.user_id - user = await get_user_by_line_uid(db, user_line_id) + user = await get_user_by_line_uid(db, user_line_id, store.id) if not user: - store_id = await get_first_store_id(db) - if store_id is None: - raise HTTPException( - status_code=503, - detail="No store configured. Create a store in Supabase first.", - ) user = await create_user( db, - CustomerCreate(line_uid=user_line_id, name="Profile Name", store_id=store_id), + CustomerCreate( + line_uid=user_line_id, name="Profile Name", store_id=store.id + ), ) - print(f"新使用者 {user_line_id} 已創建") + print(f"新使用者 {user_line_id} 已創建 (store={store.id})") else: - print(f"使用者 {user_line_id} 已存在") + print(f"使用者 {user_line_id} 已存在 (store={store.id})") chat_room = await get_chat_room_by_user_id(db, user.id) if not chat_room: @@ -97,4 +72,4 @@ async def handle_follow(event: FollowEvent, db: AsyncSession): print(f"新聊天室已創建,使用者 {user_line_id} 的聊天室 ID:{chat_room.id}") print("開始自動回覆流程") - await run_bot_flow(chat_room, "", event, db) + await run_bot_flow(chat_room, "", event, store, db) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 042deee..471d655 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -7,8 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.core.store_context import get_resolved_store_id +from app.core.auth import get_chat_room_for_store, get_current_store from app.core.deps import get_settings +from app.models.store import Store from app.schemas.chat import ( ChatMessageCreate, ChatMessageOut, @@ -32,31 +33,32 @@ @api_router.get("", response_model=List[ChatRoomOut]) async def list_chat_rooms( + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), - store_id: int = Depends(get_resolved_store_id), ): - return await get_chat_room_list(db, store_id) + return await get_chat_room_list(db, store_id=store.id) @api_router.get("/{room_id}/messages", response_model=List[ChatMessageOut]) async def get_messages( room_id: int, after: Optional[datetime] = None, - db: AsyncSession = Depends(get_db) + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), ): + await get_chat_room_for_store(db, room_id, store) return await get_chat_messages(db, room_id, after=after) @api_router.post("/{room_id}/messages/upload_image", response_model=StaffChatImageUploadOut) async def upload_staff_chat_image( room_id: int, + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), file: UploadFile = File(...), ): """本機選圖上傳:存檔後回傳絕對 URL(須設定可被 LINE 存取的 PUBLIC_BASE_URL,例如 ngrok HTTPS)。""" - room = await get_chat_room_by_room_id(db, room_id) - if not room: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat room not found") + room = await get_chat_room_for_store(db, room_id, store) ct = (file.content_type or "").split(";")[0].strip().lower() if ct not in _ALLOWED_IMAGE_CT: @@ -81,17 +83,22 @@ async def upload_staff_chat_image( @api_router.post("/{room_id}/messages", response_model=ChatMessageOut) async def post_message( - room_id: int, - message: ChatMessageCreate, - db: AsyncSession = Depends(get_db)): + room_id: int, + message: ChatMessageCreate, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + await get_chat_room_for_store(db, room_id, store) return await create_staff_message(db, room_id, message) - + @api_router.post("/{room_id}/switch_mode", response_model=dict) async def switch_mode( room_id: int, body: SwitchModeBody, + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), ): + await get_chat_room_for_store(db, room_id, store) await switch_chat_room_mode(db, room_id, body.stage) return {"message": "success"} \ No newline at end of file diff --git a/backend/app/routes/order_field_config.py b/backend/app/routes/order_field_config.py index 93fade9..630bec3 100644 --- a/backend/app/routes/order_field_config.py +++ b/backend/app/routes/order_field_config.py @@ -3,9 +3,9 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession +from app.core.auth import get_current_store from app.core.database import get_db -from app.core.store_context import get_resolved_store_id_with_path -from app.repositories.store_repository import get_first_store_id +from app.models.store import Store from app.schemas.order_field_config import OrderFieldConfigOut, OrderFieldConfigUpdate from app.services.order_field_config_service import ( get_order_field_config, @@ -15,48 +15,51 @@ api_router = APIRouter() +def _assert_own_store(store_id: int, store: Store) -> None: + """禁止存取他人 store(路徑帶 store_id 時需與登入者的 store 相符)。""" + if store_id != store.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only access your own store.", + ) + + @api_router.get("/stores/{store_id}/order-field-config", response_model=OrderFieldConfigOut) async def get_store_order_field_config( + store_id: int, + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), - store_id: int = Depends(get_resolved_store_id_with_path), ) -> OrderFieldConfigOut: + _assert_own_store(store_id, store) return await get_order_field_config(db, store_id) @api_router.put("/stores/{store_id}/order-field-config", response_model=OrderFieldConfigOut) async def put_store_order_field_config( + store_id: int, payload: OrderFieldConfigUpdate, + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), - store_id: int = Depends(get_resolved_store_id_with_path), ) -> OrderFieldConfigOut: + _assert_own_store(store_id, store) return await update_order_field_config(db, store_id, payload) -# Dev-only fallback: uses first store in DB. Frontend should use /stores/{id}/... + X-Store-Id. +# OAuth-bound store endpoints (frontend uses these). @api_router.get("/store/order-field-config/default", response_model=OrderFieldConfigOut) async def get_default_store_order_field_config( + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), ) -> OrderFieldConfigOut: - store_id = await get_first_store_id(db) - if store_id is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No store found. Please create store first.", - ) - return await get_order_field_config(db, store_id) + return await get_order_field_config(db, store.id) @api_router.put("/store/order-field-config/default", response_model=OrderFieldConfigOut) async def put_default_store_order_field_config( payload: OrderFieldConfigUpdate, + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), ) -> OrderFieldConfigOut: - store_id = await get_first_store_id(db) - if store_id is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No store found. Please create store first.", - ) - return await update_order_field_config(db, store_id, payload) + return await update_order_field_config(db, store.id, payload) diff --git a/backend/app/routes/orders.py b/backend/app/routes/orders.py index 37c8814..4041bb2 100644 --- a/backend/app/routes/orders.py +++ b/backend/app/routes/orders.py @@ -2,7 +2,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from typing import List, Optional -from app.core.store_context import get_resolved_store_id +from app.core.auth import get_chat_room_for_store, get_current_store, get_order_for_store +from app.models.store import Store from app.services.order_service import ( create_order_by_room, delete_order_by_id, @@ -27,12 +28,13 @@ from app.usecases.suggest_order_from_chat import suggest_order_from_chat api_router = APIRouter() + @api_router.get("/orders", response_model=Optional[List[OrderOut]]) async def get_orders( + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), - store_id: int = Depends(get_resolved_store_id), ): - return await get_all_orders(db, store_id) + return await get_all_orders(db, store_id=store.id) @api_router.get("/orders/room/{room_id}", response_model=List[OrderOut]) @@ -42,7 +44,12 @@ async def get_orders_by_room(room_id: int, db: AsyncSession = Depends(get_db)): # 刪除 order @api_router.delete("/order/{order_id}", response_model=bool) -async def delete_order(order_id: int, db: AsyncSession = Depends(get_db)): +async def delete_order( + order_id: int, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + await get_order_for_store(db, order_id, store) return await delete_order_by_id(db, order_id) @api_router.patch("/orders/{order_id}", response_model=OrderOut) @@ -67,25 +74,49 @@ async def suggest_order_from_chat_route( async def update_order_status( order_id: int, body: OrderStatusUpdate, + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), ): + await get_order_for_store(db, order_id, store) return await update_order_status_by_id(db, order_id, body.status) -# 新增 order + @api_router.post("/order/{room_id}", response_model=list[str]) -async def create_order(room_id: int, db: AsyncSession = Depends(get_db)): +async def create_order( + room_id: int, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + await get_chat_room_for_store(db, room_id, store) return await create_order_by_room(db, room_id) -# 更新 order + @api_router.patch("/order/{room_id}", response_model=bool) -async def update_order(room_id: int, db: AsyncSession = Depends(get_db)): +async def update_order( + room_id: int, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + await get_chat_room_for_store(db, room_id, store) return await update_order_by_room_id(db, room_id) + @api_router.get("/orderdraft/{room_id}", response_model=Optional[OrderDraftOut]) -async def get_order_draft(room_id: int, db: AsyncSession = Depends(get_db)): +async def get_order_draft( + room_id: int, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + await get_chat_room_for_store(db, room_id, store) return await get_order_draft_out_by_room(db, room_id) -@api_router.patch("/orderdraft/{room_id}", response_model= Optional[OrderDraftOut]) -async def update_order_draft(room_id: int, order_draft: OrderDraftUpdate, db: AsyncSession = Depends(get_db)): - return await update_order_draft_by_room_id(db, room_id, order_draft) +@api_router.patch("/orderdraft/{room_id}", response_model=Optional[OrderDraftOut]) +async def update_order_draft( + room_id: int, + order_draft: OrderDraftUpdate, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + await get_chat_room_for_store(db, room_id, store) + return await update_order_draft_by_room_id(db, room_id, order_draft) diff --git a/backend/app/routes/organize_data.py b/backend/app/routes/organize_data.py index e68a5a1..3db2875 100644 --- a/backend/app/routes/organize_data.py +++ b/backend/app/routes/organize_data.py @@ -3,6 +3,8 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db +from app.core.auth import get_chat_room_for_store, get_current_store +from app.models.store import Store from app.schemas.order import OrderDraftOut from app.services.organize_data import organize_data from fastapi import HTTPException, status @@ -12,8 +14,10 @@ @api_router.patch("/organize_data/{room_id}", response_model=OrderDraftOut) async def organize_data_by_room_id( room_id: int, - db: AsyncSession = Depends(get_db) + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), ): + await get_chat_room_for_store(db, room_id, store) organize_data_result = await organize_data(db, room_id) if not organize_data_result: raise HTTPException(status_code=404, detail="No data found") diff --git a/backend/app/routes/payment.py b/backend/app/routes/payment.py index acf25a5..2d9751e 100644 --- a/backend/app/routes/payment.py +++ b/backend/app/routes/payment.py @@ -1,23 +1,49 @@ from fastapi import APIRouter, Depends from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.auth import assert_payment_method_for_store, get_current_store from app.core.database import get_db -from app.services.payment_service import get_all_payment_methods, toggle_payment_method_active, get_payment_method_by_id +from app.models.store import Store +from app.services.payment_service import ( + get_all_payment_methods, + toggle_payment_method_active, + get_payment_method_by_id, +) from app.schemas.payment import PaymentMethodBase - + api_router = APIRouter(tags=["Payment"]) + @api_router.get("/payment_methods", response_model=list[PaymentMethodBase]) -async def get_payment_methods(db: AsyncSession = Depends(get_db)): - return await get_all_payment_methods(db) +async def get_payment_methods( + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + return await get_all_payment_methods(db, store_id=store.id) + @api_router.patch("/payment_methods/{payment_method_id}", response_model=PaymentMethodBase) -async def toggle_payment_method(payment_method_id: int, db: AsyncSession = Depends(get_db)): +async def toggle_payment_method( + payment_method_id: int, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): + method = await get_payment_method_by_id(db, payment_method_id) + if not method: + raise HTTPException(status_code=404, detail="Payment method not found") + assert_payment_method_for_store(method, store) return await toggle_payment_method_active(db, payment_method_id) + @api_router.get("/payment_methods/{payment_method_id}", response_model=PaymentMethodBase) -async def get_payment_method(payment_method_id: int, db: AsyncSession = Depends(get_db)): +async def get_payment_method( + payment_method_id: int, + store: Store = Depends(get_current_store), + db: AsyncSession = Depends(get_db), +): payment_method = await get_payment_method_by_id(db, payment_method_id) if not payment_method: raise HTTPException(status_code=404, detail="Payment method not found") - return payment_method \ No newline at end of file + assert_payment_method_for_store(payment_method, store) + return payment_method diff --git a/backend/app/routes/statistics.py b/backend/app/routes/statistics.py index 71685ab..25c5bc5 100644 --- a/backend/app/routes/statistics.py +++ b/backend/app/routes/statistics.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession +from app.core.auth import get_current_store from app.core.database import get_db -from app.core.store_context import get_resolved_store_id +from app.models.store import Store from app.schemas.stats import StatsOut from app.services.stats_service import get_stats @@ -11,7 +12,7 @@ @api_router.get("/stats", response_model=StatsOut) async def stats_api( + store: Store = Depends(get_current_store), db: AsyncSession = Depends(get_db), - store_id: int = Depends(get_resolved_store_id), ): - return await get_stats(db, store_id) + return await get_stats(db, store.id) diff --git a/backend/app/routes/stores.py b/backend/app/routes/stores.py index 6986f1e..9ae8b76 100644 --- a/backend/app/routes/stores.py +++ b/backend/app/routes/stores.py @@ -1,4 +1,4 @@ -"""Store list for staff store picker (no X-Store-Id required).""" +"""Store listing and authenticated owner's store.""" from __future__ import annotations @@ -7,13 +7,21 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession +from app.core.auth import get_current_store from app.core.database import get_db +from app.models.store import Store from app.repositories.store_repository import list_stores from app.schemas.store import StoreListItem api_router = APIRouter(tags=["Stores"]) +@api_router.get("/stores/me", response_model=StoreListItem) +async def get_my_store(store: Store = Depends(get_current_store)) -> StoreListItem: + """Return the store bound to the logged-in owner (OAuth 1:1).""" + return StoreListItem(id=store.id, name=store.name, slug=store.slug) + + @api_router.get("/stores", response_model=List[StoreListItem]) async def get_stores(db: AsyncSession = Depends(get_db)) -> List[StoreListItem]: rows = await list_stores(db) diff --git a/backend/app/schemas/store_provision.py b/backend/app/schemas/store_provision.py new file mode 100644 index 0000000..8fdcb5e --- /dev/null +++ b/backend/app/schemas/store_provision.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field, field_validator + + +class StoreProvisionEntry(BaseModel): + name: str + owner_email: str + line_channel_access_token: str + line_channel_secret: str + slug: str | None = None + timezone: str = "Asia/Taipei" + active: bool = True + + @field_validator("name", "owner_email", "line_channel_access_token", "line_channel_secret", "slug") + @classmethod + def strip_strings(cls, v: str | None) -> str | None: + if v is None: + return None + return v.strip() + + @field_validator("owner_email") + @classmethod + def lowercase_email(cls, v: str) -> str: + return v.strip().lower() + + +class StoreProvisionFile(BaseModel): + stores: list[StoreProvisionEntry] = Field(min_length=1) diff --git a/backend/app/seeds/seed_user.py b/backend/app/seeds/seed_user.py index 523fc35..7c93218 100644 --- a/backend/app/seeds/seed_user.py +++ b/backend/app/seeds/seed_user.py @@ -19,7 +19,7 @@ async def resolve_seed_store_id(session: AsyncSession) -> int: store_id = await get_seed_store_id(session) if store_id is None: - raise RuntimeError("資料庫中沒有 store,請先在 Supabase 建立店家資料(建議 id=1)。") + raise RuntimeError("資料庫中沒有 store,請先執行 provision-stores 建立店家資料。") return store_id diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py index f773672..88c3b00 100644 --- a/backend/app/services/message_service.py +++ b/backend/app/services/message_service.py @@ -12,8 +12,10 @@ ChatMessageCreate, ) from app.enums.chat import ChatMessageDirection, ChatMessageStatus +from app.core.line_client import line_bot_api_for_store +from app.repositories.store_repository import get_store_by_id from app.utils.line_send_message import LINE_push_message -from app.services.user_service import get_user_by_line_uid, get_user_by_id +from app.services.user_service import get_user_by_id from app.repositories.chat_repository import ( create_chat_message_entry as repo_create_chat_message_entry, create_chat_room as repo_create_chat_room, @@ -50,8 +52,10 @@ async def get_latest_message(db: AsyncSession, room_id: int) -> Optional[ChatMes ) return message -async def get_chat_room_list(db: AsyncSession, store_id: int) -> Optional[List[ChatRoomOut]]: - rooms = await list_chat_rooms(db, store_id) +async def get_chat_room_list( + db: AsyncSession, store_id: int | None = None +) -> Optional[List[ChatRoomOut]]: + rooms = await list_chat_rooms(db, store_id=store_id) response = [] for room in rooms: @@ -149,11 +153,13 @@ async def create_staff_message( status_code=status.HTTP_400_BAD_REQUEST, detail="Customer has no LINE UID; cannot push message", ) - user = await get_user_by_line_uid(db, room.customer.line_uid) - if not user: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + store = await get_store_by_id(db, room.store_id) + if not store: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Store not found") - push_ok = LINE_push_message(user.line_uid, payload) + line_uid = room.customer.line_uid + line_api = line_bot_api_for_store(store) + push_ok = LINE_push_message(line_api, line_uid, payload) message_status = ChatMessageStatus.SENT if push_ok else ChatMessageStatus.FAILED message = await repo_create_chat_message_entry( diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 8fbfa97..0cc0246 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -20,7 +20,7 @@ get_latest_confirmed_order_by_room, get_latest_order_draft_by_room, get_order_by_id, - list_active_orders, + list_all_orders, list_orders_by_customer_id, now_taipei_naive, save_order, @@ -40,6 +40,8 @@ get_user_by_id, update_user_info, ) +from app.core.line_client import line_bot_api_for_store +from app.repositories.store_repository import get_store_by_id from app.utils.line_send_message import LINE_push_message @@ -79,9 +81,11 @@ async def _build_order_out(db: AsyncSession, order: Order) -> Optional[OrderOut] ) -async def get_all_orders(db: AsyncSession, store_id: int) -> Optional[List[OrderOut]]: +async def get_all_orders( + db: AsyncSession, store_id: int | None = None +) -> Optional[List[OrderOut]]: results: list[OrderOut] = [] - for order in await list_active_orders(db, store_id): + for order in await list_all_orders(db, store_id=store_id): out = await _build_order_out(db, order) if out: results.append(out) @@ -175,7 +179,10 @@ async def create_order_by_room(db: AsyncSession, room_id: int) -> list[str]: line_uid = await get_line_uid_by_chatroom_id(db, room.id) msg = "訂單已經由後台送出囉~\n\n" if line_uid: - LINE_push_message(line_uid, ChatMessagePayload(text=msg)) + store = await get_store_by_id(db, room.store_id) + if store: + line_api = line_bot_api_for_store(store) + LINE_push_message(line_api, line_uid, ChatMessagePayload(text=msg)) else: print("❗ 無法取得 LINE UID,無法推播訂單已送出提醒。") db.add( diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py index 74657e7..7377e25 100644 --- a/backend/app/services/payment_service.py +++ b/backend/app/services/payment_service.py @@ -22,8 +22,10 @@ def payment_method_to_base(method) -> PaymentMethodBase: active=method.active, ) -async def get_all_payment_methods(db: AsyncSession) -> Optional[list[PaymentMethodBase]]: - payment_methods = await list_payment_methods(db) +async def get_all_payment_methods( + db: AsyncSession, store_id: int | None = None +) -> Optional[list[PaymentMethodBase]]: + payment_methods = await list_payment_methods(db, store_id=store_id) return [ payment_method_to_base(method) for method in payment_methods if method.active diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 9eab1d0..b828a35 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -33,8 +33,8 @@ async def get_line_uid_by_chatroom_id( return None -async def get_user_by_line_uid(db: AsyncSession, line_uid: str) -> User: - return await repo_get_user_by_line_uid(db, line_uid) +async def get_user_by_line_uid(db: AsyncSession, line_uid: str, store_id: int) -> User: + return await repo_get_user_by_line_uid(db, line_uid, store_id) async def get_user_by_id(db: AsyncSession, user_id: int) -> User: return await repo_get_user_by_id(db, user_id) diff --git a/backend/app/usecases/linebot_flow.py b/backend/app/usecases/linebot_flow.py index 61141e5..847674f 100644 --- a/backend/app/usecases/linebot_flow.py +++ b/backend/app/usecases/linebot_flow.py @@ -3,15 +3,19 @@ import asyncio from datetime import datetime, timedelta, timezone +from linebot import LineBotApi from linebot.exceptions import LineBotApiError -from linebot.models import MessageEvent, TextMessage, TextSendMessage +from linebot.models import MessageEvent, TextSendMessage from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_line_bot_api, get_settings +from app.core.deps import get_settings +from app.core.line_client import line_bot_api_for_store from app.enums.chat import ChatMessageDirection, ChatMessageStatus, ChatRoomStage from app.models.chat import ChatMessage, ChatRoom +from app.models.store import Store from app.models.user import User from app.schemas.customer import CustomerCreate +from app.services.dev_room_reset import wipe_line_customer_for_dev from app.services.message_service import ( create_chat_room, get_chat_room_by_user_id, @@ -21,8 +25,6 @@ create_order_draft_by_room_id, get_order_draft_by_room, ) -from app.repositories.store_repository import get_first_store_id -from app.services.dev_room_reset import wipe_line_customer_for_dev from app.services.user_service import create_user, get_user_by_line_uid from app.utils.line_get_profile import fetch_user_profile from app.utils.line_inbound_media import fetch_line_message_binary, save_inbound_line_image @@ -30,25 +32,20 @@ async def resolve_line_user_and_room( - db: AsyncSession, user_line_id: str + db: AsyncSession, user_line_id: str, store: Store ) -> tuple[User, ChatRoom]: - user = await get_user_by_line_uid(db, user_line_id) + line_api = line_bot_api_for_store(store) + user = await get_user_by_line_uid(db, user_line_id, store.id) if not user: - store_id = await get_first_store_id(db) - if store_id is None: - raise RuntimeError("No store configured. Create a store in Supabase first.") user = await create_user( db, - CustomerCreate(line_uid=user_line_id, name="Unknown User", store_id=store_id), + CustomerCreate( + line_uid=user_line_id, name="Unknown User", store_id=store.id + ), ) if user.name == "Unknown User" or user.avatar_url is None: - try: - profile = await fetch_user_profile(user_line_id) - except LineBotApiError as e: - print(f"Error fetching user profile: {e.status_code} {e.error.message}") - profile = None - + profile = await fetch_user_profile(line_api, user_line_id) if profile: user.name = profile.display_name user.avatar_url = profile.picture_url if profile.picture_url else "" @@ -81,9 +78,6 @@ async def resolve_line_user_and_room( async def handoff_to_owner_if_order_confirmed( chat_room: ChatRoom, db: AsyncSession ) -> None: - # A new inbound message while the room is in ORDER_CONFIRM means the - # customer reopened the conversation, so hand control back to the store - # by switching the stage to WAITING_OWNER (regardless of message type). if chat_room.stage == ChatRoomStage.ORDER_CONFIRM: chat_room.stage = ChatRoomStage.WAITING_OWNER chat_room.bot_step = -1 @@ -92,23 +86,21 @@ async def handoff_to_owner_if_order_confirmed( print("ORDER_CONFIRM received a new message; switching to WAITING_OWNER.") -async def handle_incoming_text_message(event: MessageEvent, db: AsyncSession) -> None: - """ - Use case entrypoint for an incoming LINE TextMessage. - - Keep behavior identical to the previous inline implementation in routes. - """ +async def handle_incoming_text_message( + event: MessageEvent, store: Store, db: AsyncSession +) -> None: user_line_id = event.source.user_id user_message = event.message.text + line_api = line_bot_api_for_store(store) - user, chat_room = await resolve_line_user_and_room(db, user_line_id) + user, chat_room = await resolve_line_user_and_room(db, user_line_id, store) settings = get_settings() if settings.line_test_reset_phrase and user_message.strip() == settings.line_test_reset_phrase: await wipe_line_customer_for_dev(db, chat_room.id, user.id) print(f"[dev] LINE_TEST_RESET_PHRASE matched; wiped room={chat_room.id} user={user.id}") try: - get_line_bot_api().reply_message( + line_api.reply_message( event.reply_token, TextSendMessage( text="【開發用】已清除此聊天室與顧客資料,可重新傳訊開始。" @@ -147,26 +139,29 @@ async def handle_incoming_text_message(event: MessageEvent, db: AsyncSession) -> return if chat_room.stage == ChatRoomStage.WELCOME: - await run_welcome_flow(chat_room, user_message, event, db) + await run_welcome_flow(chat_room, user_message, event, store, db) await db.refresh(chat_room) if chat_room.stage == ChatRoomStage.BOT_ACTIVE: - await run_bot_flow(chat_room, "", event, db) + await run_bot_flow(chat_room, "", event, store, db) return if chat_room.stage == ChatRoomStage.BOT_ACTIVE: - await run_bot_flow(chat_room, user_message, event, db) + await run_bot_flow(chat_room, user_message, event, store, db) return await handoff_to_owner_if_order_confirmed(chat_room, db) -async def handle_incoming_image_message(event: MessageEvent, db: AsyncSession) -> None: +async def handle_incoming_image_message( + event: MessageEvent, store: Store, db: AsyncSession +) -> None: user_line_id = event.source.user_id - _, chat_room = await resolve_line_user_and_room(db, user_line_id) + line_api = line_bot_api_for_store(store) + _, chat_room = await resolve_line_user_and_room(db, user_line_id, store) mid = event.message.id settings = get_settings() try: - raw, ct = await asyncio.to_thread(fetch_line_message_binary, mid) + raw, ct = await asyncio.to_thread(fetch_line_message_binary, line_api, mid) public_url = save_inbound_line_image(settings.public_base_url, raw, ct) except Exception as e: print(f"[LINE] 無法下載使用者圖片 message_id={mid}: {e}") @@ -191,9 +186,11 @@ async def handle_incoming_image_message(event: MessageEvent, db: AsyncSession) - await handoff_to_owner_if_order_confirmed(chat_room, db) -async def handle_incoming_sticker_message(event: MessageEvent, db: AsyncSession) -> None: +async def handle_incoming_sticker_message( + event: MessageEvent, store: Store, db: AsyncSession +) -> None: user_line_id = event.source.user_id - _, chat_room = await resolve_line_user_and_room(db, user_line_id) + _, chat_room = await resolve_line_user_and_room(db, user_line_id, store) pkg = str(event.message.package_id) stk = str(event.message.sticker_id) mid = event.message.id @@ -220,10 +217,13 @@ async def run_welcome_flow( chat_room: ChatRoom, user_text: str, event: MessageEvent, + store: Store, db: AsyncSession, ): + line_api = line_bot_api_for_store(store) if chat_room.bot_step == -1: send_confirm( + line_api, event.reply_token, "您好,歡迎來到奇美花店,若想要訂購客製化花束,請按「是」~", yes_txt="是", @@ -257,7 +257,7 @@ async def run_welcome_flow( else: chat_room.stage = ChatRoomStage.WAITING_OWNER chat_room.bot_step = -1 - get_line_bot_api().reply_message( + line_api.reply_message( event.reply_token, TextSendMessage("好的!已轉交給客服人員,請稍候。"), ) @@ -279,7 +279,10 @@ async def run_welcome_flow( await db.refresh(chat_room) -async def run_bot_flow(chat_room: ChatRoom, text: str, event: MessageEvent, db: AsyncSession): +async def run_bot_flow( + chat_room: ChatRoom, text: str, event: MessageEvent, store: Store, db: AsyncSession +): + line_api = line_bot_api_for_store(store) STEP_MAP = { 1: ask_budget, 2: ask_color, @@ -297,7 +300,9 @@ async def run_bot_flow(chat_room: ChatRoom, text: str, event: MessageEvent, db: await db.commit() return - next_step, manual_override, next_question = await handler(text, event, db, chat_room) + next_step, manual_override, next_question = await handler( + text, event, db, chat_room, line_api + ) if manual_override: chat_room.stage = ChatRoomStage.WAITING_OWNER @@ -313,10 +318,11 @@ async def run_bot_flow(chat_room: ChatRoom, text: str, event: MessageEvent, db: break -async def ask_budget(user_text, event, db, chat_room): +async def ask_budget(user_text, event, db, chat_room, line_api: LineBotApi): if chat_room.bot_step == 1: if user_text.strip() == "": send_quick_reply_message( + line_api, event.reply_token, "好的~請問預算大概多少呢?", ["500以下", "500-1000", "1000以上"], @@ -344,9 +350,10 @@ async def ask_budget(user_text, event, db, chat_room): return 3, False, True -async def ask_color(user_text, event, db, chat_room): +async def ask_color(user_text, event, db, chat_room, line_api: LineBotApi): if chat_room.bot_step == 2: send_quick_reply_message( + line_api, event.reply_token, "請問想要什麼顏色的客製化花束?", ["紅", "白", "粉", "其他"], @@ -367,9 +374,10 @@ async def ask_color(user_text, event, db, chat_room): return 4, False, False -async def ask_type(user_text, event, db, chat_room): +async def ask_type(user_text, event, db, chat_room, line_api: LineBotApi): if chat_room.bot_step == 3: send_quick_reply_message( + line_api, event.reply_token, "請問想要什麼類型的花材?", ["玫瑰花", "滿天星", "向日葵", "其他"], @@ -390,9 +398,9 @@ async def ask_type(user_text, event, db, chat_room): return 4, False, False -async def last(user_text, event, db, chat_room): +async def last(user_text, event, db, chat_room, line_api: LineBotApi): _ = user_text.strip() - get_line_bot_api().reply_message( + line_api.reply_message( event.reply_token, TextSendMessage("👌了解!已記錄到後臺~接下來會交由老闆與您聯繫確認細節。"), ) @@ -409,4 +417,3 @@ async def last(user_text, event, db, chat_room): db.add(message) await db.commit() return -1, False, False - diff --git a/backend/app/usecases/organize_order_draft.py b/backend/app/usecases/organize_order_draft.py index 7047d1f..cd9ac6e 100644 --- a/backend/app/usecases/organize_order_draft.py +++ b/backend/app/usecases/organize_order_draft.py @@ -23,6 +23,8 @@ get_order_draft_out_by_room, update_order_draft_by_room_id, ) +from app.core.line_client import line_bot_api_for_store +from app.repositories.store_repository import get_store_by_id from app.services.user_service import get_line_uid_by_chatroom_id from app.utils.line_send_message import LINE_push_message from app.managers.prompt_manager import PromptManager @@ -172,7 +174,10 @@ async def organize_order_draft(db: AsyncSession, chat_room_id: int) -> OrderDraf line_uid = await get_line_uid_by_chatroom_id(db, chat_room.id) if line_uid: - LINE_push_message(line_uid, ChatMessagePayload(text=warning_msg)) + store = await get_store_by_id(db, chat_room.store_id) + if store: + line_api = line_bot_api_for_store(store) + LINE_push_message(line_api, line_uid, ChatMessagePayload(text=warning_msg)) else: print("❗ 無法取得 LINE UID,無法推播缺漏提醒。") diff --git a/backend/app/utils/line_bot_info.py b/backend/app/utils/line_bot_info.py new file mode 100644 index 0000000..b1f7863 --- /dev/null +++ b/backend/app/utils/line_bot_info.py @@ -0,0 +1,36 @@ +"""LINE Messaging API helpers for provisioning (not webhook runtime).""" + +from __future__ import annotations + +import httpx + +LINE_BOT_INFO_URL = "https://api.line.me/v2/bot/info" + + +async def fetch_line_bot_user_id(access_token: str) -> str: + """ + Return bot userId from channel access token. + Same value as webhook JSON "destination" -> store.slug. + """ + token = (access_token or "").strip() + if not token: + raise ValueError("line_channel_access_token is empty") + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get( + LINE_BOT_INFO_URL, + headers={"Authorization": f"Bearer {token}"}, + ) + + if resp.status_code != 200: + detail = resp.text[:200] if resp.text else resp.reason_phrase + raise ValueError( + f"LINE bot info failed ({resp.status_code}): {detail}. " + "Check channel access token." + ) + + data = resp.json() + user_id = (data.get("userId") or "").strip() + if not user_id: + raise ValueError("LINE bot info response missing userId") + return user_id diff --git a/backend/app/utils/line_get_profile.py b/backend/app/utils/line_get_profile.py index 6853d00..1d7e264 100644 --- a/backend/app/utils/line_get_profile.py +++ b/backend/app/utils/line_get_profile.py @@ -1,16 +1,11 @@ from linebot import LineBotApi from linebot.exceptions import LineBotApiError -from app.core.deps import get_line_bot_api -line_bot_api = get_line_bot_api() - -async def fetch_user_profile(user_id: str): +async def fetch_user_profile(line_bot_api: LineBotApi, user_id: str): try: profile = line_bot_api.get_profile(user_id) - # profile.display_name, profile.user_id, profile.picture_url, profile.status_message return profile except LineBotApiError as e: - # 處理錯誤 print(f"Error: {e.status_code} {e.error.message}") - return None \ No newline at end of file + return None diff --git a/backend/app/utils/line_inbound_media.py b/backend/app/utils/line_inbound_media.py index d5b44fe..163fde1 100644 --- a/backend/app/utils/line_inbound_media.py +++ b/backend/app/utils/line_inbound_media.py @@ -1,13 +1,15 @@ from __future__ import annotations -from app.core.deps import get_line_bot_api +from linebot import LineBotApi + from app.utils.chat_image_storage import save_chat_image -def fetch_line_message_binary(message_id: str) -> tuple[bytes, str | None]: +def fetch_line_message_binary( + line_bot_api: LineBotApi, message_id: str +) -> tuple[bytes, str | None]: """同步呼叫 LINE Get content API;請於 asyncio.to_thread 內使用。""" - api = get_line_bot_api() - blob = api.get_message_content(message_id) + blob = line_bot_api.get_message_content(message_id) return blob.content, blob.content_type diff --git a/backend/app/utils/line_send_message.py b/backend/app/utils/line_send_message.py index 466505f..66e270a 100644 --- a/backend/app/utils/line_send_message.py +++ b/backend/app/utils/line_send_message.py @@ -1,23 +1,27 @@ -import os +import logging from linebot import LineBotApi from linebot.exceptions import LineBotApiError -import logging -from linebot.models import TextSendMessage, ImageSendMessage, StickerSendMessage -from app.enums.chat import ChatMessageDirection -from app.schemas.chat import ChatMessagePayload -from linebot.models import QuickReply, QuickReplyButton, MessageAction -from app.core.deps import get_line_bot_api +from linebot.models import ( + ConfirmTemplate, + ImageSendMessage, + MessageAction, + QuickReply, + QuickReplyButton, + StickerSendMessage, + TemplateSendMessage, + TextSendMessage, +) -# ────────────────────────────── -# LINE PUSH 基本設定 -# ────────────────────────────── -line_bot_api = get_line_bot_api() +from app.schemas.chat import ChatMessagePayload -def LINE_push_message(line_uid: str, data: ChatMessagePayload) -> bool: +def LINE_push_message( + line_bot_api: LineBotApi, line_uid: str, data: ChatMessagePayload +) -> bool: """ 發送訊息到 LINE + :param line_bot_api: 該 store 的 LineBotApi :param line_uid: 使用者的 LINE UID :param data: 訊息內容(擇一:貼圖 / 圖片 URL / 文字) """ @@ -53,37 +57,38 @@ def LINE_push_message(line_uid: str, data: ChatMessagePayload) -> bool: except Exception as e: logging.exception(f"[LINE PUSH] 未知錯誤:{str(e)}") return False - -# ────────────────────────────────────────────────────────────── -def send_quick_reply_message(reply_token: str, text: str, options: list[str]): - """ - Helper to send a text message with LINE quick‑reply buttons. - `options` is a list of button labels / text payload (kept identical). - """ + +def send_quick_reply_message( + line_bot_api: LineBotApi, reply_token: str, text: str, options: list[str] +): items = [ QuickReplyButton(action=MessageAction(label=opt, text=opt)) for opt in options ] line_bot_api.reply_message( reply_token, - TextSendMessage(text=text, quick_reply=QuickReply(items=items)) + TextSendMessage(text=text, quick_reply=QuickReply(items=items)), ) -# ────────────────────────────────────────────────────────────── -from linebot.models import ConfirmTemplate, TemplateSendMessage, MessageAction -def send_confirm(reply_token: str, text: str, - yes_txt="是", no_txt="否", yes_reply="yes", no_reply="no"): +def send_confirm( + line_bot_api: LineBotApi, + reply_token: str, + text: str, + yes_txt="是", + no_txt="否", + yes_reply="yes", + no_reply="no", +): tpl = ConfirmTemplate( text=text, actions=[ MessageAction(label=yes_txt, text=yes_reply), - MessageAction(label=no_txt, text=no_reply) - ] + MessageAction(label=no_txt, text=no_reply), + ], ) line_bot_api.reply_message( reply_token, - TemplateSendMessage(alt_text=text, template=tpl) + TemplateSendMessage(alt_text=text, template=tpl), ) - diff --git a/backend/config/stores.provision.example.json b/backend/config/stores.provision.example.json new file mode 100644 index 0000000..ed103e9 --- /dev/null +++ b/backend/config/stores.provision.example.json @@ -0,0 +1,12 @@ +{ + "stores": [ + { + "name": "測試花店", + "owner_email": "1ddttyyss@gmail.com", + "line_channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "line_channel_secret": "YOUR_CHANNEL_SECRET", + "timezone": "Asia/Taipei", + "active": true + } + ] +} diff --git a/backend/docs/manual_migration_f1a2b3c4d5e6.sql b/backend/docs/manual_migration_f1a2b3c4d5e6.sql new file mode 100644 index 0000000..971802e --- /dev/null +++ b/backend/docs/manual_migration_f1a2b3c4d5e6.sql @@ -0,0 +1,12 @@ +-- Manual apply if `alembic upgrade head` fails (multi-store LINE credentials). +-- Safe to run only if these columns do not exist yet. + +ALTER TABLE public.store + ADD COLUMN IF NOT EXISTS line_channel_access_token VARCHAR NULL; + +ALTER TABLE public.store + ADD COLUMN IF NOT EXISTS line_channel_secret VARCHAR NULL; + +-- After success, align alembic_version with repo head (if your DB was on e2f1a4b5c6d7): +-- UPDATE public.alembic_version SET version_num = 'f1a2b3c4d5e6'; +-- If alembic_version has an unknown revision, fix it to match your actual schema first. diff --git a/backend/requirements.txt b/backend/requirements.txt index 487299f..38a333c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,3 +18,4 @@ docxtpl==0.20.0 pytest==8.4.2 pytest-asyncio==0.23.8 httpx==0.28.1 +PyJWT==2.8.0 diff --git a/backend/scripts/provision_stores.py b/backend/scripts/provision_stores.py new file mode 100644 index 0000000..e250d47 --- /dev/null +++ b/backend/scripts/provision_stores.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Provision store rows from JSON (owner_email + LINE credentials). + +Slug is resolved automatically via GET https://api.line.me/v2/bot/info (userId). + +Usage: + cd backend + PYTHONPATH=. ./venv/bin/python scripts/provision_stores.py --file config/stores.provision.json + PYTHONPATH=. ./venv/bin/python scripts/provision_stores.py --file config/stores.provision.json --dry-run +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +from sqlalchemy.pool import NullPool +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.core.settings import resolve_provision_database_url +from app.core.supabase_db_url import asyncpg_connect_args_for_url, is_supabase_transaction_pooler_url +from app.repositories.store_repository import upsert_store_from_provision +from app.schemas.store_provision import StoreProvisionEntry, StoreProvisionFile +from app.utils.line_bot_info import fetch_line_bot_user_id + + +async def _resolve_slug(entry: StoreProvisionEntry) -> str: + user_id = await fetch_line_bot_user_id(entry.line_channel_access_token) + if entry.slug and entry.slug != user_id: + raise ValueError( + f"slug in JSON ({entry.slug}) does not match LINE userId ({user_id})" + ) + return user_id + + +async def run(file_path: Path, dry_run: bool, reset_owner_binding: bool) -> int: + raw = json.loads(file_path.read_text(encoding="utf-8")) + payload = StoreProvisionFile.model_validate(raw) + + db_url = resolve_provision_database_url() + if not dry_run and is_supabase_transaction_pooler_url(db_url): + print("Using Supabase transaction pooler :6543 (avoids session pooler EMAXCONNSESSION).") + + engine = create_async_engine( + db_url, + poolclass=NullPool, + pool_pre_ping=True, + connect_args=asyncpg_connect_args_for_url(db_url), + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + + async with session_factory() as db: + for i, entry in enumerate(payload.stores, start=1): + try: + slug = await _resolve_slug(entry) + except ValueError as e: + print(f"[{i}] {entry.name}: ERROR — {e}", file=sys.stderr) + return 1 + + if dry_run: + print( + f"[{i}] DRY-RUN {entry.name}: owner_email={entry.owner_email} " + f"slug={slug} active={entry.active}" + ) + continue + + try: + store, created = await upsert_store_from_provision( + db, + name=entry.name, + owner_email=entry.owner_email, + slug=slug, + line_channel_access_token=entry.line_channel_access_token, + line_channel_secret=entry.line_channel_secret, + timezone=entry.timezone, + active=entry.active, + reset_owner_binding=reset_owner_binding, + ) + action = "created" if created else "updated" + print( + f"[{i}] {action} store id={store.id} name={store.name!r} " + f"slug={store.slug} owner_email={store.owner_email}" + ) + except Exception as e: + print(f"[{i}] {entry.name}: DB ERROR — {e}", file=sys.stderr) + await db.rollback() + return 1 + + if not dry_run: + await db.commit() + print("Done. Owners can log in with owner_email (Google) to claim the store.") + + await engine.dispose() + return 0 + + +def main() -> None: + parser = argparse.ArgumentParser(description="Provision stores from JSON into Supabase") + parser.add_argument( + "--file", + type=Path, + default=Path("config/stores.provision.json"), + help="Path to provision JSON (default: config/stores.provision.json)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Resolve slug from LINE API but do not write to database", + ) + parser.add_argument( + "--reset-owner-binding", + action="store_true", + help="Clear owner_auth_user_id on update (owner must log in again)", + ) + args = parser.parse_args() + + if not args.file.is_file(): + print( + f"File not found: {args.file}\n" + "Copy config/stores.provision.example.json to config/stores.provision.json", + file=sys.stderr, + ) + sys.exit(1) + + exit_code = asyncio.run( + run(args.file, args.dry_run, args.reset_owner_binding) + ) + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_models_pr_changes.py b/backend/tests/test_models_pr_changes.py index 8402bc1..79c31ba 100644 --- a/backend/tests/test_models_pr_changes.py +++ b/backend/tests/test_models_pr_changes.py @@ -271,7 +271,16 @@ def test_store_tablename(): def test_store_has_expected_columns(): cols = _column_names(Store) - for c in ("id", "name", "slug", "timezone", "active", "owner_auth_user_id"): + for c in ( + "id", + "name", + "slug", + "line_channel_access_token", + "line_channel_secret", + "timezone", + "active", + "owner_auth_user_id", + ): assert c in cols, f"Missing: {c}" diff --git a/backend/tests/test_multi_store_line.py b/backend/tests/test_multi_store_line.py new file mode 100644 index 0000000..b72bc41 --- /dev/null +++ b/backend/tests/test_multi_store_line.py @@ -0,0 +1,91 @@ +"""Multi-store LINE routing and tenant scoping.""" + +import json + +import pytest + +from app.core.line_client import store_has_line_config +from app.models.store import Store +from app.repositories.store_repository import parse_webhook_destination + + +def test_parse_webhook_destination(): + body = json.dumps({"destination": "Udeadbeef", "events": []}) + assert parse_webhook_destination(body) == "Udeadbeef" + + +def test_parse_webhook_destination_missing(): + from fastapi import HTTPException + + with pytest.raises(HTTPException): + parse_webhook_destination("{}") + + +def test_store_has_line_config_requires_slug_and_tokens(): + complete = Store( + id=1, + name="Test", + owner_email="a@b.com", + slug="Uabc", + line_channel_access_token="tok", + line_channel_secret="sec", + ) + assert store_has_line_config(complete) is True + + incomplete = Store( + id=1, + name="Test", + owner_email="a@b.com", + slug=None, + line_channel_access_token="tok", + line_channel_secret="sec", + ) + assert store_has_line_config(incomplete) is False + + +@pytest.mark.asyncio +async def test_resolve_store_unknown_destination_404(monkeypatch): + """Unknown destination with no env fallback -> 404.""" + from unittest.mock import AsyncMock, MagicMock + + from fastapi import HTTPException + + from app.core.settings import Settings + from app.repositories.store_repository import resolve_store_for_webhook + + async def _no_store(*_args, **_kwargs): + return None + + async def _no_first_store(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "app.repositories.store_repository.get_store_by_slug", + _no_store, + ) + monkeypatch.setattr( + "app.repositories.store_repository.get_first_store_id", + _no_first_store, + ) + monkeypatch.setattr( + "app.repositories.store_repository.load_settings", + lambda: Settings( + openai_api_key=None, + line_channel_access_token=None, + line_channel_secret=None, + database_url="sqlite+aiosqlite:///", + line_test_reset_phrase=None, + public_base_url="http://localhost:8000", + supabase_url=None, + supabase_anon_key=None, + supabase_jwt_secret=None, + ), + ) + + db = AsyncMock() + body = json.dumps({"destination": "Unonexistent", "events": []}) + + with pytest.raises(HTTPException) as exc_info: + await resolve_store_for_webhook(db, body) + + assert exc_info.value.status_code == 404 diff --git a/backend/tests/test_store_provision.py b/backend/tests/test_store_provision.py new file mode 100644 index 0000000..85c75b2 --- /dev/null +++ b/backend/tests/test_store_provision.py @@ -0,0 +1,57 @@ +"""Store provisioning schema and LINE bot info helper.""" + +import pytest + +from app.schemas.store_provision import StoreProvisionEntry, StoreProvisionFile + + +def test_provision_email_lowercased(): + entry = StoreProvisionEntry( + name="Test", + owner_email="Owner@Gmail.COM", + line_channel_access_token="tok", + line_channel_secret="sec", + ) + assert entry.owner_email == "owner@gmail.com" + + +def test_provision_file_requires_stores(): + payload = StoreProvisionFile.model_validate( + { + "stores": [ + { + "name": "A", + "owner_email": "a@b.com", + "line_channel_access_token": "t", + "line_channel_secret": "s", + } + ] + } + ) + assert len(payload.stores) == 1 + + +@pytest.mark.asyncio +async def test_fetch_line_bot_user_id_parses_response(monkeypatch): + from app.utils import line_bot_info + + class FakeResp: + status_code = 200 + + def json(self): + return {"userId": "Utest123", "displayName": "Bot"} + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def get(self, url, headers=None): + assert "Bearer mytoken" in headers["Authorization"] + return FakeResp() + + monkeypatch.setattr(line_bot_info.httpx, "AsyncClient", lambda **kw: FakeClient()) + uid = await line_bot_info.fetch_line_bot_user_id("mytoken") + assert uid == "Utest123" diff --git a/backend/tests/test_stores_me.py b/backend/tests/test_stores_me.py new file mode 100644 index 0000000..ab7f1f0 --- /dev/null +++ b/backend/tests/test_stores_me.py @@ -0,0 +1,42 @@ +"""HTTP tests for GET /stores/me (OAuth-bound store).""" + +from __future__ import annotations + +import httpx +import pytest +from types import SimpleNamespace + + +@pytest.mark.asyncio +async def test_stores_me_requires_auth() -> None: + from app.main import app + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/stores/me") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_stores_me_returns_bound_store() -> None: + from app.core.auth import get_current_store + from app.main import app + + bound = SimpleNamespace(id=42, name="My Flower Shop", slug="Uabc123") + + async def override_current_store() -> SimpleNamespace: + return bound + + app.dependency_overrides[get_current_store] = override_current_store + try: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/stores/me", + headers={"Authorization": "Bearer test-token"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload == {"id": 42, "name": "My Flower Shop", "slug": "Uabc123"} + finally: + app.dependency_overrides.pop(get_current_store, None) diff --git a/docker-compose.yml b/docker-compose.yml index 295c810..c2f114a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,8 @@ services: context: ./frontend args: VITE_API_BASE_URL: http://localhost:8000 + env_file: + - ./frontend/.env.local environment: VITE_API_BASE_URL: http://localhost:8000 CHOKIDAR_USEPOLLING: "true" diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index faa0d0c..a14264f 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -45,10 +45,14 @@ pytest backend/tests/test_contract_smoke.py ### LINE Webhook - **POST** `/callback` - Header: `X-Line-Signature` 必填 + - Body: JSON 須含 top-level `destination`(對應 `store.slug`) - **200**: `"OK"` (PlainTextResponse) - - **400**: Missing signature / Invalid signature + - **400**: Missing signature / Invalid signature / missing destination + - **404**: No store for `destination` + - **503**: Store inactive or LINE credentials not configured ### Orders / Drafts +- 以下端點需 Bearer token,且僅能操作登入者所屬 `store` 的資料。 - **GET** `/orders` - Response model: `Optional[List[OrderOut]]` @@ -84,7 +88,7 @@ pytest backend/tests/test_contract_smoke.py - **PATCH** `/orders/{order_id}` 可帶 `mark_processed_message_ids`;成功後將該批訊息標為 `processed=true` ### Messages (Chat Rooms) -Base prefix: `/chat_rooms` +Base prefix: `/chat_rooms`(需 Bearer token;列表與 room 操作限所屬 store) - **GET** `/chat_rooms` - Response model: `List[ChatRoomOut]` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 15d411f..e4d1c92 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.1.0", + "@supabase/supabase-js": "^2.105.4", "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", @@ -24,7 +25,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.0.0", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -2020,6 +2022,90 @@ "win32" ] }, + "node_modules/@supabase/auth-js": { + "version": "2.105.4", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.4.tgz", + "integrity": "sha512-Ejfa37M5xoIwoxVebxRahnwubPo8g22qkXQ4p50+N9MIvU9UZoN+A8dwVPtczzGf8oV/YXN80ZPxK4aWXuSN/A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.105.4", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.4.tgz", + "integrity": "sha512-JVNKbBft3Qkja+WlGaE026AJ2AH9K0UTsxsfvEIHgd4zFrBor4BYRCrYFrv9IDsvVqkF72wKDsODJl5GY/C4tA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.105.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.4.tgz", + "integrity": "sha512-SppIyLo/kTwIlz1qpv2HN1EQqBg0GVktrDDFsXygYROha3MgVn4rT7p5EjFHFqXQm2rdRGb/BI7bc+jr10m91w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.105.4", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.4.tgz", + "integrity": "sha512-6ov6c59+8D9h7q4M4Gy/uDJlC0Akxl9/714Y+6vJ+Sijuc16TS/p5DwhfRCLNcIhNiej1gEt+CQUwsjiPt4PxQ==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.105.4", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.4.tgz", + "integrity": "sha512-Jx+pzMP1Whjof2PWHoVBUA75/p7PQE9CqKBzn1oXVyJDOggMLSH2OzVWwsXYaxEpdC1K/KltwmOX44nL3LHl9g==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.105.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.4.tgz", + "integrity": "sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.105.4", + "@supabase/functions-js": "2.105.4", + "@supabase/postgrest-js": "2.105.4", + "@supabase/realtime-js": "2.105.4", + "@supabase/storage-js": "2.105.4" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -3942,6 +4028,15 @@ "node": ">= 0.4" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 39ec908..c01a6c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-slot": "^1.1.0", + "@supabase/supabase-js": "^2.105.4", "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", @@ -31,7 +32,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.0.0", - "tailwind-merge": "^2.5.4" + "tailwind-merge": "^2.5.4", + "tslib": "^2.8.1" }, "devDependencies": { "@eslint/js": "^9.15.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6455e0..9b369d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,6 @@ -import { Outlet } from 'react-router-dom' -import Navbar from '@/components/layout/Navbar' +import AppLayout from '@/layouts/AppLayout' +/** Root route layout; delegates to AppLayout (Navbar + Outlet). */ export default function App() { - return ( - <> - -
- -
- - ) + return } diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..71c9b98 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,98 @@ +import { getMockLineOfficialDisplay } from '@/config/mockLineOfficial' +import { + clearSession, + getSession, + nextMockStaffId, + setSession, +} from '@/lib/authStorage' +import type { StaffSession } from '@/types/auth' +import type { LineOfficialDisplay } from '@/types/authApi' + +const DEFAULT_DISPLAY_NAME = '管理員' +const DEFAULT_STORE_KEY = 'demo-store' + +/** Auth operations; swap mock implementation for HTTP client when backend is ready. */ +export interface AuthApi { + getSession(): StaffSession | null + loginWithGoogleMock(): StaffSession + updateDisplayName(name: string): StaffSession + confirmLineOfficial(): StaffSession + completeOnboarding(): StaffSession + rejectWrongAccount(): void + logout(): void + getLineOfficialDisplay(storeKey: string): LineOfficialDisplay +} + +function requireSession(): StaffSession { + const session = getSession() + if (!session) { + throw new Error('No active session') + } + return session +} + +function saveSession(patch: Partial): StaffSession { + const current = requireSession() + const next: StaffSession = { ...current, ...patch } + setSession(next) + return next +} + +const mockAuthApi: AuthApi = { + getSession, + + loginWithGoogleMock(): StaffSession { + const session: StaffSession = { + staffId: nextMockStaffId(), + storeKey: DEFAULT_STORE_KEY, + displayName: DEFAULT_DISPLAY_NAME, + onboardingStep: 'NAME', + role: 'OWNER', + } + setSession(session) + return session + }, + + updateDisplayName(name: string): StaffSession { + const trimmed = name.trim() + if (!trimmed) { + throw new Error('Display name cannot be empty') + } + if (trimmed.length > 32) { + throw new Error('Display name must be at most 32 characters') + } + return saveSession({ + displayName: trimmed, + onboardingStep: 'LINE_OA', + }) + }, + + confirmLineOfficial(): StaffSession { + // Step stays LINE_OA until OrderFieldsPage calls completeOnboarding. + return requireSession() + }, + + completeOnboarding(): StaffSession { + return saveSession({ onboardingStep: 'DONE' }) + }, + + rejectWrongAccount(): void { + clearSession() + }, + + logout(): void { + clearSession() + }, + + getLineOfficialDisplay(storeKey: string): LineOfficialDisplay { + return getMockLineOfficialDisplay(storeKey) + }, +} + +/** True when VITE_USE_MOCK_AUTH is unset or not the string "false". */ +export function isMockAuthEnabled(): boolean { + return import.meta.env.VITE_USE_MOCK_AUTH !== 'false' +} + +/** Active auth API (mock for now). */ +export const authApi: AuthApi = mockAuthApi diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1335b15..8a41b89 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import { getActiveStoreId } from '@/lib/activeStoreStorage' +import { supabase } from '@/lib/supabase' export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000' @@ -8,11 +8,26 @@ export const api = axios.create({ baseURL: API_BASE, }) -// Multi-tenant staff APIs (orders, chat rooms, stats, field config). -api.interceptors.request.use(config => { - const storeId = getActiveStoreId() - if (storeId != null) { - config.headers.set('X-Store-Id', String(storeId)) +api.interceptors.request.use(async (config) => { + const { data: { session } } = await supabase.auth.getSession() + if (session?.access_token) { + config.headers.Authorization = `Bearer ${session.access_token}` } return config }) + +api.interceptors.response.use( + (response) => response, + async (error) => { + const status = error.response?.status + const hadAuth = Boolean(error.config?.headers?.Authorization) + const onLogin = window.location.pathname === '/login' + + // Only sign out when an authenticated request was rejected (avoids reload loop on /login). + if (status === 401 && hadAuth && !onLogin) { + await supabase.auth.signOut() + window.location.replace('/login') + } + return Promise.reject(error) + }, +) diff --git a/frontend/src/api/orderFieldConfig.ts b/frontend/src/api/orderFieldConfig.ts index 6a871c5..c4bf0a2 100644 --- a/frontend/src/api/orderFieldConfig.ts +++ b/frontend/src/api/orderFieldConfig.ts @@ -16,21 +16,20 @@ export interface UpdateOrderFieldConfigPayload { field_order?: OrderFieldKey[] } -export async function fetchOrderFieldConfig( - storeId: number, -): Promise { +/** Load field config for the OAuth-bound store (no path store_id). */ +export async function fetchOrderFieldConfig(): Promise { const { data } = await api.get( - `/stores/${storeId}/order-field-config`, + '/store/order-field-config/default', ) return data } +/** Persist field config for the OAuth-bound store. */ export async function updateOrderFieldConfig( - storeId: number, payload: UpdateOrderFieldConfigPayload, ): Promise { const { data } = await api.put( - `/stores/${storeId}/order-field-config`, + '/store/order-field-config/default', payload, ) return data diff --git a/frontend/src/api/stores.ts b/frontend/src/api/stores.ts index e26d46b..3519610 100644 --- a/frontend/src/api/stores.ts +++ b/frontend/src/api/stores.ts @@ -6,8 +6,8 @@ export interface StoreListItem { slug: string | null } -/** List active stores for the staff store picker (no X-Store-Id required). */ -export async function fetchStores(): Promise { - const { data } = await api.get('/stores') +/** OAuth-bound store for the logged-in owner. */ +export async function fetchMyStore(): Promise { + const { data } = await api.get('/stores/me') return data } diff --git a/frontend/src/components/auth/AuthLoading.tsx b/frontend/src/components/auth/AuthLoading.tsx new file mode 100644 index 0000000..0b929eb --- /dev/null +++ b/frontend/src/components/auth/AuthLoading.tsx @@ -0,0 +1,12 @@ +/** Shown while auth session is being restored from storage. */ +export default function AuthLoading() { + return ( +
+ 載入中… +
+ ) +} diff --git a/frontend/src/components/auth/OnboardingIndexRedirect.tsx b/frontend/src/components/auth/OnboardingIndexRedirect.tsx new file mode 100644 index 0000000..086a6d7 --- /dev/null +++ b/frontend/src/components/auth/OnboardingIndexRedirect.tsx @@ -0,0 +1,23 @@ +import { Navigate } from 'react-router-dom' +import AuthLoading from '@/components/auth/AuthLoading' +import { useAuth } from '@/hooks/useAuth' +import { getOnboardingPath } from '@/lib/onboardingPaths' + +/** Sends /onboarding to the step matching the current session. */ +export default function OnboardingIndexRedirect() { + const { session, isLoading } = useAuth() + + if (isLoading) { + return + } + + if (!session) { + return + } + + if (session.onboardingStep === 'DONE') { + return + } + + return +} diff --git a/frontend/src/components/auth/OnboardingStepGuard.tsx b/frontend/src/components/auth/OnboardingStepGuard.tsx new file mode 100644 index 0000000..30f3eca --- /dev/null +++ b/frontend/src/components/auth/OnboardingStepGuard.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' +import AuthLoading from '@/components/auth/AuthLoading' +import { useAuth } from '@/hooks/useAuth' +import { getOnboardingPath } from '@/lib/onboardingPaths' +import type { OnboardingStep } from '@/types/auth' + +interface OnboardingStepGuardProps { + /** Which wizard step this route represents. */ + expectedStep: Extract + children: ReactNode +} + +/** + * Keeps users on the correct onboarding step and off wizard routes after DONE. + */ +export default function OnboardingStepGuard({ + expectedStep, + children, +}: OnboardingStepGuardProps) { + const { session, isLoading } = useAuth() + + if (isLoading) { + return + } + + if (!session) { + return null + } + + if (session.onboardingStep === 'DONE') { + return + } + + if (session.onboardingStep !== expectedStep) { + return + } + + return children +} diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..93f225f --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import { Navigate, Outlet } from 'react-router-dom' +import AuthLoading from '@/components/auth/AuthLoading' +import { useAuth } from '@/hooks/useAuth' + +export default function ProtectedRoute() { + const { isAuthenticated, isLoading } = useAuth() + + if (isLoading) { + return + } + + if (!isAuthenticated) return + + return +} diff --git a/frontend/src/components/auth/RequireAuth.tsx b/frontend/src/components/auth/RequireAuth.tsx new file mode 100644 index 0000000..0a86f42 --- /dev/null +++ b/frontend/src/components/auth/RequireAuth.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import AuthLoading from '@/components/auth/AuthLoading' +import { useAuth } from '@/hooks/useAuth' + +interface RequireAuthProps { + children: ReactNode +} + +/** Redirect unauthenticated users to /login. */ +export default function RequireAuth({ children }: RequireAuthProps) { + const { isAuthenticated, isLoading } = useAuth() + const location = useLocation() + + if (isLoading) { + return + } + + if (!isAuthenticated) { + return + } + + return children +} diff --git a/frontend/src/components/auth/RequireOnboardingDone.tsx b/frontend/src/components/auth/RequireOnboardingDone.tsx new file mode 100644 index 0000000..d8e2cfc --- /dev/null +++ b/frontend/src/components/auth/RequireOnboardingDone.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' +import { Navigate } from 'react-router-dom' +import AuthLoading from '@/components/auth/AuthLoading' +import { useAuth } from '@/hooks/useAuth' +import { getOnboardingPath } from '@/lib/onboardingPaths' + +interface RequireOnboardingDoneProps { + children: ReactNode +} + +/** + * Blocks main app routes until onboarding is DONE. + * Use inside RequireAuth on dashboard, orders, messages, stats. + */ +export default function RequireOnboardingDone({ children }: RequireOnboardingDoneProps) { + const { session, isLoading } = useAuth() + + if (isLoading) { + return + } + + if (!session) { + return null + } + + if (session.onboardingStep !== 'DONE') { + return + } + + return children +} diff --git a/frontend/src/components/layout/StorePicker.tsx b/frontend/src/components/layout/StorePicker.tsx index 7d2f025..e69fbe6 100644 --- a/frontend/src/components/layout/StorePicker.tsx +++ b/frontend/src/components/layout/StorePicker.tsx @@ -1,10 +1,8 @@ import { useStore } from '@/context/StoreContext' -/** - * Staff store selector; writes active-store-id and drives X-Store-Id on API calls. - */ +/** Shows the OAuth-bound store name (single-store mode). */ export default function StorePicker() { - const { stores, currentStoreId, setCurrentStoreId, loading, error } = useStore() + const { stores, loading, error } = useStore() if (loading) { return ( @@ -26,21 +24,14 @@ export default function StorePicker() { return 尚無店家 } + const store = stores[0] return ( - + + {store.name} + ) } diff --git a/frontend/src/components/onboarding/OnboardingCard.tsx b/frontend/src/components/onboarding/OnboardingCard.tsx new file mode 100644 index 0000000..e4a2682 --- /dev/null +++ b/frontend/src/components/onboarding/OnboardingCard.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface OnboardingCardProps { + title?: string + description?: string + children: ReactNode + className?: string +} + +/** Centered white card used on onboarding steps. */ +export default function OnboardingCard({ + title, + description, + children, + className, +}: OnboardingCardProps) { + const hasHeader = Boolean(title || description) + + return ( +
+ {title ? ( +

{title}

+ ) : null} + {description ? ( +

+ {description} +

+ ) : null} +
{children}
+
+ ) +} diff --git a/frontend/src/components/onboarding/StepIndicator.tsx b/frontend/src/components/onboarding/StepIndicator.tsx new file mode 100644 index 0000000..d70d50d --- /dev/null +++ b/frontend/src/components/onboarding/StepIndicator.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils' + +interface StepIndicatorProps { + /** Current step number (1-based). */ + current: 1 | 2 + /** Total onboarding wizard steps before the order-fields page. */ + total?: 2 + className?: string +} + +/** Shows progress within the two-step onboarding wizard (name + LINE OA). */ +export default function StepIndicator({ + current, + total = 2, + className, +}: StepIndicatorProps) { + return ( +

+ 步驟 {current} / {total} +

+ ) +} diff --git a/frontend/src/config/mockLineOfficial.ts b/frontend/src/config/mockLineOfficial.ts new file mode 100644 index 0000000..6d5260d --- /dev/null +++ b/frontend/src/config/mockLineOfficial.ts @@ -0,0 +1,17 @@ +import type { LineOfficialDisplay } from '@/types/authApi' + +/** + * Placeholder LINE Official Account display for onboarding Step 2. + * Replace with GET /stores/{storeKey}/line-official-display when backend is ready. + */ +export const MOCK_LINE_OFFICIAL: LineOfficialDisplay = { + displayName: '奇美花店 Chi-Mei Floral', + // Public placeholder image; swap for store-specific asset later. + imageUrl: + 'https://placehold.co/240x240/e4e7ff/6168fc/png?text=LINE+OA&font=noto-sans', +} + +/** Resolve mock display by store key (multi-store hook for future API). */ +export function getMockLineOfficialDisplay(_storeKey: string): LineOfficialDisplay { + return MOCK_LINE_OFFICIAL +} diff --git a/frontend/src/config/support.ts b/frontend/src/config/support.ts new file mode 100644 index 0000000..fc52a3d --- /dev/null +++ b/frontend/src/config/support.ts @@ -0,0 +1,12 @@ +/** + * Contact info shown when staff reports LINE Official Account display mismatch. + * Update these constants when real support channels are defined. + */ +export const SUPPORT_CONTACT = { + title: '官方帳號顯示不對', + description: + '若畫面上的 LINE 官方帳號名稱或圖示與您店家實際使用的不符,請聯絡技術支援,我們會協助檢查 Channel 設定。', + email: 'support@example.com', + lineId: '@example-support', + hours: '週一至週五 09:00–18:00', +} as const diff --git a/frontend/src/context/OrderDisplayConfigContext.tsx b/frontend/src/context/OrderDisplayConfigContext.tsx index e7db966..ff54d55 100644 --- a/frontend/src/context/OrderDisplayConfigContext.tsx +++ b/frontend/src/context/OrderDisplayConfigContext.tsx @@ -11,8 +11,8 @@ import { fetchOrderFieldConfig, updateOrderFieldConfig, } from '@/api/orderFieldConfig' +import { useAuth } from '@/hooks/useAuth' import { getDefaultConfig, isFieldLockedVisible } from '@/config/orderDisplayFields' -import { useStore } from '@/context/StoreContext' import { buildConfigFromApiResponse, buildOrderDisplayStorageKey, @@ -76,10 +76,8 @@ interface OrderDisplayConfigProviderProps { } export function OrderDisplayConfigProvider({ children }: OrderDisplayConfigProviderProps) { - const { currentStoreId, isReady } = useStore() - const storageKey = - currentStoreId != null ? buildOrderDisplayStorageKey(currentStoreId) : null - + const { isAuthenticated, isLoading: authLoading } = useAuth() + const [storageKey, setStorageKey] = useState(null) const [savedConfig, setSavedConfig] = useState(() => mergeWithRegistry(getDefaultConfig()), ) @@ -89,34 +87,40 @@ export function OrderDisplayConfigProvider({ children }: OrderDisplayConfigProvi const [loadError, setLoadError] = useState(null) useEffect(() => { - if (!isReady) { - return - } - if (currentStoreId == null || storageKey == null) { + if (authLoading) return + + if (!isAuthenticated) { + setStorageKey(null) + const local = mergeWithRegistry(loadConfig()) + setSavedConfig(local) + setDraftConfig(cloneConfig(local)) + setLoadError(null) setLoading(false) return } let cancelled = false setLoading(true) - ;(async () => { try { - const remote = await fetchOrderFieldConfig(currentStoreId) + const remote = await fetchOrderFieldConfig() if (cancelled) { return } - const normalized = buildConfigFromApiResponse(remote, storageKey) + const key = buildOrderDisplayStorageKey(remote.store_id) + setStorageKey(key) + const normalized = buildConfigFromApiResponse(remote, key) setSavedConfig(normalized) setDraftConfig(cloneConfig(normalized)) - saveConfig(normalized, storageKey) + saveConfig(normalized, key) setLoadError(null) } catch (err) { if (cancelled) { return } const message = err instanceof Error ? err.message : String(err) - const fromLocal = mergeWithRegistry(loadConfig(storageKey)) + const fallbackKey = storageKey ?? 'order-display-config:unknown' + const fromLocal = mergeWithRegistry(loadConfig(fallbackKey)) setSavedConfig(fromLocal) setDraftConfig(cloneConfig(fromLocal)) setLoadError(`讀取後端欄位設定失敗,已改用本機暫存:${message}`) @@ -130,7 +134,7 @@ export function OrderDisplayConfigProvider({ children }: OrderDisplayConfigProvi return () => { cancelled = true } - }, [currentStoreId, isReady, storageKey]) + }, [authLoading, isAuthenticated]) const hasChanges = useMemo( () => !configsEqual(savedConfig, draftConfig), @@ -166,25 +170,27 @@ export function OrderDisplayConfigProvider({ children }: OrderDisplayConfigProvi }, [savedConfig]) const save = useCallback(async () => { - if (currentStoreId == null || storageKey == null) { - throw new Error('No store selected') + if (!isAuthenticated) { + throw new Error('Not signed in') } setSavePending(true) const normalized = mergeWithRegistry(draftConfig) try { - const remote = await updateOrderFieldConfig(currentStoreId, { + const remote = await updateOrderFieldConfig({ visible_fields: extractVisibleFieldKeys(normalized), field_order: extractFieldOrderKeys(normalized), }) - const applied = buildConfigFromApiResponse(remote, storageKey) - saveConfig(applied, storageKey) + const key = buildOrderDisplayStorageKey(remote.store_id) + setStorageKey(key) + const applied = buildConfigFromApiResponse(remote, key) + saveConfig(applied, key) setSavedConfig(applied) setDraftConfig(cloneConfig(applied)) setLoadError(null) } finally { setSavePending(false) } - }, [currentStoreId, draftConfig, storageKey]) + }, [draftConfig, isAuthenticated]) const value = useMemo( () => ({ diff --git a/frontend/src/context/StoreContext.tsx b/frontend/src/context/StoreContext.tsx index 5080d2c..9b09572 100644 --- a/frontend/src/context/StoreContext.tsx +++ b/frontend/src/context/StoreContext.tsx @@ -7,7 +7,8 @@ import { useState, type ReactNode, } from 'react' -import { fetchStores, type StoreListItem } from '@/api/stores' +import { fetchMyStore, type StoreListItem } from '@/api/stores' +import { useAuth } from '@/hooks/useAuth' import { getActiveStoreId, setActiveStoreId } from '@/lib/activeStoreStorage' export interface StoreContextValue { @@ -22,24 +23,12 @@ export interface StoreContextValue { const StoreContext = createContext(null) -function resolveInitialStoreId( - stores: StoreListItem[], - storedId: number | null, -): number | null { - if (stores.length === 0) { - return null - } - if (storedId != null && stores.some(s => s.id === storedId)) { - return storedId - } - return stores[0].id -} - interface StoreProviderProps { children: ReactNode } export function StoreProvider({ children }: StoreProviderProps) { + const { isAuthenticated, isLoading: authLoading } = useAuth() const [stores, setStores] = useState([]) const [currentStoreId, setCurrentStoreIdState] = useState(() => getActiveStoreId(), @@ -48,20 +37,30 @@ export function StoreProvider({ children }: StoreProviderProps) { const [error, setError] = useState(null) useEffect(() => { + if (authLoading) return + + if (!isAuthenticated) { + setStores([]) + setCurrentStoreIdState(null) + setActiveStoreId(null) + setError(null) + setLoading(false) + return + } + let cancelled = false async function load() { setLoading(true) setError(null) try { - const list = await fetchStores() + const myStore = await fetchMyStore() if (cancelled) { return } - setStores(list) - const resolved = resolveInitialStoreId(list, getActiveStoreId()) - setCurrentStoreIdState(resolved) - setActiveStoreId(resolved) + setStores([myStore]) + setCurrentStoreIdState(myStore.id) + setActiveStoreId(myStore.id) } catch (err) { if (cancelled) { return @@ -82,7 +81,7 @@ export function StoreProvider({ children }: StoreProviderProps) { return () => { cancelled = true } - }, []) + }, [authLoading, isAuthenticated]) const setCurrentStoreId = useCallback((storeId: number) => { setCurrentStoreIdState(storeId) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..3a03d82 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,195 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' +import type { Session, User } from '@supabase/supabase-js' +import { authApi } from '@/api/auth' +import { + clearSession as clearStaffSession, + getSession as getStaffSession, + nextMockStaffId, + resetMockAuthStorage, + setSession as setStaffSession, +} from '@/lib/authStorage' +import { supabase } from '@/lib/supabase' +import type { StaffSession } from '@/types/auth' +import type { LineOfficialDisplay } from '@/types/authApi' + +const DEFAULT_STORE_KEY = 'demo-store' + +export interface AuthContextValue { + session: StaffSession | null + isAuthenticated: boolean + isLoading: boolean + signInWithGoogle: () => Promise + signOut: () => Promise + updateDisplayName: (name: string) => StaffSession + confirmLineOfficial: () => StaffSession + completeOnboarding: () => StaffSession + rejectWrongAccount: () => Promise + getLineOfficialDisplay: (storeKey: string) => LineOfficialDisplay + /** DEV: clear mock session and staff ID counter. */ + resetMockAuth: () => void +} + +export const AuthContext = createContext(null) + +function createStaffSessionFromUser(user: User): StaffSession { + const displayName = + (user.user_metadata?.full_name as string | undefined) ?? + user.email ?? + '管理員' + + const session: StaffSession = { + staffId: nextMockStaffId(), + storeKey: DEFAULT_STORE_KEY, + displayName, + onboardingStep: 'NAME', + role: 'OWNER', + } + setStaffSession(session) + return session +} + +/** Load or create StaffSession when Supabase auth is present. */ +function bridgeStaffSession(supabaseSession: Session | null): StaffSession | null { + if (!supabaseSession?.access_token) { + return null + } + + const existing = getStaffSession() + if (existing) { + return existing + } + + // TODO: replace with GET /auth/me when backend Google OAuth is ready. + return createStaffSessionFromUser(supabaseSession.user) +} + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [session, setSession] = useState(null) + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let mounted = true + + function applySupabaseSession(supabaseSession: Session | null) { + const authed = Boolean(supabaseSession?.access_token) + setIsAuthenticated(authed) + setSession(authed ? bridgeStaffSession(supabaseSession) : null) + if (!authed) { + clearStaffSession() + } + } + + supabase.auth.getSession().then(({ data: { session: supabaseSession } }) => { + if (!mounted) return + applySupabaseSession(supabaseSession) + setIsLoading(false) + }) + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((event, supabaseSession) => { + if (!mounted) return + applySupabaseSession(supabaseSession) + if (event === 'INITIAL_SESSION') { + setIsLoading(false) + } + }) + + return () => { + mounted = false + subscription.unsubscribe() + } + }, []) + + const signInWithGoogle = useCallback(async () => { + await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: `${window.location.origin}/login` }, + }) + }, []) + + const signOut = useCallback(async () => { + authApi.logout() + setSession(null) + setIsAuthenticated(false) + await supabase.auth.signOut() + }, []) + + const updateDisplayName = useCallback((name: string) => { + const next = authApi.updateDisplayName(name) + setSession(next) + return next + }, []) + + const confirmLineOfficial = useCallback(() => { + const next = authApi.confirmLineOfficial() + setSession(next) + return next + }, []) + + const completeOnboarding = useCallback(() => { + const next = authApi.completeOnboarding() + setSession(next) + return next + }, []) + + const rejectWrongAccount = useCallback(async () => { + authApi.rejectWrongAccount() + setSession(null) + setIsAuthenticated(false) + await supabase.auth.signOut() + }, []) + + const getLineOfficialDisplay = useCallback( + (storeKey: string) => authApi.getLineOfficialDisplay(storeKey), + [], + ) + + const resetMockAuth = useCallback(() => { + resetMockAuthStorage() + setSession(null) + }, []) + + const value = useMemo( + () => ({ + session, + isAuthenticated, + isLoading, + signInWithGoogle, + signOut, + updateDisplayName, + confirmLineOfficial, + completeOnboarding, + rejectWrongAccount, + getLineOfficialDisplay, + resetMockAuth, + }), + [ + session, + isAuthenticated, + isLoading, + signInWithGoogle, + signOut, + updateDisplayName, + confirmLineOfficial, + completeOnboarding, + rejectWrongAccount, + getLineOfficialDisplay, + resetMockAuth, + ], + ) + + return {children} +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..19168ff --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' +import { AuthContext, type AuthContextValue } from '@/contexts/AuthContext' + +/** Access auth session and onboarding actions from AuthProvider. */ +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) { + throw new Error('useAuth must be used within AuthProvider') + } + return ctx +} diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx new file mode 100644 index 0000000..06d20e6 --- /dev/null +++ b/frontend/src/layouts/AppLayout.tsx @@ -0,0 +1,14 @@ +import { Outlet } from 'react-router-dom' +import Navbar from '@/components/layout/Navbar' + +/** Main app shell with top Navbar (orders, messages, stats, etc.). */ +export default function AppLayout() { + return ( + <> + +
+ +
+ + ) +} diff --git a/frontend/src/layouts/OnboardingLayout.tsx b/frontend/src/layouts/OnboardingLayout.tsx new file mode 100644 index 0000000..3d8f4d1 --- /dev/null +++ b/frontend/src/layouts/OnboardingLayout.tsx @@ -0,0 +1,32 @@ +import { Outlet } from 'react-router-dom' + +const GRADIENT_STYLE = { + background: 'linear-gradient(160deg, #6168FC 0%, #77B5FF 55%, #e4e7ff 100%)', +} as const + +/** + * Layout for onboarding wizard steps (no main Navbar). + * Child routes render inside the centered card region via Outlet. + */ +export default function OnboardingLayout() { + return ( +
+
+

+ 奇美花店 +

+

+ Chi-Mei Floral +

+

帳號設定

+
+ +
+ +
+
+ ) +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..96b12ca --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,34 @@ +import { redirect } from 'react-router-dom' +import { isSupabaseConfigured, supabase } from '@/lib/supabase' + +export async function requireAuth() { + if (!isSupabaseConfigured) { + throw redirect('/login') + } + + const { + data: { session }, + } = await supabase.auth.getSession() + + if (!session?.access_token) { + throw redirect('/login') + } + + return null +} + +export async function redirectIfAuthed() { + if (!isSupabaseConfigured) { + return null + } + + const { + data: { session }, + } = await supabase.auth.getSession() + + if (session?.access_token) { + throw redirect('/') + } + + return null +} diff --git a/frontend/src/lib/authStorage.ts b/frontend/src/lib/authStorage.ts new file mode 100644 index 0000000..dc917c6 --- /dev/null +++ b/frontend/src/lib/authStorage.ts @@ -0,0 +1,57 @@ +import type { StaffSession } from '@/types/auth' + +/** localStorage key for the authenticated staff session. */ +export const AUTH_SESSION_KEY = 'flower_auth_session' + +/** Counter for generating unique mock staff IDs across logins. */ +const MOCK_STAFF_ID_COUNTER_KEY = 'flower_auth_mock_staff_counter' + +function isStaffSession(value: unknown): value is StaffSession { + if (!value || typeof value !== 'object') return false + const s = value as Record + return ( + typeof s.staffId === 'number' && + typeof s.storeKey === 'string' && + typeof s.displayName === 'string' && + (s.onboardingStep === 'NAME' || + s.onboardingStep === 'LINE_OA' || + s.onboardingStep === 'DONE') + ) +} + +/** Read the current session from localStorage, or null if missing/invalid. */ +export function getSession(): StaffSession | null { + try { + const raw = localStorage.getItem(AUTH_SESSION_KEY) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + return isStaffSession(parsed) ? parsed : null + } catch { + return null + } +} + +/** Persist the session to localStorage. */ +export function setSession(session: StaffSession): void { + localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) +} + +/** Remove the session from localStorage. */ +export function clearSession(): void { + localStorage.removeItem(AUTH_SESSION_KEY) +} + +/** Allocate the next mock staff ID (persists across page reloads). */ +export function nextMockStaffId(): number { + const raw = localStorage.getItem(MOCK_STAFF_ID_COUNTER_KEY) + const current = raw ? Number.parseInt(raw, 10) : 0 + const next = Number.isFinite(current) ? current + 1 : 1 + localStorage.setItem(MOCK_STAFF_ID_COUNTER_KEY, String(next)) + return next +} + +/** DEV helper: clear session and staff ID counter for a fresh onboarding run. */ +export function resetMockAuthStorage(): void { + clearSession() + localStorage.removeItem(MOCK_STAFF_ID_COUNTER_KEY) +} diff --git a/frontend/src/lib/onboardingPaths.ts b/frontend/src/lib/onboardingPaths.ts new file mode 100644 index 0000000..df39a3d --- /dev/null +++ b/frontend/src/lib/onboardingPaths.ts @@ -0,0 +1,13 @@ +import type { OnboardingStep } from '@/types/auth' + +/** Route for the given onboarding step. */ +export function getOnboardingPath(step: OnboardingStep): string { + switch (step) { + case 'NAME': + return '/onboarding/name' + case 'LINE_OA': + return '/onboarding/line-official' + case 'DONE': + return '/settings/order-fields' + } +} diff --git a/frontend/src/lib/supabase.ts b/frontend/src/lib/supabase.ts new file mode 100644 index 0000000..8b74b1f --- /dev/null +++ b/frontend/src/lib/supabase.ts @@ -0,0 +1,22 @@ +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string | undefined +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined + +export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseAnonKey) + +if (!isSupabaseConfigured) { + console.error('Missing VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY') +} + +export const supabase = createClient( + supabaseUrl ?? 'https://placeholder.supabase.co', + supabaseAnonKey ?? 'placeholder', + { + auth: { + flowType: 'pkce', + detectSessionInUrl: true, + persistSession: true, + }, + }, +) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 62ddf16..b7f36a2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClientProvider } from '@tanstack/react-query' import { RouterProvider } from 'react-router-dom' +import { AuthProvider } from '@/contexts/AuthContext' import { OrderDisplayConfigProvider } from '@/context/OrderDisplayConfigContext' import { StoreProvider } from '@/context/StoreContext' import { router } from './router' @@ -16,11 +17,13 @@ if (!rootElement) { createRoot(rootElement).render( - - - - - + + + + + + + , ) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2b84021 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,66 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import AuthLoading from '@/components/auth/AuthLoading' +import { useAuth } from '@/hooks/useAuth' +import { getOnboardingPath } from '@/lib/onboardingPaths' +import { isSupabaseConfigured } from '@/lib/supabase' + +export default function LoginPage() { + const { session, isAuthenticated, isLoading, signInWithGoogle } = useAuth() + const navigate = useNavigate() + + useEffect(() => { + if (isLoading || !isAuthenticated || !session) return + + const target = + session.onboardingStep === 'DONE' ? '/' : getOnboardingPath(session.onboardingStep) + navigate(target, { replace: true }) + }, [isLoading, isAuthenticated, session, navigate]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!isSupabaseConfigured) { + return ( +
+
+

無法登入

+

+ 請在 frontend/.env.local 設定 VITE_SUPABASE_URL 與 VITE_SUPABASE_ANON_KEY,然後重啟前端。 +

+
+
+ ) + } + + return ( +
+
+
+
🌸
+

花店訂單系統

+

請使用 Google 帳號登入

+
+ + +
+
+ ) +} diff --git a/frontend/src/pages/onboarding/OnboardingLineOfficialPage.tsx b/frontend/src/pages/onboarding/OnboardingLineOfficialPage.tsx new file mode 100644 index 0000000..663a1b7 --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingLineOfficialPage.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import OnboardingCard from '@/components/onboarding/OnboardingCard' +import StepIndicator from '@/components/onboarding/StepIndicator' +import { SUPPORT_CONTACT } from '@/config/support' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { useAuth } from '@/hooks/useAuth' +import { cn } from '@/lib/utils' + +export default function OnboardingLineOfficialPage() { + const { session, confirmLineOfficial, rejectWrongAccount, getLineOfficialDisplay } = + useAuth() + const navigate = useNavigate() + const [wrongAccountOpen, setWrongAccountOpen] = useState(false) + const [supportOpen, setSupportOpen] = useState(false) + + if (!session) { + return null + } + + const lineOfficial = getLineOfficialDisplay(session.storeKey) + + function handleConfirmCorrect() { + confirmLineOfficial() + navigate('/settings/order-fields', { replace: true }) + } + + function handleRejectWrongAccount() { + rejectWrongAccount() + navigate('/login?error=wrong_account', { replace: true }) + } + + return ( + <> + + +
+ +

+ {lineOfficial.displayName} +

+
+ +
+ + + + + +
+
+ + + + + 重新登入 + + 將登出目前帳號。請改用店家授權的 Google 帳號重新登入。 + + + + + + + + + + + + + {SUPPORT_CONTACT.title} + {SUPPORT_CONTACT.description} + + + + + + + + + ) +} diff --git a/frontend/src/pages/onboarding/OnboardingNamePage.tsx b/frontend/src/pages/onboarding/OnboardingNamePage.tsx new file mode 100644 index 0000000..0a3e2cb --- /dev/null +++ b/frontend/src/pages/onboarding/OnboardingNamePage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' +import OnboardingCard from '@/components/onboarding/OnboardingCard' +import StepIndicator from '@/components/onboarding/StepIndicator' +import { useAuth } from '@/hooks/useAuth' +import { cn } from '@/lib/utils' + +const DEFAULT_NAME = '管理員' +const MAX_NAME_LENGTH = 32 + +export default function OnboardingNamePage() { + const { session, updateDisplayName } = useAuth() + const navigate = useNavigate() + const [name, setName] = useState(DEFAULT_NAME) + const [error, setError] = useState(null) + + useEffect(() => { + if (session?.displayName) { + setName(session.displayName) + } + }, [session?.displayName]) + + function handleSubmit(event: FormEvent) { + event.preventDefault() + setError(null) + + const trimmed = name.trim() + if (!trimmed) { + setError('請輸入顯示名稱') + return + } + if (trimmed.length > MAX_NAME_LENGTH) { + setError(`顯示名稱最多 ${MAX_NAME_LENGTH} 個字元`) + return + } + + try { + updateDisplayName(trimmed) + navigate('/onboarding/line-official', { replace: true }) + } catch (err) { + setError(err instanceof Error ? err.message : '無法儲存名稱') + } + } + + return ( + <> + + +
+ + + {error ? ( +

+ {error} +

+ ) : null} + + +
+
+ + ) +} diff --git a/frontend/src/pages/settings/OrderFieldsPage.tsx b/frontend/src/pages/settings/OrderFieldsPage.tsx new file mode 100644 index 0000000..1262b36 --- /dev/null +++ b/frontend/src/pages/settings/OrderFieldsPage.tsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react' +import { Link } from 'react-router-dom' +import PageHeader from '@/components/layout/PageHeader' +import { useAuth } from '@/hooks/useAuth' +import { cn } from '@/lib/utils' + +/** + * Placeholder for store order list visible-field settings. + * Entering this route marks onboarding as DONE (see useEffect below). + */ +export default function OrderFieldsPage() { + const { session, completeOnboarding } = useAuth() + const hasCompletedRef = useRef(false) + + useEffect(() => { + if (hasCompletedRef.current) return + hasCompletedRef.current = true + completeOnboarding() + }, [completeOnboarding]) + + const storeKey = session?.storeKey ?? '—' + + return ( + <> + +
+
+

+ 此頁將用於設定訂單列表與工單中要顯示的欄位。功能開發中,完成 onboarding + 後您可先使用其他後台功能。 +

+

+ 之後會串接 API: + + GET/PUT /stores/{'{storeKey}'}/display-fields + + (目前店家: + {storeKey}) +

+ + + 前往首頁 + +
+
+ + ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 9f38bb0..35d54c0 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,21 +1,131 @@ -import { createBrowserRouter } from 'react-router-dom' +import type { ReactNode } from 'react' +import { createBrowserRouter, Navigate } from 'react-router-dom' +import RequireAuth from '@/components/auth/RequireAuth' +import RequireOnboardingDone from '@/components/auth/RequireOnboardingDone' +import OnboardingStepGuard from '@/components/auth/OnboardingStepGuard' +import OnboardingIndexRedirect from '@/components/auth/OnboardingIndexRedirect' +import AuthLoading from '@/components/auth/AuthLoading' import App from './App' +import OnboardingLayout from './layouts/OnboardingLayout' import DashboardPage from './pages/DashboardPage' import OrdersPage from './pages/OrdersPage' import MessagesPage from './pages/MessagesPage' import StatsPage from './pages/StatsPage' +import LoginPage from './pages/LoginPage' +import OnboardingNamePage from './pages/onboarding/OnboardingNamePage' +import OnboardingLineOfficialPage from './pages/onboarding/OnboardingLineOfficialPage' +import OrderFieldsPage from './pages/settings/OrderFieldsPage' import OrderFieldSettingsPage from './pages/OrderFieldSettingsPage' +import { useAuth } from '@/hooks/useAuth' + +/** Wraps main back-office pages; blocks until onboarding is DONE. */ +function ProtectedPage({ children }: { children: ReactNode }) { + return {children} +} + +/** + * Onboarding finale uses OrderFieldsPage (calls completeOnboarding on mount). + * After DONE, the same path serves the full OrderFieldSettingsPage from the sidebar. + * Intentionally not wrapped in ProtectedPage so LINE_OA can enter this route. + */ +function OrderFieldsRoute() { + const { session, isLoading } = useAuth() + + if (isLoading) { + return + } + + if (!session) { + return null + } + + if (session.onboardingStep !== 'DONE') { + return + } + + return +} export const router = createBrowserRouter([ + { + path: '/login', + element: , + }, + { + path: '/onboarding', + element: ( + + + + ), + children: [ + { index: true, element: }, + { + path: 'name', + element: ( + + + + ), + }, + { + path: 'line-official', + element: ( + + + + ), + }, + ], + }, { path: '/', - element: , + element: ( + + + + ), children: [ - { index: true, element: }, - { path: 'orders', element: }, - { path: 'messages', element: }, - { path: 'stats', element: }, - { path: 'settings/order-fields', element: }, + { + index: true, + element: ( + + + + ), + }, + { + path: 'orders', + element: ( + + + + ), + }, + { + path: 'messages', + element: ( + + + + ), + }, + { + path: 'stats', + element: ( + + + + ), + }, + { + path: 'settings/order-fields', + element: , + }, ], }, + { + path: '*', + element: , + }, ]) diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..5358558 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,14 @@ +/** Staff role aligned with backend StaffRole enum. */ +export type StaffRole = 'OWNER' | 'CLERK' | 'ADMIN' + +/** Onboarding progress for a single staff member. */ +export type OnboardingStep = 'NAME' | 'LINE_OA' | 'DONE' + +/** Authenticated staff session (mock now; same shape for future GET /auth/me). */ +export interface StaffSession { + staffId: number + storeKey: string + displayName: string + onboardingStep: OnboardingStep + role?: StaffRole +} diff --git a/frontend/src/types/authApi.ts b/frontend/src/types/authApi.ts new file mode 100644 index 0000000..4b327e2 --- /dev/null +++ b/frontend/src/types/authApi.ts @@ -0,0 +1,48 @@ +import type { OnboardingStep, StaffRole, StaffSession } from '@/types/auth' + +/** + * Future: GET /auth/me + * Returns the current authenticated staff session. + */ +export type AuthMeResponse = StaffSession + +/** + * Future: PATCH /auth/me/onboarding + * Body for onboarding step transitions. + */ +export type OnboardingPatchBody = + | { step: 'name'; name: string } + | { step: 'line_oa_confirmed' } + | { step: 'complete' } + | { step: 'line_oa_rejected'; reason: 'wrong_google_account' | 'oa_display_mismatch' } + +/** + * Future: PATCH /auth/me/onboarding response + */ +export interface OnboardingPatchResponse { + onboardingStep: OnboardingStep + displayName?: string +} + +/** + * Future: GET /stores/{storeKey}/line-official-display + * LINE Official Account info shown during onboarding Step 2. + */ +export interface LineOfficialDisplay { + displayName: string + imageUrl: string +} + +/** + * Future: POST /auth/logout + */ +export type LogoutResponse = void + +/** + * Future: staff creation / invite metadata (for reference when wiring Google OAuth). + */ +export interface StaffInviteMetadata { + storeKey: string + role: StaffRole + email?: string +}