สร้างระบบจองคิวตัดผมผ่าน LINE Bot ด้วย Node.js + Supabase (ฉบับทำตามได้จริง!)
เปลี่ยนร้านตัดผมให้ทันสมัย ด้วยระบบจองคิวอัตโนมัติผ่าน LINE OA — ไม่ต้องมี Frontend, ไม่ต้อง deploy หลาย service, แค่ Bot ตัวเดียวจัดการได้หมด
สิ่งที่จะได้เรียน
- ตั้ง project Node.js + Express สำหรับรับ Webhook จาก LINE
- เชื่อมต่อ Supabase เป็นฐานข้อมูล
- ออกแบบ Flex Message สวย ๆ ให้ user จองผ่าน LINE ได้ใน 2 ขั้นตอน
- จัดการ Multi-step Flow (เลือกวัน → เลือกเวลา → ยืนยัน)
- ป้องกัน Double Booking ทั้งฝั่ง App และ DB
- จัดโครงสร้างโค้ดให้เป็นระเบียบ (Modular Architecture)
ตัวอย่างหน้าจอ (Preview)
Bot ของเราจะมี Flow หลักดังนี้:
ผู้ใช้ Add Friend → Welcome Flex Message → เลือกเมนู
├─ 💈 จองคิว → เลือกวัน → เลือกเวลา → ✅ จองสำเร็จ
├─ 📋 ดูคิวฉัน → แสดงรายละเอียดพร้อม Google Maps
├─ ❌ ยกเลิกคิว
└─ ❓ วิธีใช้สถาปัตยกรรม (Architecture Overview)
┌────────────┐ Webhook ┌──────────────────────┐ Supabase
│ LINE OA │ ──────────▶ │ Express Server │ ──────────▶ PostgreSQL
│ (ผู้ใช้พิมพ์│ ◀────────── │ (Node.js) │ ◀──────────
│ กดปุ่ม) │ Flex Reply │ │
└────────────┘ └──────────────────────┘Tech Stack ที่ใช้:
Get Codequeror’s stories in your inbox
Join Medium for free to get updates from this writer.
Node.js + Express , @line/bot-sdk , Supabase (PostgreSQL)
Step 1 — เตรียม Project
1.1 สร้าง Project & ติดตั้ง Dependencies
mkdir barbershop-line-bot && cd barbershop-line-bot
npm init -y
npm install express @line/bot-sdk @supabase/supabase-js dotenv1.2 โครงสร้างไฟล์
barbershop-line-bot/
├── index.js ← Entry point
├── .env ← Secrets
├── supabase.db ← SQL Schema
├── package.json
└── src/
├── config.js ← ENV, LINE/Supabase clients, Constants
├── helpers/
│ └── reply.js ← LINE reply helper
├── db/
│ └── bookings.js ← Supabase queries
├── handlers/
│ ├── event.js ← Webhook event router
│ └── commands.js ← Business logic (book, view, help)
└── flex/
├── welcome.js ← Welcome & main menu
├── booking.js ← Date, time, confirm UI
└── help.js ← Help page💡 Tip: การแยกไฟล์ตาม concern จะช่วยให้ maintain ง่ายขึ้นมาก — แต่ละไฟล์มีหน้าที่เดียว ถ้า bug ก็หาได้เร็ว
Step 2 — ออกแบบ Database (Supabase)
2.1 สร้าง Project ใน Supabase
- ไปที่ supabase.com → สร้าง project ใหม่
- เข้า SQL Editor แล้วรัน schema ด้านล่าง
2.2 SQL Schema
-- ─────────────────────────────────────────
-- Supabase Schema: barbershop-bot
-- ─────────────────────────────────────────
create table bookings (
id uuid default gen_random_uuid() primary key,
user_id text not null,
booking_date date not null,
booking_time text not null,
status text not null default 'confirmed'
check (status in ('confirmed', 'cancelled')),
created_at timestamptz default now()
);
-- Index สำหรับ query ที่ใช้บ่อย
create index idx_bookings_user_status on bookings (user_id, status);
create index idx_bookings_date on bookings (booking_date);
-- 🛡️ ป้องกัน double booking ในระดับ DB!
create unique index idx_no_double_booking
on bookings (booking_date, booking_time)
where status = 'confirmed';จุดสำคัญ:
checkconstraint — status ได้แค่confirmedหรือcancelledเท่านั้นidx_no_double_booking— unique partial index ที่ป้องกัน 2 คนจองเวลาเดียวกันในวันเดียวกัน (เฉพาะ status = confirmed) นี่คือ ด่านสุดท้าย ที่ป้องกัน race condition!
Step 3 — Configuration & Clients
3.1 ไฟล์ .env
LINE_CHANNEL_SECRET=your_channel_secret_here
LINE_CHANNEL_ACCESS_TOKEN=your_channel_access_token_here
SUPABASE_URL=https://xxxxxx.supabase.co
SUPABASE_KEY=your_supabase_anon_keyสร้าง LINE Channel ได้ที่ LINE Developers Console → สร้าง Messaging API Channel
3.2 src/config.js — ศูนย์กลางการตั้งค่า
require('dotenv').config();
const { messagingApi, middleware } = require('@line/bot-sdk');
const { createClient } = require('@supabase/supabase-js');
// ── LINE Config ──────────────────────────
const LINE_CONFIG = {
channelSecret: process.env.LINE_CHANNEL_SECRET,
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
};
const lineClient = new messagingApi.MessagingApiClient({
channelAccessToken: LINE_CONFIG.channelAccessToken,
});
// ── Supabase Config ──────────────────────
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_KEY
);
// ── App Constants ────────────────────────
const PORT = process.env.PORT || 3000;
const TIME_SLOTS = [
'09:00', '10:00', '11:00',
'13:00', '14:00', '15:00', '16:00', '17:00',
];
// ธีมสีที่ใช้ทั่ว Flex Messages
const THEME = {
navy: '#16213e',
blue: '#0f3460',
teal: '#0d7377',
red: '#e94560',
gold: '#e2b714',
skyBlue: '#89cff0',
mint: '#b2dfdb',
};
module.exports = {
LINE_CONFIG, lineClient, supabase,
PORT, TIME_SLOTS, THEME, middleware,
};ทำไมต้องแยก config ?
- ไม่ต้อง
require('dotenv')ซ้ำทุกไฟล์ TIME_SLOTSกับTHEMEอ้างอิงจากที่เดียว จะแก้สีทั้งระบบก็แก้จุดเดียว- ง่ายต่อการ test (สามารถ mock ได้)
Step 4 — Entry Point (index.js)
const express = require('express');
const { LINE_CONFIG, PORT, middleware } = require('./src/config');
const { handleEvent } = require('./src/handlers/event');
const app = express();
// ── Webhook Endpoint ─────────────────────
app.post('/webhook', middleware(LINE_CONFIG), async (req, res) => {
try {
await Promise.all(req.body.events.map(handleEvent));
res.json({ ok: true });
} catch (err) {
console.error('Webhook error:', err);
res.status(500).json({ error: err.message });
}
});
// ── Health Check ─────────────────────────
app.get('/', (_req, res) => {
res.json({ status: 'ok', service: 'barbershop-line-bot' });
});
// ── Start Server ─────────────────────────
app.listen(PORT, () => {
console.log(`✂️ Bot running on :${PORT}`);
});สิ่งที่ควรรู้:
middleware(LINE_CONFIG)→ ตรวจสอบ signature ของ request จาก LINE (ป้องกัน request ปลอม)Promise.all(events.map(handleEvent))→ ประมวลผล event ทุก event แบบ parallel- Health check endpoint
/→ ใช้ตรวจสอบว่า server ยังทำงานอยู่
Step 5 — Database Layer (src/db/bookings.js)
ไฟล์นี้เป็น Data Access Layer — ทุก query ที่เกี่ยวกับตาราง bookings อยู่ที่นี่หมด:
const { supabase } = require('../config');
// ดึงคิวที่ยังจอง confirmed อยู่ (ตัวล่าสุด, วันนี้หรืออนาคต)
async function getActiveBooking(userId) {
const { data, error } = await supabase
.from('bookings')
.select('*')
.eq('user_id', userId)
.eq('status', 'confirmed')
.gte('booking_date', new Date().toISOString().split('T')[0])
.order('booking_date', { ascending: true })
.limit(1)
.maybeSingle();
if (error) { console.error('getActiveBooking error:', error); return null; }
return data;
}
// ดึงเวลาที่ถูกจองไปแล้วในวันที่กำหนด
async function getBookedSlots(date) {
const { data, error } = await supabase
.from('bookings')
.select('booking_time')
.eq('booking_date', date)
.eq('status', 'confirmed');
if (error) { console.error('getBookedSlots error:', error); return []; }
return (data || []).map(r => r.booking_time);
}
// เช็คว่า slot ว่างมั้ย (ป้องกัน race condition ฝั่ง app)
async function isSlotBooked(date, time) {
const { data, error } = await supabase
.from('bookings')
.select('id')
.eq('booking_date', date)
.eq('booking_time', time)
.eq('status', 'confirmed')
.limit(1)
.maybeSingle();
if (error) return false;
return !!data;
}
// บันทึกการจอง
async function saveBooking(userId, date, time) {
const { error } = await supabase.from('bookings').insert({
user_id: userId,
booking_date: date,
booking_time: time,
status: 'confirmed',
});
if (error) throw error;
}
// ยกเลิกคิว (soft delete — เปลี่ยน status เป็น cancelled)
async function cancelBooking(userId) {
const { data, error } = await supabase
.from('bookings')
.update({ status: 'cancelled' })
.eq('user_id', userId)
.eq('status', 'confirmed')
.select();
if (error) throw error;
return data?.length > 0;
}
module.exports = {
getActiveBooking, getBookedSlots,
isSlotBooked, saveBooking, cancelBooking,
};Design Decisions:
- ใช้ soft delete (เปลี่ยน status) แทนที่จะลบจริง → เก็บ history ไว้ได้
maybeSingle()→ returnnullแทนที่จะ error ถ้าไม่เจอ- Double-check ทั้ง app level (isSlotBooked) และ DB level (unique index) → ปลอดภัย 100%
Step 6 — Event Router (src/handlers/event.js)
ไฟล์นี้คือ หัวใจ ของ Bot — ทำหน้าที่ route webhook event ไปยัง handler ที่เหมาะสม:
const { reply } = require('../helpers/reply');
const { startBooking, showMyBooking, showHelp } = require('./commands');
const {
getActiveBooking, getBookedSlots,
isSlotBooked, saveBooking, cancelBooking,
} = require('../db/bookings');
const { buildWelcome, buildMainMenu } = require('../flex/welcome');
const { buildTimeFlex, buildConfirmFlex, buildMyBookingFlex } = require('../flex/booking');
// In-memory state สำหรับ multi-step flow
// ⚠️ Production ควรเอาลง Redis หรือ Supabase
const userState = new Map();
// ── Text Command Map ─────────────────────
const TEXT_COMMANDS = {
จอง: startBooking,
จองคิว: startBooking,
ดูคิวฉัน: showMyBooking,
วิธีใช้: showHelp,
};6.1 Main Event Handler
async function handleEvent(event) {
const userId = event.source.userId;
// 1) ผู้ใช้ Add Friend → ส่ง Welcome Message
if (event.type === 'follow') {
return reply(event.replyToken, buildWelcome());
}
// 2) ผู้ใช้พิมพ์ข้อความ → จับ keyword
if (event.type === 'message' && event.message.type === 'text') {
const text = event.message.text.trim();
const fn = TEXT_COMMANDS[text];
if (fn) return fn(event.replyToken, userId);
return reply(event.replyToken, buildMainMenu());
}
// 3) ผู้ใช้กดปุ่ม Flex → postback
if (event.type === 'postback') {
const params = Object.fromEntries(
new URLSearchParams(event.postback.data)
);
return handlePostback(event.replyToken, userId, params);
}
}จุดน่าสนใจ:
TEXT_COMMANDS→ ใช้ lookup object แทน if-else ยาว ๆ — เพิ่มคำสั่งใหม่ได้ง่ายมาก- ถ้า user พิมพ์อะไรก็ตามที่ไม่ตรง keyword → แสดง Quick Reply menu กลับไป (ไม่ปล่อยค้าง)
6.2 Postback Handler — Multi-step Booking Flow
นี่คือส่วนที่ซับซ้อนที่สุด — จัดการ flow แบบ step-by-step:
async function handlePostback(replyToken, userId, params) {
const { action } = params;
// ── Start Booking ──
if (action === 'book_start') {
return startBooking(replyToken, userId);
}
// ── Select Date → Show Available Times ──
if (action === 'select_date') {
const date = params.date;
userState.set(userId, { date }); // จำวันที่ไว้
const bookedSlots = await getBookedSlots(date);
return reply(replyToken, buildTimeFlex(date, bookedSlots));
}
// ── Select Time → Save Booking ──
if (action === 'select_time') {
const state = userState.get(userId) || {};
// ⚠️ Session หมดอายุ (server restart ฯลฯ)
if (!state.date) {
return reply(replyToken, {
type: 'text',
text: '⚠️ Session หมดอายุ กรุณากด "จองคิว" ใหม่อีกครั้งนะครับ',
});
}
// 🛡️ เช็คว่า user มีคิวอยู่แล้วมั้ย (จองได้ครั้งละ 1)
const existing = await getActiveBooking(userId);
if (existing) {
userState.delete(userId);
return reply(replyToken, [
{ type: 'text', text: '⚠️ คุณมีคิวอยู่แล้วครับ ยกเลิกคิวเดิมก่อนถึงจะจองใหม่ได้นะ' },
buildMyBookingFlex(existing.booking_date, existing.booking_time),
]);
}
// 🛡️ เช็คว่า slot ยังว่าง (ป้องกัน race condition)
const alreadyBooked = await isSlotBooked(state.date, params.time);
if (alreadyBooked) {
userState.delete(userId);
return reply(replyToken, [
{ type: 'text', text: '⚠️ เวลานี้ถูกจองไปแล้วครับ กรุณาเลือกเวลาอื่น' },
buildMainMenu(),
]);
}
// ✅ จองได้!
await saveBooking(userId, state.date, params.time);
userState.delete(userId);
return reply(replyToken, buildConfirmFlex(state.date, params.time));
}
// ── Cancel Booking ──
if (action === 'cancel') {
const cancelled = await cancelBooking(userId);
if (cancelled) {
return reply(replyToken, [
{ type: 'text', text: '✅ ยกเลิกคิวเรียบร้อยแล้วครับ' },
buildMainMenu(),
]);
}
return reply(replyToken, {
type: 'text', text: '⚠️ ไม่พบคิวที่จะยกเลิก',
});
}
// ── View Booking ──
if (action === 'view_booking') {
return showMyBooking(replyToken, userId);
}
}Flow Diagram
user กดจองคิว
│
▼
📅 buildDateFlex() ← แสดง 7 วันข้างหน้า (ข้ามอาทิตย์)
│
│ user เลือกวัน (postback: action=select_date&date=2025-03-05)
│ ➜ userState.set(userId, { date })
▼
⏰ buildTimeFlex() ← แสดง slot ว่าง/เต็ม
│
│ user เลือกเวลา (postback: action=select_time&time=14:00)
│ ➜ ตรวจ: มีคิวอยู่แล้ว? slot ว่าง?
│ ➜ saveBooking()
▼
✅ buildConfirmFlex() ← ยืนยันสำเร็จ + ปุ่มยกเลิก + Google MapsStep 7 — Flex Message UI
LINE Flex Message คือ JSON-based layout ที่ให้เราสร้าง UI สวย ๆ ใน chat ได้ ลองดูตัวอย่างที่สำคัญ:
7.1 Welcome Message (src/flex/welcome.js)
const { THEME } = require('../config');
function buildWelcome() {
return {
type: 'flex',
altText: 'ยินดีต้อนรับ! ✂️',
contents: {
type: 'bubble',
size: 'mega',
hero: {
type: 'box', layout: 'vertical',
backgroundColor: THEME.navy, paddingAll: '30px',
contents: [
{ type: 'text', text: '✂️', size: '5xl', align: 'center' },
{
type: 'text', text: 'ยินดีต้อนรับ!',
color: '#ffffff', size: 'xxl',
weight: 'bold', align: 'center', margin: 'md',
},
{
type: 'text', text: 'ร้านตัดผม The Cut',
color: THEME.gold, size: 'md',
align: 'center', margin: 'sm',
},
],
},
footer: {
type: 'box', layout: 'vertical', spacing: 'sm',
contents: [
{
type: 'button',
action: {
type: 'postback', label: '✂️ จองคิวเลย!',
data: 'action=book_start', displayText: 'จองคิว',
},
style: 'primary', color: THEME.red, height: 'sm',
},
{
type: 'button',
action: {
type: 'postback', label: '📋 ดูคิวของฉัน',
data: 'action=view_booking', displayText: 'ดูคิวฉัน',
},
style: 'secondary', height: 'sm',
},
],
},
},
};
}7.2 Date Picker (7 วันข้างหน้า ข้ามอาทิตย์)
function buildDateFlex() {
const today = new Date();
const dates = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() + i + 1);
if (d.getDay() === 0) return null; // ข้ามวันอาทิตย์
return {
label: d.toLocaleDateString('th-TH', {
weekday: 'short', day: 'numeric', month: 'short',
}),
value: d.toISOString().split('T')[0],
};
}).filter(Boolean);
return {
type: 'flex',
altText: '📅 เลือกวันที่ต้องการจอง',
contents: {
type: 'bubble',
size: 'mega',
header: {
type: 'box', layout: 'vertical',
backgroundColor: THEME.navy, paddingAll: '20px',
contents: [
{ type: 'text', text: '✂️ จองคิวตัดผม', color: '#ffffff',
size: 'xl', weight: 'bold', align: 'center' },
{ type: 'text', text: 'Step 1/2 — เลือกวันที่',
color: THEME.gold, size: 'sm', align: 'center', margin: 'sm' },
],
},
body: {
type: 'box', layout: 'vertical', spacing: 'sm', paddingAll: '16px',
contents: dates.map(d => ({
type: 'button',
action: {
type: 'postback',
label: `📅 ${d.label}`.substring(0, 20),
data: `action=select_date&date=${d.value}`,
displayText: d.label,
},
style: 'secondary', height: 'sm', margin: 'xs',
})),
},
},
};
}เทคนิค:
.substring(0, 20)→ LINE จำกัด label ไม่เกิน 20 ตัวอักษรgetDay() === 0→ ข้ามวันอาทิตย์ (ร้านปิด)- ใช้
th-THlocale → แสดงเป็นภาษาไทย ("จ. 5 มี.ค.")
7.3 Time Picker (แสดงสถานะ ว่าง/เต็ม)
function buildTimeFlex(date, bookedSlots = []) {
const bookedSet = new Set(bookedSlots);
const slotContents = TIME_SLOTS.map(t => {
if (bookedSet.has(t)) {
// ❌ คิวเต็ม — แสดงเป็นกล่องสีเทา กดไม่ได้
return {
type: 'box', layout: 'horizontal', spacing: 'md',
backgroundColor: '#f0f0f0', cornerRadius: '8px',
paddingAll: '12px', margin: 'sm',
contents: [
{ type: 'text', text: `🕐 ${t}`, size: 'md',
color: '#aaaaaa', flex: 1 },
{ type: 'text', text: '❌ เต็ม', size: 'sm',
color: '#cc0000', align: 'end', gravity: 'center', flex: 0 },
],
};
}
// ✅ ว่าง — แสดงเป็นปุ่มกดได้
return {
type: 'button',
action: {
type: 'postback',
label: `🕐 ${t} — ว่าง ✅`,
data: `action=select_time&time=${t}`,
displayText: `เลือกเวลา ${t}`,
},
style: 'primary', color: THEME.teal,
height: 'sm', margin: 'sm',
};
});
// ... return Flex Bubble
}ไอเดียที่น่าสนใจ:
- Slot ที่ว่าง → ปุ่มสีเขียว กดได้
- Slot ที่เต็ม → กล่องสีเทา แสดง “❌ เต็ม” กดไม่ได้
- User เข้าใจได้ทันทีว่าเวลาไหนจองได้/ไม่ได้
7.4 Quick Reply Menu
function buildMainMenu() {
return {
type: 'text',
text: '✂️ สวัสดีครับ เลือกเมนูด้านล่างได้เลย 👇',
quickReply: {
items: [
{
type: 'action',
imageUrl: 'https://img.icons8.com/color/48/calendar--v1.png',
action: { type: 'message', label: '💈 จองคิว', text: 'จองคิว' },
},
{
type: 'action',
imageUrl: 'https://img.icons8.com/color/48/clipboard-list.png',
action: { type: 'message', label: '📋 ดูคิวฉัน', text: 'ดูคิวฉัน' },
},
{
type: 'action',
imageUrl: 'https://img.icons8.com/color/48/help--v1.png',
action: { type: 'message', label: '❓ วิธีใช้', text: 'วิธีใช้' },
},
],
},
};
}Quick Reply จะแสดงเป็นปุ่มกลม ๆ ใต้ chat — user กดได้ง่ายไม่ต้องพิมพ์เอง!
Step 8 — Reply Helper
ฟังก์ชันเล็ก ๆ แต่สำคัญ — แปลง message เป็น array อัตโนมัติ:
const { lineClient } = require('../config');
async function reply(replyToken, messages) {
const msgs = Array.isArray(messages) ? messages : [messages];
return lineClient.replyMessage({ replyToken, messages: msgs });
}ทำให้เราเรียก
reply(token, singleMessage) หรือ reply(token, [msg1, msg2]) ได้เหมือนกัน — ไม่ต้อง wrap array เอง
Step 9 — ระบบป้องกัน Double Booking
เราป้องกัน 3 ชั้น:
ชั้นกลไกตำแหน่งในโค้ด
- UI LevelSlot ที่เต็มจะแสดง “❌ เต็ม” กดไม่ได้buildTimeFlex()
- App Levelเช็ค isSlotBooked() ก่อน savehandlePostback()
- DB LevelUnique partial index ใน PostgreSQL
idx_no_double_booking
User A เลือก 14:00 ──┐
├─ ทั้งคู่เห็น "ว่าง" ─┐
User B เลือก 14:00 ──┘ │
▼
App: isSlotBooked() ← ชั้นที่ 2
│
A ผ่าน, B ไม่ผ่าน
│
DB: unique index ← ชั้นที่ 3 (safety net)ไอเดียพัฒนาต่อ
- 🔔 Reminder: ส่งข้อความเตือนก่อนถึงเวลานัด 1 ชม. (ใช้ Push Message + cron)
- 👤 Admin Dashboard: ดูคิวทั้งหมดของวันนี้ผ่าน LIFF หรือ Web App
- 📊 Analytics: สรุปยอดจองรายวัน/รายสัปดาห์
- 🗃 Redis State: ย้าย
userState(in-memory Map) ไป Redis เพื่อรองรับ server restart - 💰 Payment: เชื่อมต่อระบบชำระเงิน (PromptPay QR)
- 📱 Rich Menu: ใช้ LINE Rich Menu แทน Quick Reply เพื่อ UX ที่ดีขึ้น
🔗 Source Code
GitHub: Line-Booking-HairCut
ถ้าบทความนี้มีประโยชน์ กดปรบมือ 👏 แล้วแชร์ให้เพื่อนด้วยนะครับ! 🙏
มีคำถามหรือข้อเสนอแนะ comment ไว้ได้เลย — จะมาตอบทุกคำถาม! ✂️







