# บทเรียน 05 — เว็บบริษัท: contact form ที่รันกับ demo นี้ได้จริง

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

ผู้เรียนจะเปิด `/examples/company/`, ส่ง contact form ไปยัง `POST /api/contact`, อ่านผลที่ Worker คืนและพิสูจน์ใน D1 ว่า demo เก็บเฉพาะค่า redacted. Lab นี้ใช้ contract ที่อยู่ใน `src/worker.ts`, `migrations/0001_initial.sql` และ `examples/company/main.js` ณ เวอร์ชันของหลักสูตร ไม่ใช่ API สมมติ.

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

- อยู่ที่ root ของ repository นี้และมี Node/npm ที่ติดตั้ง dependencies แล้ว
- ใช้ชื่อ/อีเมล/ข้อความ **สมมติ** เท่านั้น เช่น `Demo Learner`, `learner@example.test`; ห้ามส่ง PII จริง
- เข้าใจว่า `wrangler.jsonc` ตั้ง `DEMO_MODE: "true"`: Worker ยัง validate input และเขียน row เพื่อสอน flow แต่แทน `name`, `email`, `message` ด้วย `[demo-redacted]` ก่อนเขียน D1; Turnstile ถูกข้ามเฉพาะ demo นี้

## Prompt สำหรับสร้างเว็บบริษัท

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

```text
สร้างเว็บไซต์บริษัทสถาปัตยกรรมภาษาไทยชื่อ “เส้นตั้ง สตูดิโอ” ใน starter หลักสูตรนี้
เป้าหมาย: ผู้เข้าชมเข้าใจบริการ เห็นแนวทางออกแบบ และส่งข้อความสอบถามโครงการได้
เริ่มจากอ่าน package.json, DESIGN.md, examples/company/ และ contact route ใน src/worker.ts
ใช้ semantic HTML, CSS และ vanilla JavaScript ตาม stack เดิม ไม่เพิ่ม dependency
สรุปแผนและไฟล์ที่จะเปลี่ยน แล้วลงมือสร้างใน examples/company/{index.html,style.css,main.js}
รักษาเว็บตัวอย่างอื่นและ API เดิม หากขาด prerequisite ให้รายงานสิ่งที่ขาดตามจริง

แนวภาพ: สตูดิโอสถาปัตยกรรมร่วมสมัย สีกรมท่า ขาว และไม้ แสงธรรมชาติ พื้นที่ว่างอ่านสบาย
ใช้ฟอนต์ IBM Plex Sans Thai ที่มีใน public/fonts และภาพ /images/company-studio.webp
หน้าเว็บมีเมนู, hero พร้อม CTA “คุยเรื่องโครงการ”, บริการ, ผลงานสมมติ,
กระบวนการทำงาน, แบบฟอร์มติดต่อ และ footer ที่กลับไปยังหลักสูตรได้
ระบุว่าเป็นธุรกิจและผลงานสมมติ ห้ามแต่งคำรับรอง โลโก้ลูกค้า หรือสถิติเป็นข้อเท็จจริง
กำหนดขนาดภาพและ alt ที่เหมาะสม ทุก CTA ต้องนำไปยังเนื้อหาหรือการทำงานที่มีอยู่จริง

แบบฟอร์มมี label สำหรับ name, email, message และ consent พร้อม honeypot ชื่อ website
ส่ง JSON {name,email,message,consent,website} ไป POST /api/contact ที่ origin เดียวกัน
ส่ง Idempotency-Key และใช้ key เดิมเมื่อ retry ข้อมูลเดิมหลัง network error
อ่าน success {id,message} และ error {error:{code,message}} ตาม response จริง
ทำสถานะเริ่มต้น/ข้อมูลไม่ครบ/กำลังส่ง/สำเร็จ/ส่งไม่สำเร็จ พร้อมข้อความภาษาไทย
ปิดปุ่มเฉพาะระหว่างส่ง ไม่ล้างข้อความเมื่อเกิด error และไม่แสดงสำเร็จก่อน server ยืนยัน
ใช้ข้อมูลติดต่อสมมติและแจ้งว่า Worker ปกปิด name/email/message ก่อนบันทึก D1
ไม่มีการส่งอีเมลจริง ไม่มี secret ใน client และไม่เปลี่ยน DEMO_MODE ของ starter

รองรับ desktop และมือถือ 390px ไม่มีเนื้อหาล้นแนวนอน มี skip link,
heading ตามลำดับ, focus ที่เห็น, keyboard navigation, status ที่ screen reader อ่านได้,
ปุ่มใช้งานสะดวกอย่างน้อย 44px และ prefers-reduced-motion
ตรวจด้วย npm run build และ npm run lint จากนั้นทดลองกรอกผิด/ส่งสำเร็จ/จำลอง network error
ใช้ preview ตามพอร์ตที่โปรเจกต์จองไว้ ห้ามใช้พอร์ตของโครงการอื่น
ส่งสรุปไฟล์ที่สร้าง วิธีเปิดหน้า ภาพผลลัพธ์ และผลตรวจจริง; สิ่งที่ยังไม่ได้รันทดสอบให้ระบุไว้
```

**ผลลัพธ์ที่ควรได้:** หน้าเว็บบริษัทที่มีส่วนแนะนำบริการ/ผลงานและแบบฟอร์มทำงานได้ เปิดเทียบกับ [เส้นตั้ง สตูดิโอ](/examples/company/) และ [ภาพผลลัพธ์](/images/results/company-desktop.png) แล้วตรวจ response ของ form ตาม lab ด้านล่าง ดู [prompt ภาพประกอบที่ใช้](../assets/image-provenance.md#company-studio) หากต้องเตรียมภาพใหม่

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

```text
ตรวจเว็บบริษัทที่เพิ่งสร้างเทียบกับ brief ข้างต้น แล้วแก้เฉพาะจุดที่ไม่ผ่าน
ตรวจหน้า 390px, การใช้ Tab, label/focus, ข้อมูลผิด, contact success และ network error
ตรวจว่า retry ข้อมูลเดิมไม่สร้าง contact ซ้ำ และ success แสดงจาก response ของ API
สรุปเป็นตาราง ข้อกำหนด / ผลที่พบ / หลักฐาน / สิ่งที่แก้ พร้อมรันตรวจซ้ำเฉพาะส่วนที่แก้
```

## Contract ที่มีอยู่จริง

![ลำดับข้อความของ contact form จากหน้า examples/company และ main.js ส่ง POST api contact ไปยัง Worker ซึ่งตรวจคำขอและเขียนฟิลด์ที่ปกปิดแล้วลงตาราง contacts ก่อนคืน 201 พร้อม id และ message](/diagrams/contact-sequence.svg)

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

ความสัมพันธ์เดิมยังครบ: `/examples/company/` กับ `examples/company/main.js` ส่ง `POST /api/contact` ไป `src/worker.ts`; Worker ใช้ D1 batch เขียนฟิลด์ที่ redacted ลง `contacts` แล้วคืน `201 {id, message}` ให้หน้าเดิม ไม่มีลูกศรใดหมายความว่า browser เขียน D1 ได้โดยตรง

**ฝึกอ่านภาพ:** ชี้ข้อความแรกที่ข้าม trust boundary และหลักฐานสองจุดที่ยืนยัน success จริง โดยอย่างน้อยหนึ่งจุดต้องมาจาก response หรือ D1 ไม่ใช่ข้อความที่ frontend สร้างเอง

`wrangler.jsonc` กำหนด static assets และ `run_worker_first: ["/api/*"]`; `/examples/company/` จึงเป็น asset และ `/api/contact` เป็น Worker route. Worker บังคับ `Origin` ให้เท่ากับ origin ของ request, `Content-Type: application/json`, object ที่มีเฉพาะ `name`, `email`, `message`, `consent`, `website` (และ `turnstileToken` สำหรับ non-demo). `Idempotency-Key` เป็น optional แต่หน้า demo ส่งให้ทุกครั้ง.

## Lab: รันและตรวจ contact demo

1. เตรียม local D1 และเปิด Worker. เปิด terminal หนึ่งหน้าต่างค้างไว้ที่ command ที่สอง:

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

   **ผลที่คาดหวัง:** migration `migrations/0001_initial.sql` ถูก apply ลง local D1 และ Wrangler ฟังที่ `http://localhost:3320`. ถ้า port ถูกใช้แล้ว อย่าเปลี่ยน URL ใน curl โดยเดา; หยุด process เดิมหรือดู output ของ Wrangler ก่อน.

2. เปิด `http://localhost:3320/examples/company/`. ส่ง form ด้วยชื่อและอีเมลตัวอย่าง, message ยาวอย่างน้อย 10 ตัวอักษร, tick consent. `examples/company/main.js` เรียก endpoint นี้จริง:

   ```js
   fetch('/api/contact', { method: 'POST', headers: {
     'content-type': 'application/json', 'Idempotency-Key': submissionKey
   }})
   ```

   **ผลที่คาดหวัง:** หน้าจอแสดงข้อความ `รับข้อความสาธิตแล้ว กรุณาอย่าส่งข้อมูลจริง` และ reference id ที่เปลี่ยนทุก submission ใหม่.

3. ทำซ้ำด้วย curl เพื่อเห็น contract และ Same-Origin rule ชัดเจน. `Origin` ต้องตรงกับ local Worker และ `Idempotency-Key` เป็น ASCII key ที่ยังไม่เคยใช้:

   ```bash
   curl -i -X POST http://localhost:3320/api/contact \
     -H 'Origin: http://localhost:3320' \
     -H 'Content-Type: application/json' \
     -H 'Idempotency-Key: company-lab-001' \
     --data '{"name":"Demo Learner","email":"learner@example.test","message":"ข้อความสำหรับทดสอบระบบสาธิต","consent":true,"website":""}'
   ```

   **ผลที่คาดหวัง:** HTTP `201` และ JSON รูปนี้ โดย `id` เป็น UUID ใหม่:

   ```json
   {"id":"<uuid>","message":"รับข้อความสาธิตแล้ว กรุณาอย่าส่งข้อมูลจริง"}
   ```

4. ส่ง payload ผิดเพื่อเรียนรู้ error ที่ Worker คืนจริง:

   ```bash
   curl -i -X POST http://localhost:3320/api/contact \
     -H 'Origin: http://localhost:3320' \
     -H 'Content-Type: application/json' \
     --data '{"name":"A","email":"not-email","message":"สั้น","consent":false,"website":""}'
   ```

   **ผลที่คาดหวัง:** HTTP `400` และ envelope เสมอเป็น `{ "error": { "code": "…", "message": "ข้อความภาษาไทย" } }`. สำหรับ payload นี้ validation แรกของ Worker คือ `name`; ตัวอย่างผลคือ `{"error":{"code":"INVALID_FIELD","message":"name ต้องยาว 2-100 ตัวอักษร"}}`. ลองเอา `Origin` ออกเพื่อได้ `403 ORIGIN_REJECTED`. Frontend ปัจจุบันแสดงข้อความ error จาก API ใน UI; เมื่อเขียน client ใหม่ให้ parse `body.error.message` ไม่ใช่สมมติว่า `body.error` เป็น string.

5. ดู local database เพื่อพิสูจน์ redaction:

   ```bash
   npx wrangler d1 execute DB --local --command \
     "SELECT id, name, email, message, created_at FROM contacts ORDER BY created_at DESC LIMIT 3"
   ```

   **ผลที่คาดหวัง:** row จาก request ที่สำเร็จมี `name`, `email`, `message` เป็น `[demo-redacted]`, ไม่ใช่ค่าตัวอย่างที่ส่ง. นี่เป็นหลักฐานว่า demo ไม่ต้องมี PII จริงเพื่อสอน endpoint/database flow.

6. ทดลอง idempotency โดยส่ง command จากขั้น 3 ซ้ำ **เหมือนเดิมทุก byte**. ได้ `201` response เดิมและ header `Idempotent-Replayed: true`; ถ้าใช้ key เดิมแต่แก้ payload จะได้ `409 IDEMPOTENCY_CONFLICT`. จากนั้นรัน:

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

## สิ่งที่ demo ทำ และสิ่งที่ยังไม่ใช่ production

| หัวข้อ | ใน `DEMO_MODE=true` ตอนนี้ | เมื่อทำเป็นธุรกิจจริง (งานเพิ่ม) |
| --- | --- | --- |
| contact data | validate แล้ว redact ก่อน insert | กำหนด lawful purpose, retention, access control, secure notification และ privacy copy |
| bot defense | honeypot + daily limit; Turnstile skip | ตั้ง `TURNSTILE_SECRET`, site key, hostname และทดสอบ verification |
| ส่งข้อความ | เก็บ local D1 เท่านั้น | เลือก provider/queue และป้องกัน replay/abuse ตาม policy |
| deploy | local lab | apply migration ไป environment ที่ตั้งใจ, preview smoke test, แล้วจึง deploy ตามบท 08 |

ส่วน “business implementation” ด้านขวาเป็นแนวทางออกแบบ ไม่ใช่เงื่อนไขที่ learner ต้องทำเพื่อผ่าน demo. OWASP แนะนำ syntactic และ semantic validation ตั้งแต่ต้นทาง และ parameterized statements สำหรับ SQL ([Input Validation](https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html), [SQL Injection Prevention](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html)); Worker ของ lab ใช้ D1 prepared statements/batch ตาม API ของ D1 ([Cloudflare D1](https://developers.cloudflare.com/d1/worker-api/)).

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

| อาการ | สาเหตุ | วิธีแก้ |
| --- | --- | --- |
| `403 ORIGIN_REJECTED` | curl ไม่มี/ใช้ Origin คนละ port | ใส่ `Origin: http://localhost:3320` ตาม command |
| `415 JSON_REQUIRED` | ไม่ได้ส่ง `Content-Type` | ใช้ `application/json` |
| หน้า form บอก error แปลก ๆ | client อ่าน `body.error` ทั้ง object | อ่าน `body.error.message` ใน code ที่ปรับปรุง |
| D1 ไม่มี table | ยังไม่รัน local migration | รัน `npm run db:local` แล้ว restart dev หากจำเป็น |
| ค้นหา PII แล้วไม่พบ | `DEMO_MODE=true` redact โดยเจตนา | นี่คือ expected result; ห้ามปิด DEMO_MODE เพื่อทดสอบด้วย PII |

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

ผู้เรียนต้องสาธิต success 201, validation 400, origin 403 และ query ที่เห็น `[demo-redacted]`; อธิบายได้ว่าทำไม client-side validation และ browser success message ไม่ใช่ authority ของ data persistence. ส่งหลักฐานเป็น command/output ที่ปิดบัง id หากนำไปเผยแพร่.

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