FileEditViewTerminal
IT Issue Bootcamp - Visual Studio Code
day-4-hour-2.mdx

Day 4 / Hour 2

Read from Supabase

Server client, database row mapping, getIssues, list page, and detail page.

60 minutes
The app reads issues from Supabase.

Read from Supabase

Day 4 - ชั่วโมงที่ 2: อ่านข้อมูลจาก Supabase

เป้าหมายของชั่วโมงนี้

หลังจบชั่วโมงนี้ ผู้เรียนจะสามารถ:

  1. เชื่อม Next.js กับ Supabase ผ่าน Server Component
  2. อ่าน Issue ทั้งหมดและเรียงรายการจากใหม่ไปเก่า
  3. แปลงชื่อ Field จาก Database ให้ตรงกับ Type ที่ UI ใช้
  4. แสดงข้อมูลจริงในหน้า /issues
  5. อ่าน Issue รายการเดียวจาก id ใน URL

ไฟล์ที่ใช้ในชั่วโมงนี้

lib/supabase/server.ts       สร้าง Client สำหรับติดต่อ Supabase
lib/issues.ts                รวม Function สำหรับอ่าน Issue
app/page.tsx                 ปรับหน้าแรกให้เชื่อมไปหน้ารายการ
app/issues/page.tsx          แสดง Issue ทั้งหมด
app/issues/[id]/page.tsx     แสดง Issue รายการเดียว

Slide 1: Recap จากชั่วโมงที่ 1

ชั่วโมงที่แล้วเราเตรียมฝั่ง Database ไว้แล้ว:

  • สร้าง Supabase Project และ Table issues
  • กำหนด Column, Constraint, RLS และ Policy
  • เพิ่มข้อมูลทดลองลงใน Table
  • เก็บ Project URL และ Publishable Key ไว้ใน .env.local
  • ปรับ Type Issue ให้ตรงกับข้อมูลใน Database

ชั่วโมงนี้เราจะนำข้อมูลเหล่านั้นมาแสดงใน Next.js แทน Mock Data

Supabase
  → อ่านข้อมูลใน lib/issues.ts
  → ส่งข้อมูลให้ page.tsx
  → แสดงผลด้วย IssueList

Slide 2: ติดตั้ง Supabase JavaScript Client

เปิด Terminal ที่ Project Root แล้วรัน:

npm install @supabase/supabase-js

Package นี้ทำให้ JavaScript ติดต่อ Supabase Data API ได้ เช่น:

  • .select() อ่านข้อมูล
  • .insert() เพิ่มข้อมูล
  • .update() แก้ไขข้อมูล
  • .delete() ลบข้อมูล

ชั่วโมงนี้จะเริ่มจาก .select() เพื่ออ่านข้อมูลก่อน ส่วนการเพิ่มและแก้ไขข้อมูลจะทำในชั่วโมงถัดไป


Slide 3: สร้าง Supabase Server Client

สร้าง Folder และไฟล์ใหม่ที่ Project Root:

project-root/
├─ app/
├─ components/
├─ lib/
│  └─ supabase/
│     └─ server.ts
└─ package.json

ใช้ Code นี้เป็นเนื้อหาทั้งไฟล์ lib/supabase/server.ts:

import { createClient } from "@supabase/supabase-js";
 
export function createSupabaseServerClient() {
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
 
  if (!supabaseUrl || !supabaseKey) {
    throw new Error("Supabase environment variables are missing");
  }
 
  return createClient(supabaseUrl, supabaseKey, {
    auth: {
      persistSession: false,
    },
  });
}

Function นี้ทำหน้าที่สร้าง Client กลาง เพื่อให้ไฟล์อื่นนำไปใช้ติดต่อ Supabase โดยไม่ต้องเขียน URL และ Key ซ้ำ

  • process.env... อ่านค่าจาก .env.local
  • if หยุดการทำงานทันทีเมื่อ Environment Variables ไม่ครบ
  • createClient(...) สร้างตัวเชื่อมต่อ Supabase
  • persistSession: false ไม่บันทึก Login Session เพราะ Client นี้ยังใช้บน Server เพื่ออ่านข้อมูลสาธารณะ

เราใช้ Publishable Key ซึ่งทำงานร่วมกับสิทธิ์ GRANT, RLS และ Policy ที่ตั้งไว้ในชั่วโมงก่อน ห้ามนำ Secret Key หรือ Service Role Key มาใส่ในตัวแปรที่ขึ้นต้นด้วย NEXT_PUBLIC_


Slide 4: แยกรูปแบบข้อมูลของ Database และ UI

Database ใช้ชื่อ Column แบบ snake_case แต่ TypeScript ใน UI ใช้ชื่อ Property แบบ camelCase:

DatabaseUI
reporter_namereporterName
admin_commentadminComment
created_atcreatedAt
updated_atupdatedAt

สร้างไฟล์ใหม่ lib/issues.ts แล้วเริ่มด้วย Code นี้:

import type { Issue, IssueStatus } from "@/types/issue";
import { createSupabaseServerClient } from "./supabase/server";
 
type DbIssue = {
  id: string;
  reporter_name: string;
  reporter_email: string;
  title: string;
  description: string;
  status: IssueStatus;
  admin_comment: string | null;
  created_at: string;
  updated_at: string;
};

DbIssue อธิบายข้อมูล 1 Row ที่ Supabase ส่งกลับมา ชื่อ Property จึงต้องตรงกับชื่อ Column ใน Table

Type นี้มีไว้ช่วยตรวจ Code เท่านั้น ไม่ได้สร้างหรือแก้ Table ใน Supabase


Slide 5: แปลง DbIssue ให้เป็น Issue

ทำไมต้องแปลงข้อมูล

ข้อมูลจาก Supabase ยังใช้ชื่อ Field ตาม Database เช่น reporter_name แต่ Component ที่เราสร้างไว้ใช้ Type Issue และเรียก Field ว่า reporterName

ถ้าส่ง DbIssue เข้า Component โดยตรง:

  • ชื่อ Field จะไม่ตรงกับ Code เดิม
  • TypeScript จะแจ้งว่า Type ไม่ตรงกัน
  • Component ต้องรู้รายละเอียดของ Database และต้องแก้หลายจุดเมื่อโครงสร้าง Database เปลี่ยน

เราจึงใช้ mapIssue() เป็นตัวกลาง แปลงข้อมูลจากรูปแบบของ Database ให้เป็นรูปแบบเดียวกับที่ UI ใช้อยู่

เพิ่ม Function นี้ต่อจาก DbIssue ในไฟล์ lib/issues.ts:

function mapIssue(row: DbIssue): Issue {
  return {
    id: row.id,
    reporterName: row.reporter_name,
    reporterEmail: row.reporter_email,
    title: row.title,
    description: row.description,
    status: row.status,
    adminComment: row.admin_comment ?? undefined,
    createdAt: row.created_at,
    updatedAt: row.updated_at,
  };
}

mapIssue() รับข้อมูลจาก Database 1 Row แล้วคืน Object รูปแบบ Issue ที่ Component เดิมของเราเข้าใจ

DbIssue จาก Supabase
       ↓ mapIssue()
Issue สำหรับ UI

row.admin_comment ?? undefined หมายถึง ถ้า Database ส่ง null มา ให้เปลี่ยนเป็น undefined ตาม Type Issue ที่กำหนดไว้

การแปลงข้อมูลไว้ใน lib/issues.ts ทำให้ Component ไม่ต้องรู้ว่า Database ตั้งชื่อ Column อย่างไร


Slide 6: สร้าง getIssues() สำหรับอ่านทุก Issue

เพิ่ม Function นี้ต่อจาก mapIssue() ในไฟล์ lib/issues.ts:

export async function getIssues(): Promise<Issue[]> {
  const supabase = createSupabaseServerClient();
 
  const { data, error } = await supabase
    .from("issues")
    .select("*")
    .order("created_at", { ascending: false });
 
  if (error) {
    throw new Error(`Failed to fetch issues: ${error.message}`);
  }
 
  return (data ?? []).map((row) => mapIssue(row as DbIssue));
}

ลำดับการทำงาน:

  1. สร้าง Supabase Client
  2. เลือกข้อมูลทุก Column จาก Table issues
  3. เรียง created_at จากใหม่ไปเก่าด้วย ascending: false
  4. ถ้า Query ไม่สำเร็จ ให้หยุดและแจ้ง Error
  5. ถ้าไม่มีข้อมูล ให้ใช้ Array ว่าง
  6. แปลงทุก Row ด้วย mapIssue() แล้วคืนค่าเป็น Issue[]

อ่าน Syntax ของบรรทัด return

return (data ?? []).map((row) => mapIssue(row as DbIssue));
  • data ?? [] ถ้า data มีค่าให้ใช้ข้อมูลนั้น แต่ถ้าเป็น null หรือ undefined ให้ใช้ Array ว่างแทน
  • .map(...) วนอ่านทุก Row ใน Array แล้วสร้าง Array ใหม่
  • (row) => ... คือ Arrow Function ที่รับข้อมูลครั้งละ 1 Row
  • row as DbIssue บอก TypeScript ให้ตรวจ row ในรูปแบบ DbIssue คำสั่งนี้ไม่ได้เปลี่ยนข้อมูลจริง
  • mapIssue(...) แปลง Row จากชื่อ Field แบบ Database ให้เป็น Issue สำหรับ UI
  • return ส่ง Array ใหม่ชนิด Issue[] กลับไปให้ Code ที่เรียก getIssues()

เขียนแยกเป็นหลายบรรทัดเพื่อดูขั้นตอนได้ดังนี้:

const rows = data ?? [];
const issues = rows.map((row) => mapIssue(row as DbIssue));
 
return issues;

Promise<Issue[]> บอกว่า Function ต้องรอการอ่านข้อมูล และเมื่อเสร็จแล้วจะได้ Array ของ Issue


Slide 7: ให้หน้า /issues ใช้ข้อมูลจาก Supabase

ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ app/issues/page.tsx:

import { IssueList } from "@/components/IssueList";
import { getIssues } from "@/lib/issues";
 
export default async function IssuesPage() {
  const issues = await getIssues();
 
  return (
    <main className="mx-auto max-w-5xl px-6 py-8">
      <h1 className="mb-4 text-2xl font-bold text-slate-950">
        รายการปัญหา
      </h1>
      <IssueList issues={issues} />
    </main>
  );
}

หน้านี้เป็น Server Component จึงประกาศเป็น async และใช้ await getIssues() ก่อนส่งข้อมูลให้ IssueList

ไม่ต้องแก้ IssueList เพิ่ม เพราะ Day 3 Hour 4 ทำให้ onUpdateStatus เป็น Optional Prop ไว้แล้ว การส่งเฉพาะ issues จึงแสดงรายการแบบอ่านอย่างเดียวได้

อย่าใส่ "use client" ใน app/issues/page.tsx เพราะหน้านี้มีหน้าที่อ่านข้อมูลบน Server


Slide 8: ปรับหน้าแรกให้เลิกใช้ Mock Data

หน้าแรกเดิมยังแสดง IssueBoard และ Mock Data จาก data/issue.ts การแก้ครั้งนี้แบ่งเป็น 3 ส่วน:

  • ลบ: Import IssueBoard, Import issues และ <IssueBoard initialIssues={issues} />
  • แก้: ข้อความใน Header และ Layout ของ <main>
  • เพิ่ม: Import Link พร้อมหัวข้อ คำอธิบาย และ Link ไปหน้า /issues

ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ app/page.tsx บรรทัดที่ Highlight คือส่วนที่เพิ่มหรือแก้จาก Code เดิม:

import Link from "next/link"; export default function Home() {  return (    <>      <header className="bg-teal-800 px-6 py-8 text-white">        <div className="mx-auto max-w-5xl">          <h1 className="text-3xl font-bold">ระบบแจ้งปัญหา IT</h1>          <p className="mt-2 text-teal-100">            แจ้งและติดตามปัญหาการใช้งานระบบภายใน          </p>        </div>      </header>       <main className="mx-auto max-w-5xl px-6 py-12">        <h2 className="text-2xl font-semibold">จัดการปัญหาการใช้งานระบบ</h2>        <p className="mt-2 text-slate-600">          ดูรายการปัญหาที่แจ้งเข้ามาและติดตามสถานะการดำเนินงาน        </p>        <Link          href="/issues"          className="mt-6 inline-block rounded-md bg-teal-700 px-4 py-2 font-semibold text-white hover:bg-teal-800"        >          ดูรายการปัญหา        </Link>      </main>       <footer className="px-6 py-8 text-center text-sm text-slate-500">        <p>ฝ่ายเทคโนโลยีสารสนเทศ</p>      </footer>    </>  );}

ส่วน Header เดิมและ Footer เดิมยังคงใช้ต่อ เพียงแก้ข้อความใน Header ให้ตรงกับหน้าที่ของระบบ

ตอนนี้แต่ละหน้ามีหน้าที่ชัดเจน:

/        หน้าแนะนำระบบและ Link ไปหน้ารายการ
/issues  รายการข้อมูลจริงจาก Supabase

Slide 9: ทดสอบหน้ารายการ

เปิดหน้า:

http://localhost:3000/issues

ตรวจสอบว่า:

  • เห็นข้อมูลที่ Seed ไว้ใน Supabase
  • รายการใหม่กว่าอยู่ด้านบน
  • แต่ละ Row มี Link ดูรายละเอียด
  • Refresh หน้าแล้วข้อมูลยังอยู่ เพราะอ่านจาก Database ไม่ใช่ State

ถ้าหน้าว่างหรือเกิด Error ให้ตรวจตามลำดับ:

  1. .env.local ใช้ชื่อ Environment Variables ตรงกับ Slide 3
  2. Restart Dev Server หลังแก้ .env.local
  3. Table ชื่อ issues ไม่ใช่ issue
  4. รัน GRANT SELECT และสร้าง RLS Policy จาก Hour 1 ครบแล้ว

Slide 10: สร้าง getIssueById()

เมื่อกด ดูรายละเอียด เราต้องอ่าน Issue เพียงรายการเดียวตาม id ใน URL

เพิ่ม Function นี้ต่อจาก getIssues() ในไฟล์ lib/issues.ts:

export async function getIssueById(id: string): Promise<Issue | null> {
  const supabase = createSupabaseServerClient();
 
  const { data, error } = await supabase
    .from("issues")
    .select("*")
    .eq("id", id)
    .maybeSingle();
 
  if (error?.code === "22P02") {
    return null;
  }
 
  if (error) {
    throw new Error(`Failed to fetch issue: ${error.message}`);
  }
 
  return data ? mapIssue(data as DbIssue) : null;
}
  • .eq("id", id) กรอง Row ที่ Column id ตรงกับค่าจาก URL
  • .maybeSingle() บอกว่า Query นี้ควรได้ 0 หรือ 1 Row
  • Error Code 22P02 หมายถึงค่าที่ส่งมาใช้เป็น UUID ไม่ได้ กรณีนี้ให้คืน null เพื่อไปหน้า 404
  • Error ชนิดอื่นยังต้อง throw ตามเดิม เพื่อไม่ซ่อนปัญหาเรื่องสิทธิ์หรือการเชื่อมต่อ Database
  • ถ้าพบข้อมูล จะส่งผ่าน mapIssue()
  • ถ้าไม่พบข้อมูล จะคืน null ให้หน้า Detail จัดการต่อ

ทำไมต้องตรวจ 22P02 แยกออกมา

Column id ของ Table เป็นชนิด UUID ถ้าผู้ใช้พิมพ์ URL เช่น /issues/not-found Supabase จะอ่าน not-found เป็น UUID ไม่ได้และส่ง Error Code 22P02 กลับมา

  • error?.code อ่าน code เฉพาะเมื่อมี Error เครื่องหมาย ?. ช่วยป้องกันการอ่าน Property จากค่า null
  • เราตรวจ 22P02 ก่อน if (error) เพื่อเปลี่ยนกรณีรหัสผิดรูปแบบให้เป็น null
  • หน้า Detail จะเห็นว่าไม่มี Issue แล้วเรียก notFound()
  • Error อื่น เช่น ไม่มีสิทธิ์อ่าน Table ยังถูก throw เพื่อให้เราเห็นและแก้ปัญหาได้
id ผิดรูปแบบ → 22P02 → return null → notFound() → หน้า 404

Slide 11: ให้ Dynamic Route อ่านข้อมูลจริง

Folder [id] เป็น Dynamic Route เช่น /issues/abc123 จะส่ง abc123 มาเป็น params.id

หน้า Detail จาก Day 2 มี Dynamic Route, params และ notFound() อยู่แล้ว การปรับครั้งนี้มีทั้งส่วนเชื่อมข้อมูลและส่วนจัดหน้า:

  • ลบ: import { issues } from "@/data/issue";
  • เพิ่ม: Import getIssueById จาก lib/issues.ts
  • แก้: จากค้นหาใน Mock Array ด้วย .find() เป็นรอข้อมูลจาก Supabase ด้วย await getIssueById(id)
  • เพิ่ม Tailwind: จัดความกว้าง ระยะห่าง สีข้อความ Border และพื้นหลังให้หน้า Detail สอดคล้องกับหน้าอื่น
  • ปรับโครงสร้าง: ใช้ <dl>, <dt> และ <dd> สำหรับข้อมูลที่เป็นหัวข้อกับค่า เช่น ผู้แจ้งและอีเมล

ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ app/issues/[id]/page.tsx บรรทัดที่ Highlight คือส่วนที่เพิ่มหรือแก้:

import { notFound } from "next/navigation";import { getIssueById } from "@/lib/issues"; type IssueDetailPageProps = {  params: Promise<{    id: string;  }>;}; export default async function IssueDetailPage({  params,}: IssueDetailPageProps) {  const { id } = await params;  const issue = await getIssueById(id);   if (!issue) {    notFound();  }   return (    <main className="mx-auto max-w-3xl px-6 py-8">      <h1 className="text-2xl font-bold text-slate-950">{issue.title}</h1>      <p className="mt-2 text-slate-600">{issue.description}</p>       <dl className="mt-6 grid gap-4 rounded-lg border border-slate-200 bg-white p-6">        <div>          <dt className="text-sm font-semibold text-slate-500">ผู้แจ้ง</dt>          <dd className="mt-1 text-slate-950">{issue.reporterName}</dd>        </div>        <div>          <dt className="text-sm font-semibold text-slate-500">อีเมล</dt>          <dd className="mt-1 text-slate-950">{issue.reporterEmail}</dd>        </div>        <div>          <dt className="text-sm font-semibold text-slate-500">สถานะ</dt>          <dd className="mt-1 text-slate-950">{issue.status}</dd>        </div>      </dl>    </main>  );}

ใน Next.js เวอร์ชันนี้ params เป็น Promise จึงต้องใช้ await params ก่อนอ่าน id

ถ้า getIssueById() คืน null คำสั่ง notFound() จะหยุดการแสดงหน้านี้และเปิดหน้า 404 แทน

Tailwind มีหน้าที่จัดหน้าตาเท่านั้น ไม่ได้เปลี่ยนวิธีอ่านข้อมูล ส่วน mapIssue() ทำให้ JSX ยังเรียก Property เดิม เช่น issue.reporterName และ issue.status ได้


Slide 12: ทดสอบหน้า Detail

กด ดูรายละเอียด จากหน้า /issues แล้วตรวจสอบว่า:

  • URL เปลี่ยนเป็น /issues/[id]
  • หัวข้อและรายละเอียดตรงกับ Row ที่เลือก
  • แสดงชื่อผู้แจ้ง อีเมล และสถานะ

จากนั้นทดลองใส่ค่าที่ไม่มีอยู่จริงใน URL เช่น:

/issues/not-found

Supabase จะส่ง Error Code 22P02 เพราะ not-found ไม่ใช่ UUID จากนั้น getIssueById() จะคืน null และหน้า Detail จะเรียก notFound() เพื่อแสดงหน้า 404 เริ่มต้นของ Next.js


Slide 13: สรุป Read Flow

หน้ารายการ

/issues
  → getIssues()
  → SELECT ทุก Row และเรียงจากใหม่ไปเก่า
  → mapIssue() ทุก Row
  → IssueList

หน้ารายละเอียด

/issues/[id]
  → อ่าน id จาก params
  → getIssueById(id)
  → พบข้อมูล: แสดง Detail
  → ไม่พบข้อมูล: แสดง 404

ตอนนี้ Supabase เป็น Source of Truth สำหรับการแสดงรายการและรายละเอียดแล้ว ชั่วโมงถัดไปเราจะทำให้ Form บันทึก Issue ใหม่ลง Supabase ผ่าน Server Action


คำศัพท์สำคัญ

คำศัพท์ความหมาย
Supabase Clientตัวเชื่อมระหว่าง Application กับ Supabase
Queryคำสั่งขอข้อมูลจาก Database
Mappingการแปลงข้อมูลจากรูปแบบหนึ่งเป็นอีกรูปแบบ
Server ComponentComponent ที่ทำงานและอ่านข้อมูลบน Server ได้
Dynamic RouteRoute ที่บางส่วนของ URL เปลี่ยนค่าได้ เช่น [id]
maybeSingle()รับผลลัพธ์เป็น 0 หรือ 1 Row

อ้างอิง