iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Software Development

我獨自開發 - 用 Supabase 打造全端應用系列 第 24

第二十四關 - 來企排隊: Supabase 快速建立商家列表和預約功能

  • 分享至 

  • xImage
  •  

商家列表

透過 Supabase Database 和 REST API,建立完整的商家列表和預約功能,讓使用者能夠瀏覽商家並進行排隊預約。

主要功能

  1. 商家管理系統

    • 建立商家資料表和關聯
    • 支援商家分類、聯絡資訊
    • 實作行級安全性(RLS)政策
  2. 預約排隊功能

    • 建立預約資料表
    • 自動分配排隊號碼
    • 支援預約狀態管理
  3. API 整合

    • 建立 RESTful API 端點
    • 實作資料查詢和關聯
    • 處理複雜的資料關係

第一步:資料庫架構設計

1.1 建立商家資料表

首先建立商家(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
);

1.2 建立預約資料表

接著建立預約(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
);

第二步:設定行級安全性(RLS)

2.1 商家資料表的 RLS 政策

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);

2.2 預約資料表的 RLS 政策

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()
        )
    );

第三步:解決資料關聯問題

3.1 建立資料視圖

在 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 欄位,這證明了視圖已經成功地將兩個資料表關聯起來。

3.2 使用資料庫函式處理帶有參數的查詢

雖然視圖(View)對於簡單的資料關聯很有用,但當我們需要根據特定參數(例如,只查詢某個商家的預約)來篩選資料時,使用資料庫函式(Function)會是更強大且靈活的選擇。

以下我們建立一個名為 get_bookings_with_user_info 的函式,它接受一個 p_store_id 作為參數,並回傳該商家的所有預約,同時包含預約者的詳細資訊。

特別注意: 在定義 RETURNS TABLE 時,每個欄位的資料型別都必須與 SELECT 查詢回傳的欄位型別完全一致。如果型別不匹配(例如,資料庫中的 emailcharacter 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).';

3.3 視圖(View)與函式(Function)的選擇

視圖和資料庫函式都可以用來封裝複雜的查詢,但它們在使用情境和功能上有著關鍵的區別。簡單來說,最大的差別在於「函式可以接收參數,而視圖不行」。

特性 視圖 (View) 資料庫函式 (Database Function)
使用方式 像查詢一張普通的資料表,過濾條件需用 WHERE 加在外部查詢中。 像呼叫一個 API,直接傳入參數來取得篩選後的結果。
使用情境 適合將一個常用的、固定的查詢(如 JOIN)封裝起來,簡化後續查詢。 適合需要動態查詢的場景,例如根據特定 ID 獲取資料。
效能 對於大型資料表,先產生完整視圖再過濾,效能可能較差。 函式可以在內部就用參數過濾資料,只回傳必要的結果,效能通常更好。

總結:

  • 當你需要一個固定的、全域性的資料組合,讓前端可以自由查詢時,使用「視圖」。
  • 當你需要一個可重複使用的、需要根據特定條件動態查詢的 API 端點時,使用「資料庫函式」是更佳的選擇。

透過這種方式,不僅能實現與視圖類似的資料關聯查詢,還能傳遞參數來動態篩選結果,並且確保 RLS 規則得到正確應用。

第四步:建立 API 服務

4.1 定義資料型別

// 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;
}

4.2 實作商家查詢功能

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[];
}

4.3 實作預約功能

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;
}

4.4 實作商家建立功能

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 測試

5.1 建立 Postman Collection

建立 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"
    }
  ]
}

5.2 常見 API

獲取所有商家:

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

有任何想討論歡迎留言,或需要指正的地方請鞭大力一點,歡迎訂閱、按讚加分享,分享給想要提升開發效率的朋友


上一篇
第二十三關 - 來企排隊:Supabase 快速建立上傳個人頭像
下一篇
第二十五關 - 來企排隊: Supabase 快速建立即時通知功能
系列文
我獨自開發 - 用 Supabase 打造全端應用25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言