SurePhone ERD — Domain 0: Shared Foundations (ฐานกลาง)

ตาราง/partials ที่ทุก domain ใช้ร่วม — นิยามที่นี่ที่เดียว เวลา compile/export ให้ concat 00-shared + domain ที่ต้องการ


DBML

//// ───────── Shared audit partials ─────────
TablePartial audit_c {
  created_by varchar [not null, default: 'SYSTEM', note: 'ID ผู้สร้าง หรือ SYSTEM']
  created_at timestamptz [not null, default: `CURRENT_TIMESTAMP`]
 
  Note: 'Metadata ตรวจสอบผู้สร้าง + เวลา — ใช้กับตารางที่ insert-only / ไม่แก้ไข'
}
 
TablePartial audit_cu {
  created_by varchar [not null, default: 'SYSTEM']
  updated_by varchar [not null, default: 'SYSTEM']
  created_at timestamptz [not null, default: `CURRENT_TIMESTAMP`]
  updated_at timestamptz [not null, default: `CURRENT_TIMESTAMP`]
 
  Note: 'Metadata ตรวจสอบผู้สร้าง/ผู้แก้ไข + เวลา — ใช้กับตารางที่แก้ไขได้'
}
 
//// ───────── sys.r2_file (catalog ไฟล์กลาง บน Cloudflare R2) ─────────
Table sys.r2_file {
  id uuid [pk, default: `gen_random_uuid()`]
  object_key varchar(1024) [not null, unique, note: 'path/key ของไฟล์ใน R2 (bucket เดียว = app config) — สร้าง signed URL ตอนเข้าถึง']
  is_public bool [not null, default: false, note: 'true = ไฟล์สาธารณะ (โลโก้/QR/แบนเนอร์) เข้าถึงตรงได้; false = ส่วนบุคคล ต้องผ่าน signed URL']
  mime_type varchar(255)
  file_size_bytes bigint
  original_filename varchar(255) [note: 'ชื่อไฟล์เดิมตอน upload']
  ~audit_c
 
  indexes {
    is_public [name: 'idx_r2_file_public']
  }
  Note: '''
    Catalog ไฟล์ทุกไฟล์บน R2 (bucket เดียว) — single source of truth ของ metadata ไฟล์
    เก็บ object_key เท่านั้น ไม่เก็บ public URL ถาวร เพราะไฟล์ส่วนบุคคลต้องเข้าถึงผ่าน signed URL
    ตารางอื่นอ้างไฟล์ผ่านคอลัมน์ xxx_file_id (FK → sys.r2_file.id)
    หลายไฟล์ต่อ 1 record (รูปเครื่อง/หลักฐาน/วิดีโอ) ใช้ child table ที่มี file_id แทน column เดียว
    created_by (จาก audit_c) = ผู้ upload
  '''
}

Mermaid ER

erDiagram
  r2_file {
    uuid id PK
    varchar object_key UK
    bool is_public
    varchar mime_type
    bigint file_size_bytes
    varchar original_filename
    varchar created_by
    timestamptz created_at
  }

หลักการใช้งาน

  • ทุกคอลัมน์ที่เคยเป็น xxx_img_path varchar → เปลี่ยนเป็น xxx_file_id uuid (FK → sys.r2_file.id)
  • ไฟล์สาธารณะ (โลโก้/QR/แบนเนอร์) set is_public = true ตอน upload · ไฟล์ส่วนบุคคล false
  • แนบหลายไฟล์ → child table เช่น contract.contract_documents (id, contract_id, file_id, doc_type)
  • ลบไฟล์จริงบน R2 → ใช้ deletion-queue table (จะนิยามใน sys/Domain 8 แบบ phonerefun) trigger ตอน record เปลี่ยน/ลบ

object_key คืออะไร

R2 (เหมือน S3) เป็น object storage แบบ flat key→bytes ไม่มีโฟลเดอร์จริง

  • object_key = “กุญแจ/เส้นทาง” ที่ระบุ object ภายใน bucket — เหมือน path ของไฟล์ เช่น business/abc123/logo/9f2e.png
  • bucket + object_key = ชี้ไฟล์ได้ unique (เรามี bucket เดียว เลยเหลือแค่ object_key)
  • ไม่ใช่ URL — URL เกิดจาก key มาประกอบทีหลัง: public = <base>/<object_key>, private = presigned URL จาก object_key (มีลายเซ็น + วันหมดอายุ)
  • เราตั้ง key เป็น path-like เพื่อจัดระเบียบ + ใส่ uuid กันชื่อชน เช่น customer/{customer_id}/id_card/{uuid}.jpg
  • เก็บ object_key ใน DB (เสถียร ไม่หมดอายุ) แทนการเก็บ URL (ซึ่งเปลี่ยน/หมดอายุได้)

การทำงาน (Flow)

sequenceDiagram
  autonumber
  actor U as ผู้ใช้/Client
  participant A as Backend (App)
  participant R as Cloudflare R2 (1 bucket)
  participant D as DB (sys.r2_file + owner)

  note over U,D: 1) Upload
  U->>A: อัปโหลดไฟล์ (bytes)
  A->>R: PUT object ที่ object_key
  R-->>A: ok
  A->>D: INSERT r2_file (object_key, is_public, mime...) → file_id
  A->>D: UPDATE owner SET xxx_file_id = file_id
  A-->>U: สำเร็จ

  note over U,D: 2) Read — public (โลโก้/QR)
  U->>R: GET public URL (จาก object_key) ตรงๆ
  R-->>U: ไฟล์

  note over U,D: 3) Read — private (บัตร/สลิป/วิดีโอ)
  U->>A: ขอดูไฟล์ (file_id)
  A->>D: SELECT object_key, is_public + เช็คสิทธิ์
  A->>R: presign(object_key, หมดอายุ 10 นาที)
  R-->>A: signed URL (ชั่วคราว)
  A-->>U: signed URL — ไม่เก็บลง DB
  U->>R: GET signed URL → ได้ไฟล์

  note over A,R: 4) Cleanup — เปลี่ยน/ลบไฟล์ → enqueue job → worker เรียก R2 delete → ลบแถว r2_file

ตัวอย่าง — โลโก้บ้าน (public):

  • r2_file: { id: "f1a2…", object_key: "business/abc/logo/9x.png", is_public: true, mime_type: "image/png" }
  • businesses: { id: "abc", name: "บ้าน A", logo_file_id: "f1a2…" }
  • แสดง: is_public=true → ต่อ URL จาก object_key เสิร์ฟตรงๆ

บัตรประชาชน (private): is_public=false → แอดมินเปิดดู ระบบเช็คสิทธิ์ → presign ลิงก์อายุ 10 นาที → หมดเวลาเปิดไม่ได้