# บทเรียน 06 — เว็บเช่าอุปกรณ์: availability และ capacity ของ demo ที่รันได้จริง

## ผลลัพธ์การเรียนรู้

ผู้เรียนจะใช้ `/examples/rental/`, `GET /api/availability` และ `POST /api/rentals` ของ project นี้, ตรวจ D1 table `rental_days`, และอธิบายได้ว่าทำไม demo ใช้ one row ต่อวันเพื่อกัน capacity แบบ atomic. Lab นี้จำลองการ reserve (`status: "reserved-demo"`) เท่านั้น ไม่ใช่ contract การเช่าหรือ payment จริง.

## ต้องมีก่อนเริ่ม

- รัน `npm run db:local` และให้ `npm run dev` ทำงานที่ `http://localhost:3320` ตามบท 05
- ใช้ข้อมูลติดต่อสมมติเท่านั้น; `DEMO_MODE=true` redact `name`/`email` ก่อน insert แต่ยังมี validation, daily limit และ capacity constraint
- เลือกวันเริ่มในอนาคตไม่เกิน 365 วัน, วันสิ้นสุดหลังวันเริ่ม 1–30 วัน. Demo ใช้ช่วง `[start,end)`: รวมวันเริ่ม แต่ไม่รวมวันคืน

## Prompt สำหรับสร้างเว็บให้เช่าอุปกรณ์

คัดลอก prompt นี้ไปใช้ในสำเนา starter ของผู้เรียนก่อนทำ lab ตัวอย่างใช้ชื่อ “หยิบยืม” และ contract เดียวกับหน้าเว็บที่เปิดดูได้ ภาพหน้าจอเป็นผลลัพธ์อ้างอิงสำหรับเทียบงาน ส่วนการกันจองเกินจำนวนต้องพิสูจน์จาก API และ D1

```text
สร้างเว็บไซต์ให้เช่าอุปกรณ์ถ่ายภาพภาษาไทยชื่อ “หยิบยืม” ใน starter หลักสูตรนี้
ให้ผู้เรียนเลือกกล้อง ไฟ หรือชุดเสียง เลือกช่วงวันและจำนวน ตรวจของว่าง และจองสาธิตได้
อ่าน package.json, examples/rental/, src/worker.ts และ migrations/0001_initial.sql
กับ 0002_demo_retention.sql ก่อนลงมือ เพื่อใช้ contract และกติกาที่มีอยู่จริง
ใช้ HTML/CSS/vanilla JavaScript ไม่เพิ่ม dependency ไม่เพิ่มระบบชำระเงิน
สรุปแผนและ state ของฟอร์ม แล้วสร้าง examples/rental/{index.html,style.css,main.js}
รักษา API เดิมและเว็บอื่น ไม่แก้ migration ที่ apply แล้ว; ถ้าขาด API ให้รายงาน prerequisite

แนวภาพ: เหลืองสด ดำ ขาว จัดข้อมูลอุปกรณ์ให้ดูง่ายและใช้งานได้เร็ว
ใช้ฟอนต์ไทยใน public/fonts และภาพ /images/rental-kit.webp
มี hero, รายการอุปกรณ์, ตัวเลือกช่วงวัน/จำนวน, ผลตรวจของว่าง,
ฟอร์มข้อมูลสาธิต, ขั้นตอนการเช่า และข้อความว่าการจองไม่มีผลทางการค้า
โหลดอุปกรณ์ ราคา และ stock จาก GET /api/catalog ไม่ใช้ราคาที่ hard-code เป็น authority

ฟอร์มใช้ equipmentId, start, end, quantity โดยวันที่เป็นปฏิทิน UTC
ใช้ช่วง [start,end): รวมวันรับ ไม่รวมวันคืน ช่วงเช่า 1–30 วันตาม server validation
เรียก GET /api/availability?equipmentId=...&start=...&end=...&quantity=...
แสดง loading/available/unavailable/error และ totalPrice/currency/days ตาม API
เมื่อเปลี่ยนตัวเลือก ให้ปิดการส่งทันที ยกเลิกผลเก่า และป้องกัน response ที่มาช้าทับค่าปัจจุบัน

ส่ง JSON {equipmentId,start,end,quantity,name,email,consent,website} ไป POST /api/rentals
ส่ง Idempotency-Key และใช้ key เดิมเมื่อ retry ข้อมูลเดิม
ไม่ส่ง price,totalPrice,stock หรือ status ให้ server เชื่อจาก client
D1 และ capacity trigger เป็นผู้ตัดสินจำนวนว่างตอนเขียนข้อมูล; preview ไม่ใช่การยืนยันจอง
เมื่อ 201 แสดง id,totalPrice,status และ expiresAt; demo hold หมดอายุภายใน 30 นาที
เมื่อ 409 UNAVAILABLE ให้แจ้งว่าของเพิ่งถูกจองและตรวจ availability ใหม่
แสดง error.message ให้ผู้ใช้เข้าใจ เก็บข้อมูลฟอร์มไว้แก้ไขหรือ retry
ใช้ข้อมูลติดต่อสมมติ แจ้งการปกปิดชื่อ/อีเมลก่อนบันทึก D1 และไม่อ้างว่าเป็นสัญญาเช่าจริง

ทุก input มี label ใช้ keyboard ได้ มี focus/status ชัดเจน และมือถือ 390px ไม่ล้น
ตรวจ npm run build, npm run lint และใช้ชุดทดสอบ API เดิมสำหรับวันไม่ถูกต้อง,
ช่วงติดกันไม่ทับกัน, ของเต็ม, concurrent requests และ idempotency
ใช้เฉพาะพอร์ตที่จองไว้และ local D1 ของชุดทดสอบ ไม่ reset ฐานข้อมูลจริง
ส่งสรุป source, ภาพหน้าเว็บ, request/response ตัวอย่าง และผลทดสอบที่รันจริง
```

**ผลลัพธ์ที่ควรได้:** เว็บ [หยิบยืม](/examples/rental/) มีตัวเลือกอุปกรณ์ วัน และจำนวน พร้อมผล availability และเลขอ้างอิงเมื่อจองสาธิตสำเร็จ เทียบ [ภาพผลลัพธ์](/images/results/rental-desktop.png) และ [prompt ภาพอุปกรณ์](../assets/image-provenance.md#rental-kit) แล้วพิสูจน์เรื่อง stock ด้วย lab ด้านล่าง

### Prompt ติดตามผลหลังสร้าง

```text
ตรวจเว็บให้เช่าที่เพิ่งสร้าง แล้วแก้กรณีที่ไม่ผ่านโดยรักษา API contract เดิม
ลองเปลี่ยนวันอย่างรวดเร็วระหว่างโหลด, วันคืนเท่ากับวันรับรายการถัดไป,
ของไม่พอ, POST ตอบ 409, ส่งคำขอเดิมซ้ำ และเปิดบนมือถือ 390px
ตรวจว่าผลของ query เก่าไม่เปิดปุ่มจองให้ข้อมูลใหม่ และราคา/stock มาจาก D1
รายงานผลพร้อม request/response และหลักฐานจากชุดทดสอบ ไม่สรุปว่าจองได้จากข้อความบน UI อย่างเดียว
```

## Contract และ schema ที่มีอยู่จริง

![แบบจำลองข้อมูลและการเชื่อมต่อของระบบเช่า โดยหน้า examples rental เรียก availability และ rentals ซึ่งอ่านเขียน equipment rentals และ rental_days ก่อน capacity trigger ตรวจจำนวนต่ออุปกรณ์ต่อวัน](/diagrams/rental-data.svg)

[เปิดแผนภาพขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง](/diagrams/rental-data.html)

ความสัมพันธ์เดิมยังครบ: UI ส่ง `GET equipmentId,start,end,quantity` ไป `/api/availability` และส่ง `POST` ฟิลด์เดียวกันพร้อมข้อมูลติดต่อสาธิตไป `/api/rentals`; availability อ่าน `equipment + rental_days`; rentals เขียนชุดคำสั่งด้วย D1 batch และ capacity trigger ตรวจข้อมูลใน write path

![ลำดับการตรวจของว่างและสร้างรายการจองสาธิต โดยผล availability เป็นเพียง preview ก่อน Worker ตรวจซ้ำและให้ D1 capacity trigger เป็นผู้ตัดสินขณะเขียน](/diagrams/rental-reservation.svg)

[เปิดลำดับคำขอขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง](/diagrams/rental-reservation.html)

**ฝึกอ่านภาพ:** อธิบายสถานการณ์ที่ GET ตอบว่าว่างแต่ POST ตอบ `409 UNAVAILABLE` แล้วชี้ว่าจุดใดป้องกัน overbooking จริง

Worker รับ query `equipmentId`, `start`, `end`, `quantity` ที่ `/api/availability`, และ POST body เดียวกันเพิ่ม `name`, `email`, `consent`, `website` ที่ `/api/rentals`. เมื่อ DB binding พร้อม Worker อ่าน equipment, daily rate และ stock จาก D1; migration `0001_initial.sql` seed ตาราง `equipment` และมี `rentals`, `rental_days` กับ trigger `rental_days_capacity_before_insert`.

`rental_days` แตกแต่ละ reservation เป็นหนึ่ง row ต่อวัน. Trigger รวม quantity ของ equipment/date เดียวกัน แล้ว abort ด้วย `capacity_exceeded` หากมากกว่า `equipment.stock`. Worker ส่ง insert reservation, insert days และ idempotency record ผ่าน `db.batch()`, ซึ่ง D1 ระบุว่าเป็น transaction: statement ทำตามลำดับและ rollback sequence เมื่อ statement ใดล้มเหลว ([D1 `batch()`](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch)). นี่ทำให้ availability preview ไม่ใช่ authority; capacity ถูกตรวจอีกครั้งตรง write path.

## Lab: ตรวจ availability และสร้าง reservation สาธิต

1. ถ้ายังไม่ได้เปิด local environment ให้ใช้ command จริงของ project:

   ```bash
   npm run db:local
   npm run dev
   ```

   **ผลที่คาดหวัง:** local D1 มี tables จาก `migrations/0001_initial.sql` และหน้า `http://localhost:3320/examples/rental/` โหลด catalog จาก `/api/catalog`.

2. กำหนดช่วงวันแบบ portable ด้วย Node (ไม่ hard-code วันที่ที่จะหมดอายุในหลักสูตร):

   ```bash
   START=$(node -e "console.log(new Date(Date.now()+7*864e5).toISOString().slice(0,10)")
   END=$(node -e "console.log(new Date(Date.now()+8*864e5).toISOString().slice(0,10)")
   echo "$START to $END"
   ```

3. เรียก availability endpoint ที่มีจริง:

   ```bash
   curl -sS "http://localhost:3320/api/availability?equipmentId=camera&start=$START&end=$END&quantity=1"
   ```

   **ผลที่คาดหวัง:** JSON มี `available`, `remaining`, `totalPrice`, `currency`, `days`; เช่น `{"available":true,"remaining":2,"totalPrice":189000,"currency":"THB","days":1}` เมื่อยังไม่มี reservation. `totalPrice` เป็น integer satang และ `currency` เป็น `THB`.

4. POST reservation demo. `Origin` ต้องตรง local Worker, `consent` ต้อง `true`, honeypot `website` ต้องว่าง และ key นี้ใช้ซ้ำได้เฉพาะ payload เดิม:

   ```bash
   curl -i -X POST http://localhost:3320/api/rentals \
     -H 'Origin: http://localhost:3320' \
     -H 'Content-Type: application/json' \
     -H 'Idempotency-Key: rental-lab-001' \
     --data "{\"equipmentId\":\"camera\",\"start\":\"$START\",\"end\":\"$END\",\"quantity\":1,\"name\":\"Demo Learner\",\"email\":\"learner@example.test\",\"consent\":true,\"website\":\"\"}"
   ```

   **ผลที่คาดหวัง:** HTTP `201` และ `{id,totalPrice,currency:"THB",status:"reserved-demo",message}`. ข้อความจริงคือ `จองอุปกรณ์ในระบบสาธิตแล้ว รายการนี้ไม่มีผลทางการค้า`; จึงห้ามเปลี่ยน copy ของ lesson/page เป็น “ชำระเงินสำเร็จ”.

5. Inspect rows ที่ Worker สร้างจริง:

   ```bash
   npx wrangler d1 execute DB --local --command \
     "SELECT rental_id, equipment_id, rental_date, quantity FROM rental_days ORDER BY rental_date"
   npx wrangler d1 execute DB --local --command \
     "SELECT id, status, name, email, total_price FROM rentals ORDER BY created_at DESC LIMIT 3"
   ```

   **ผลที่คาดหวัง:** วันใน `[START, END)` มี row ใน `rental_days`; `rentals.status` เป็น `reserved-demo`, และ name/email เป็น `[demo-redacted]`.

6. ทดสอบ validation และ conflict. ลอง `end=$START` จะได้ `400 INVALID_DURATION`; ลอง quantity มากกว่า stock ของ camera (`3`) จะได้ `400 INVALID_FIELD`. สร้าง/ยิง requests ซ้อนกันจนเกิน stock ในวันเดียวกัน แล้ว Worker จะ map SQLite trigger error เป็น `409` envelope นี้:

   ```json
   {"error":{"code":"UNAVAILABLE","message":"อุปกรณ์ไม่พอสำหรับช่วงวันที่เลือก"}}
   ```

   ทดสอบ boundary โดยสร้าง A `[START,END)` และ B เริ่ม `END`: สองรายการไม่แชร์ `rental_date` จึงไม่ชนกัน. B ที่เริ่มก่อน `END` หนึ่งวันใช้วันร่วมกันและกิน stock วันนั้น. นี่เป็นผลของ half-open interval ที่ schema/recursive SQL ทำจริง.

7. ตรวจ browser path ต่อ: เปิด `/examples/rental/`, เลือก camera และวันเดียวกับ command, ดู response จาก `/api/availability`, ส่ง form ด้วย demo identity. จากนั้นรัน:

   ```bash
   npm run typecheck
   npm run test
   npm run build
   ```

## ขอบเขต demo กับ business implementation

![เครื่องสถานะของแถวการจองสาธิตจาก reserved-demo ไปสู่การหมดผลตาม expires_at หลัง 30 นาที แล้วถูก scheduled cleanup รายชั่วโมงลบออก](/diagrams/reservation-states.svg)

[เปิดแผนภาพสถานะขนาดเต็ม พร้อม prompt และแหล่งข้อมูลที่ใช้สร้าง](/diagrams/reservation-states.html)

ภาพนี้แสดงเฉพาะสิ่งที่โค้ดทำแล้ว: รุ่นปัจจุบันคืน `reserved-demo`; “หมดผล” เป็นเงื่อนไขเวลาและ “deleted” หมายถึงไม่มีแถวแล้ว ไม่ใช่ค่าในคอลัมน์ `status` ส่วนสถานะ quote, hold, confirmed และ cancelled ต้องมี schema, authorization และ test เพิ่มก่อนอ้างว่าใช้งานจริง

| เรื่อง | DEMO_MODE=true ปัจจุบัน | งานผลิตจริงภายหลัง |
| --- | --- | --- |
| state | ทุก POST สำเร็จเป็น `reserved-demo` และกิน local capacity | นิยาม quote/hold/confirmed/cancelled, hold expiry, staff authorization และ audit |
| อุปกรณ์/ราคา | D1 seeded catalog เป็น authority ของ availability/price; D1 เก็บ reservation | product/catalog administration ที่มี authorization และ migration strategy |
| data | redact PII, ไม่มี notification/payment | data policy, staff access, communication และ lawful retention |
| capacity | trigger per equipment-day ใน D1 | load/concurrency, cancellation/release rule และ reconciliation ตามธุรกิจ |

ตารางขวาเป็น **conceptual alternative** ไม่ใช่สิ่งที่ code ใน demo อ้างว่าทำแล้ว. หากออกแบบ state จริง อาจใช้ `quote_request` ที่ไม่กิน capacity, `hold` ที่มี expiry และ `confirmed_booking` ที่กิน capacity; ต้องเพิ่ม schema/authorization/test ใหม่ทั้งหมด. Current demo ตั้งใจสอน transaction/capacity โดยไม่รับเงินจริง.

OWASP แนะนำ validate ทั้งรูปแบบและ business rule ตั้งแต่รับ input ([Input Validation](https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html)); Worker ตรวจ date จริง, duration, date in past/too far, equipment ID และ quantity ก่อนสร้าง statements. D1 statement ใช้ bind parameters ไม่ต่อ user input เป็น SQL ([D1 Worker Binding API](https://developers.cloudflare.com/d1/worker-api/), [OWASP SQL injection](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)).

## ข้อผิดพลาดที่พบบ่อย

| อาการ | สาเหตุ | วิธีแก้ |
| --- | --- | --- |
| `404` หรือ response ไม่ใช่ API | พิมพ์ endpoint ไม่ตรง Worker | ใช้ `/api/availability` และ `/api/rentals` เท่านั้น |
| `403 ORIGIN_REJECTED` | POST ไม่มี Origin ที่ตรง Worker | ใช้ header ตาม lab |
| `400 DATE_IN_PAST` | คัดลอกวันที่เก่าจากตัวอย่าง | สร้าง `START`/`END` จากขั้น 2 ใหม่ |
| `409 UNAVAILABLE` | reservation อื่นกิน quantity ในวันนั้น | เป็น expected business response; เลือกวัน/จำนวนใหม่แล้วเช็ก availability |
| ไม่เห็นข้อมูลติดต่อใน D1 | demo redaction ทำงาน | ตรวจ `[demo-redacted]`; อย่าใช้ PII เพื่อ “แก้” ผลนี้ |

## แบบประเมิน

ผู้เรียนสาธิต GET availability, POST 201, D1 `rental_days` และ 409 capacity conflict. ต้องชี้ได้ว่าทำไม GET เป็น preview แต่ trigger/batch คือจุดตัดสินจริง และบอกได้ว่า `reserved-demo` ต่างจากสัญญาเช่าหรือ payment confirmation อย่างไร.

อ่านต่อ: [มาตรฐานคุณภาพและ commerce](../research/web-quality-commerce.md) และ [บท deploy](08-cloudflare-deploy.md).
