透過 Supabase Database 和 REST API,建立完整的商家列表和預約功能,讓使用者能夠瀏覽商家並進行排隊預約。
商家管理系統
預約排隊功能
API 整合
首先建立商家(stores)資料表,用來儲存所有商家的基本資訊:
CREATE TABLE IF NOT EXISTS public.stores (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
owner_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL DEFAULT 'service',
avatar_url TEXT,
address TEXT,
contact_email VARCHAR(255),
rating DECIMAL(2,1) DEFAULT 0.0,
review_count INTEGER DEFAULT 0,
queue_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
接著建立預約(bookings)資料表,用來管理使用者的排隊預約:
CREATE TABLE IF NOT EXISTS public.bookings (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
store_id UUID REFERENCES public.stores(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
queue_number INTEGER NOT NULL,
status VARCHAR(20) DEFAULT 'waiting' CHECK (status IN ('waiting', 'serving', 'completed', 'cancelled')),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE
);
ALTER TABLE public.stores ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public stores are viewable by everyone" ON public.stores
FOR SELECT USING (is_active = true);
CREATE POLICY "Users can insert their own stores" ON public.stores
FOR INSERT WITH CHECK (auth.uid() = owner_id);
CREATE POLICY "Users can update their own stores" ON public.stores
FOR UPDATE USING (auth.uid() = owner_id);
ALTER TABLE public.bookings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own bookings" ON public.bookings
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Store owners can view bookings for their stores" ON public.bookings
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.stores
WHERE stores.id = bookings.store_id
AND stores.owner_id = auth.uid()
)
);
CREATE POLICY "Users can insert their own bookings" ON public.bookings
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Store owners can update bookings for their stores" ON public.bookings
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.stores
WHERE stores.id = bookings.store_id
AND stores.owner_id = auth.uid()
)
);
在 bookings 資料表中,user_id 欄位是關聯到 auth.users 的 id,這是為了確保只有登入的使用者才能建立預約,並且符合RLS(行級安全性)的設計。
當我們要在應用程式中顯示預約資訊時,我們通常需要顯示的是公開的使用者資訊(例如:是哪位使用者預約的),而不是auth.users 中的敏感資訊。這就產生了一個問題:bookings表直接關聯的是 auth.users,但我們需要顯示的是 public.users的資料。
建立遷移檔案:
首先,supabase/migrations
資料夾中建立一個新的 SQL 檔案。檔案名稱建議使用時間戳加上描述性的名稱,例如:20250801000000_create_bookings_with_users_view.sql
。
建立檔案後,執行 supabase migration up
指令,Supabase 會自動將這個視圖套用到本地資料庫。
-- 透過 b.user_id = u.id 這個條件,將 bookings 資料表中的每一筆預約,都去 users 資料表中找出對應的使用者資料。
-- 查詢這個視圖時,你得到的每一筆預約資料,都會額外包含 user_name, user_email, 和 user_avatar_url 這幾個來自 public.users 的欄位。
-- 建立一個新的視圖,如果同名的視圖已經存在,則會取代它。
CREATE OR REPLACE VIEW public.bookings_with_users AS
SELECT
b.*,
u.name as user_name,
u.email as user_email,
u.phone as user_phone,
u.avatar_url as user_avatar_url
FROM public.bookings b
LEFT JOIN public.users u ON b.user_id = u.id;
-- 設置安全性和權限
-- 當使用者查詢這個視圖時,會使用查詢者本身的權限來檢查底層資料表(bookings 和 users)的存取權限。
-- 例如:使用者只能看到自己的預約,而商家可以看到所有他們商家的預約。
ALTER VIEW public.bookings_with_users SET (security_invoker = true);
-- 指令授權所有「已登入」的使用者 (authenticated 角色) 都可以查詢這兩個視圖。
GRANT SELECT ON public.bookings_with_users TO authenticated;
測試回傳結果:
你可以直接在 Supabase Studio 的 SQL 編輯器中查詢這個視圖,就像查詢一張普通的資料表一樣。
測試語法:
-- 查詢視圖中的所有資料
SELECT * FROM public.bookings_with_users;
-- 查詢特定商家的預約資訊
SELECT *
FROM public.bookings_with_users
WHERE store_id = 'YOUR_STORE_ID'; -- 將 YOUR_STORE_ID 換成實際的商家 ID
執行查詢後,你應該能看到 bookings
資料表的欄位,並且額外附加了來自 users
資料表的 user_name
, user_avatar_url
, user_email
, 和 user_phone
欄位,這證明了視圖已經成功地將兩個資料表關聯起來。
雖然視圖(View)對於簡單的資料關聯很有用,但當我們需要根據特定參數(例如,只查詢某個商家的預約)來篩選資料時,使用資料庫函式(Function)會是更強大且靈活的選擇。
以下我們建立一個名為 get_bookings_with_user_info
的函式,它接受一個 p_store_id
作為參數,並回傳該商家的所有預約,同時包含預約者的詳細資訊。
特別注意: 在定義 RETURNS TABLE
時,每個欄位的資料型別都必須與 SELECT
查詢回傳的欄位型別完全一致。如果型別不匹配(例如,資料庫中的 email
是 character varying(255)
,但在函式中定義為 text
),就會導致錯誤。
-- 首先,如果舊的函式存在,先刪除它
drop function if exists public.get_bookings_with_user_info(uuid);
-- 建立或取代函式
create or replace function public.get_bookings_with_user_info(p_store_id uuid)
-- 定義回傳的資料表結構,確保型別完全匹配
returns table (
id uuid,
created_at timestamptz,
store_id uuid,
user_id uuid,
queue_number integer,
status character varying(20),
user_name character varying(255),
user_avatar_url text,
user_email character varying(255), -- 必須與 users.email 的型別一致
user_phone character varying(255) -- 必須與 users.phone 的型別一致
)
language plpgsql
-- 使用呼叫者的權限,這樣 RLS 規則才會生效
security invoker
set search_path = ''
as $
begin
-- 執行查詢,並將 bookings 和 users 資料表連接起來
return query
select
b.id,
b.created_at,
b.store_id,
b.user_id,
b.queue_number,
b.status,
u.name as user_name,
u.avatar_url as user_avatar_url,
u.email as user_email,
u.phone as user_phone
from
public.bookings as b
join
public.users as u on b.user_id = u.id
where
b.store_id = p_store_id -- 使用傳入的參數進行篩選
order by
b.queue_number asc;
end;
$;
-- 為函式加上註解,方便理解
comment on function public.get_bookings_with_user_info(uuid) is 'Fetches bookings for a given store, including user information (name, avatar, email, and phone).';
視圖和資料庫函式都可以用來封裝複雜的查詢,但它們在使用情境和功能上有著關鍵的區別。簡單來說,最大的差別在於「函式可以接收參數,而視圖不行」。
特性 | 視圖 (View) | 資料庫函式 (Database Function) |
---|---|---|
使用方式 | 像查詢一張普通的資料表,過濾條件需用 WHERE 加在外部查詢中。 |
像呼叫一個 API,直接傳入參數來取得篩選後的結果。 |
使用情境 | 適合將一個常用的、固定的查詢(如 JOIN )封裝起來,簡化後續查詢。 |
適合需要動態查詢的場景,例如根據特定 ID 獲取資料。 |
效能 | 對於大型資料表,先產生完整視圖再過濾,效能可能較差。 | 函式可以在內部就用參數過濾資料,只回傳必要的結果,效能通常更好。 |
總結:
透過這種方式,不僅能實現與視圖類似的資料關聯查詢,還能傳遞參數來動態篩選結果,並且確保 RLS 規則得到正確應用。
// lib/supabase/stores.ts
export interface Store {
id: string;
owner_id: string;
name: string;
category: string;
description: string;
address: string;
avatar_url: string;
rating: number;
review_count: number;
queue_count: number;
contact_email: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateStoreData {
name: string;
description?: string;
category?: string;
avatar_url?: string;
address?: string;
contact_email?: string;
}
export interface Booking {
id: string;
store_id: string;
user_id: string;
queue_number: number;
status: "waiting" | "serving" | "completed" | "cancelled";
notes?: string;
created_at: string;
completed_at?: string;
}
export async function getStores(filters?: {
category?: string;
search?: string;
limit?: number;
offset?: number;
}) {
let query = supabase
.from("stores")
.select("*")
.eq("is_active", true)
.order("created_at", { ascending: false });
if (filters?.category && filters.category !== "all") {
query = query.eq("category", filters.category);
}
if (filters?.search) {
query = query.or(
`name.ilike.%${filters.search}%,description.ilike.%${filters.search}%,category.ilike.%${filters.search}%`
);
}
if (filters?.limit) {
query = query.limit(filters.limit);
}
if (filters?.offset) {
query = query.range(
filters.offset,
filters.offset + (filters.limit || 10) - 1
);
}
const { data, error } = await query;
if (error) {
console.error("Error fetching stores:", error);
throw error;
}
return data as Store[];
}
export async function createBooking(
storeId: string,
userId: string,
notes?: string
) {
// 首先,獲取商店的當前隊列計數
const { data: store, error: storeError } = await supabase
.from("stores")
.select("queue_count")
.eq("id", storeId)
.single();
if (storeError) {
console.error("Error fetching store queue count:", storeError);
throw storeError;
}
const queueNumber = (store?.queue_count || 0) + 1;
// 創建預訂
const { data, error } = await supabase
.from("bookings")
.insert({
store_id: storeId,
user_id: userId,
queue_number: queueNumber,
status: "waiting",
notes: notes || null,
})
.select()
.single();
if (error) {
console.error("Error creating booking:", error);
throw error;
}
// 更新商店的排隊計數
const { error: updateError } = await supabase
.from("stores")
.update({ queue_count: queueNumber })
.eq("id", storeId);
if (updateError) {
console.error("Error updating store queue count:", updateError);
}
return data as Booking;
}
export async function createStore(storeData: CreateStoreData) {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error("User must be authenticated to create a store");
}
const { data, error } = await supabase
.from("stores")
.insert({
owner_id: user.id,
name: storeData.name,
description: storeData.description || "",
category: storeData.category || "service",
avatar_url: storeData.avatar_url || "",
address: storeData.address || "",
contact_email: storeData.contact_email || user.email,
is_active: true,
})
.select()
.single();
if (error) {
console.error("Error creating store:", error);
throw error;
}
return data as Store;
}
建立 API 測試集合,包含 CRUD 操作:
{
"info": {
"name": "Stores & Bookings API",
"description": "API collection for stores and bookings management"
},
"variable": [
{
"key": "API_URL",
"value": "http://localhost:54321"
},
{
"key": "ANON_KEY",
"value": "your-anon-key"
},
{
"key": "ACCESS_TOKEN",
"value": "your-access-token"
}
]
}
獲取所有商家:
GET {{API_URL}}/rest/v1/stores?select=*&is_active=eq.true&order=rating.desc
建立新商家:
POST {{API_URL}}/rest/v1/stores
Content-Type: application/json
Authorization: Bearer {{ACCESS_TOKEN}}
{
"owner_id": "{{USER_ID}}",
"name": "我的新商家",
"description": "提供優質服務的商家",
"category": "restaurant",
"address": "台北市信義區",
"contact_email": "store@example.com"
}
為來企排隊應用提供了核心的業務邏輯基礎,使用者可以瀏覽商家、進行預約,商家可以管理預約狀態,形成完整的排隊管理系統。
... to be continued
有任何想討論歡迎留言,或需要指正的地方請鞭大力一點,歡迎訂閱、按讚加分享,分享給想要提升開發效率的朋友