// แปลง files.external_id ของเอกสาร e-Court จาก fid ดิบ ("ecourt:") เป็น hash // ("ecourt:" + HMAC-SHA256(pepper, fid)) — รันครั้งเดียวตอน roll การเปลี่ยนของ // lib/ecourt-fid.ts (2026-11-04) + เก็บ title default ที่ฝัง fid ให้เป็นค่ากลางใหม่ // // ทำเป็น Node script ไม่ใช่ SQL ใน drizzle/ เพราะ pepper ต้องมาจาก env ของเครื่อง // (ห้าม commit ค่าลับลง migration) · สูตร hash ต้องตรงกับ lib/ecourt-fid.ts เป๊ะ // (HMAC-SHA256 key=pepper บน fid.trim() → hex — script .mjs import TS ไม่ได้จึงมี // สำเนาสูตรตรงนี้ แก้ที่ใดต้องแก้อีกทางคู่กัน) // // idempotent — รันซ้ำได้: แถวที่ tail เป็น hex 64 แล้วถูกข้าม // · idempotent ไม่รวม "ข้ามแถวชน" — ถ้า target hash ที่คำนวณไปชนกับแถวอื่น (มีการ // roll ใหม่ค้างก่อน migration) จะพิมพ์ warning แล้วข้าม ห้าม merge/delete เอง // // รัน: npm run migrate:ecourt-fid (node --env-file=.env ตาม pattern ของ seed-samples) // ⚠️ ระหว่างรันต้องหยุด process ของแอปก่อน — โค้ดเก่าที่ยังเขียน plaintext กลับมา // หลัง migration จะ insert แถวคู่เงียบ ๆ (ดูลำดับ roll ในแผน) import { createHmac } from "node:crypto"; import postgres from "postgres"; const ECOURT_PREFIX = "ecourt:"; const ECOURT_DEFAULT_TITLE = "เอกสารจาก e-Court"; const HEX64 = /^[0-9a-f]{64}$/; const dbUrl = process.env.DATABASE_URL; if (!dbUrl) throw new Error("ต้องตั้ง DATABASE_URL ก่อนรัน (node --env-file=.env)"); const pepper = process.env.FID_HASH_PEPPER?.trim(); if (!pepper) { throw new Error( "ต้องตั้ง FID_HASH_PEPPER ก่อน (ค่าคงที่ตลอดชีพ — ห้ามเปลี่ยนหลังมีข้อมูล, 32+ ตัวสุ่ม)", ); } const ecourtExternalId = (fid) => ECOURT_PREFIX + createHmac("sha256", pepper).update(fid.trim()).digest("hex"); const sql = postgres(dbUrl, { prepare: false, max: 1 }); try { const rows = await sql`select id, title, external_id from files where external_id like ${ECOURT_PREFIX + "%"}`; // target ทุกแถวก่อนเพื่อตรวจชน (แถวที่ hash แล้ว target = ตัวมันเอง = ไม่ชนใคร) const targets = new Map(); // id → target external_id for (const row of rows) { const fid = row.external_id.slice(ECOURT_PREFIX.length); targets.set(row.id, HEX64.test(fid) ? row.external_id : ecourtExternalId(fid)); } const ownerByTarget = new Map(); for (const [id, target] of targets) { const prev = ownerByTarget.get(target); if (prev !== undefined && prev !== id) { console.warn(`[fid-hash] ตรวจพบแถวชน: ${prev} และ ${id} hash ไปที่ค่าเดียวกัน — ข้ามทั้งคู่ (ตรวจข้อมูลก่อนรันใหม่)`); targets.delete(prev); targets.delete(id); } else { ownerByTarget.set(target, id); } } let converted = 0; let titlesFixed = 0; let skipped = 0; for (const row of rows) { const target = targets.get(row.id); if (target === undefined) { skipped += 1; continue; } if (target === row.external_id) { skipped += 1; // hash แล้ว (idempotent) continue; } const fid = row.external_id.slice(ECOURT_PREFIX.length); // title default เก่าสองรูปแบบที่ฝัง fid ดิบ: "เอกสาร " (N11) และ fid เปล่า (N8 // fallback ของ titleFromExternalUrl ยุคเก่า) — เก็บเป็นค่ากลางใหม่ const isDefaultTitle = row.title === `เอกสาร ${fid}` || row.title === fid; const newTitle = isDefaultTitle ? ECOURT_DEFAULT_TITLE : row.title; await sql`update files set external_id = ${target}, title = ${newTitle} where id = ${row.id}`; converted += 1; if (isDefaultTitle) titlesFixed += 1; } const leftovers = await sql`select count(*)::int as n from files where external_id like ${ECOURT_PREFIX + "%"} and external_id !~ ${ECOURT_PREFIX + "[0-9a-f]{64}$"}`; console.log( `[fid-hash] เสร็จ: แปลง ${converted} แถว (แก้ title default ${titlesFixed}) · ข้าม ${skipped} แถว (hash แล้ว/ชน) · เหลือ plaintext ${leftovers[0].n} แถว (ต้องเป็น 0)`, ); if (leftovers[0].n !== 0) process.exitCode = 1; } finally { await sql.end(); }