@suphark/notify 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @suphark/notify
2
2
 
3
- SDK ส่งอีเมล + แจ้งเตือน (Telegram) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
3
+ SDK ส่งอีเมล + แจ้งเตือน (Telegram / Web Push) ของแอปทุกตัว — ค่าเริ่มต้นส่งผ่าน **Pi Notify hub** (`https://notify.suphark.com`) ซึ่งเป็นคนถือ credential ของ Resend/SMTP/Zoho/Telegram bot ทำ failover นับโควต้า เก็บ log และเก็บว่าผู้รับผูกแชทไหนไว้ แอปใส่แค่ key ตัวเดียว
4
+
5
+ > 📖 **คู่มือการใช้งานและสูตรการเขียนโค้ดฉบับเต็ม:** ดูที่ [docs/guide.md](../../docs/guide.md)
4
6
 
5
7
  ```bash
6
8
  pnpm add @suphark/notify # หรือ npm i / bun add
@@ -85,24 +87,155 @@ const { channels } = await getRecipient(user.email); // [{ type: "telegram
85
87
 
86
88
  หรือส่งผู้ใช้ไปผูกเองที่ `https://notify.suphark.com/me` (ล็อกอิน Pi Auth) — ผูกครั้งเดียวใช้ได้ทุกแอป
87
89
 
90
+ `createLink()` คืน `code` ด้วย (รหัสเดียวกับ `?start=` ใน url — ไว้ให้ผู้ใช้พิมพ์ `/start <code>` เองเมื่อเปิด deep link ไม่ได้) และ `getChannelStatus()` ตอบสถานะการผูกเลยโดยไม่ต้อง find เอง:
91
+
92
+ ```ts
93
+ const { url, code } = await createLink(user); // รับ user object ได้
94
+ const { linked, label, manageUrl } = await getChannelStatus(user, "telegram");
95
+ ```
96
+
97
+ ### ให้ hub ส่งซ้ำเอง: `deliverWithin` + `idempotencyKey` (0.6.0)
98
+
99
+ ```ts
100
+ const r = await notify({
101
+ to: user,
102
+ title: "ถึงกำหนดงาน",
103
+ body: "…",
104
+ channels: ["telegram"],
105
+ deliverWithin: 3600, // ให้ hub พยายามส่งภายใน 1 ชม.
106
+ idempotencyKey: `pi-done:reminder:${user.id}:${runId}`, // cron รันซ้ำก็ไม่เตือนซ้ำ
107
+ });
108
+ // r.results[i].status: "sent" | "queued" (hub รับไว้ส่งซ้ำเอง) | "failed" | "skipped"
109
+ ```
110
+
111
+ - นับ `queued` เป็น "รับไว้แล้ว" — แอปไม่ต้องส่งซ้ำเอง; ดูสถานะที่หน้า Logs / คิวส่งซ้ำของ Pi Notify
112
+ - `failed` = ผิดถาวร / ลองครบแล้ว / เลยเวลา — แอปตัดสินใจเอง (อย่าสลับไปอีเมลถ้าผู้ใช้ไม่ได้ขอ)
113
+ - ใช้ `idempotencyKey` ที่ผูกกับเหตุการณ์ (ไม่ใช่สุ่มใหม่ทุกครั้ง) จึงกันซ้ำได้ — ส่งเนื้อหาเดิมด้วย key เดิมซ้ำจะได้ผลเดิมกลับมา
114
+
115
+ ### เขียนให้สั้น: ส่งแบบไม่โยน / ส่งเบื้องหลัง / `to` เป็น user object (0.5.0)
116
+
117
+ `to` ของ `notify()` รับ **user object ที่มี `email`** (และ array ของมัน) ได้เลย — ไม่ต้องแกะ `.email` เอง
118
+ ผู้รับเดี่ยวที่ไม่มีอีเมลโยน `MailError` kind `invalid` (เป็น bug ของแอป ไม่เงียบ); ใน array กรอง `null`/`undefined` ทิ้งให้
119
+
120
+ ```ts
121
+ await notify({ to: user, title: "งานเสร็จแล้ว", body: "โปรเจกต์ A พร้อมส่งมอบ" });
122
+ await notify({ to: [owner, assignee], title: "มีการอัปเดต", body: "…" }); // null ใน array ถูกกรอง
123
+ ```
124
+
125
+ เลือกตามว่า **ต้องรู้ผลหรือไม่**:
126
+
127
+ | ต้องการ | ใช้ | พฤติกรรม |
128
+ |---|---|---|
129
+ | ต้องรู้ผล / ให้ cron บันทึก FAILED แล้วลองใหม่ | `notify()` / `sendMail()` | โยน `MailError` เมื่อล้มเหลว |
130
+ | แจ้งเตือนเป็นผลพลอยได้ของงานหลัก (ไม่ให้งานหลักพัง) | `notifySafe()` / `sendMailSafe()` | **ไม่โยน** คืน `{ ok, sent, failed, skipped, results, error?, disabled? }` |
131
+ | เหมือนข้างบน และไม่อยากให้หน้าเว็บรอ (Next.js) | `notifyLater()` / `sendMailLater()` จาก `@suphark/notify/next` | ส่งหลังตอบ response ด้วย `after()` คืน `void` |
132
+
133
+ ```ts
134
+ import { notifySafe } from "@suphark/notify";
135
+ const { ok } = await notifySafe({ to: user, title: "แจ้งเตือน", body: "เนื้อหา" });
136
+
137
+ import { notifyLater } from "@suphark/notify/next"; // Next.js เท่านั้น (next เป็น optional peer)
138
+ notifyLater({ to: user, title: "บันทึกแล้ว", body: "ระบบอัปเดตงานแล้ว" });
139
+ ```
140
+
141
+ - **ไม่มี `PI_NOTIFY_KEY`** → `{ ok: false, disabled: true }` ไม่ส่งและไม่ถือเป็น error (dev/test ไม่ต้องมี key) — เตือนครั้งเดียวใน dev, ทุกครั้งใน production เพื่อให้เห็นว่าลืมตั้ง
142
+ - `ok` = ไม่มี error และไม่มีรายการ `failed` — `skipped` (เช่นยังไม่ผูก Telegram) **ไม่ใช่** ความล้มเหลว; ดูรายตัวที่ `results`
143
+ - `notifyLater` ใช้ timeout 20 วิ (งานใน `after()` นับรวม `maxDuration` ของ function) และ log เมื่อล้มเหลวโดยไม่มีอีเมลผู้รับ — ส่งต่อเข้า logger ของแอปได้ด้วย `{ onError }`; นอก request scope (test/script) รันแบบ detached
144
+ - **ห้ามใช้ `…Later` เมื่อต้องรู้ผล** (เช่น cron digest ที่บันทึกสถานะ) — ใช้ `await notify()`
145
+
146
+ **อีเมลหน้าตาเดียวกับที่ Pi Notify ส่งแทนแชท** โดยไม่ต้องเขียน HTML เอง — `renderNotifyEmail()` (หัวข้อ + ย่อหน้า + ปุ่มเดียว; body เป็นข้อความล้วน ถูก escape ให้):
147
+
148
+ ```ts
149
+ import { renderNotifyEmail, sendMail } from "@suphark/notify";
150
+
151
+ await sendMail({
152
+ to: user.email,
153
+ ...renderNotifyEmail({
154
+ title: "บัญชีของคุณพร้อมใช้งานแล้ว",
155
+ body: "ผู้ดูแลอนุมัติบัญชีของคุณแล้ว",
156
+ link: "https://app.example/",
157
+ linkLabel: "เข้าสู่ระบบ", // ค่าเริ่มต้น "เปิดลิงก์"
158
+ accent: "#0f766e", // สีปุ่ม (hex เท่านั้น) ค่าเริ่มต้น #1d4ed8
159
+ appName: "Pi PDF", // ท้ายอีเมล
160
+ }),
161
+ });
162
+ ```
163
+
88
164
  ### แจ้งเตือนบนเบราว์เซอร์ (web push)
89
165
 
90
166
  hub ถือคู่กุญแจ VAPID ให้ (ไม่ต้องใช้ Firebase) — แอปเหลือแค่ service worker ของตัวเองกับปุ่มขอสิทธิ์
91
167
  **subscription ผูกกับแอปที่เรียกเสมอ** เพราะ service worker อยู่ที่โดเมนของแอป ใช้ข้ามแอปไม่ได้
92
168
 
169
+ > **แพ็กเกจนี้รันฝั่งเซิร์ฟเวอร์เท่านั้น** เพราะถือ `PI_NOTIFY_KEY` — ห้าม import ใน component ที่ส่งถึงเบราว์เซอร์
170
+ > เบราว์เซอร์คุยกับเซิร์ฟเวอร์ของแอปตัวเอง (server action / route) แล้วเซิร์ฟเวอร์ค่อยคุยกับ hub
171
+
172
+ **ทางลัด (0.5.0):** `createPushHandlers({ getUser })` ทำ 3 ฟังก์ชันข้างล่างให้ — แอปส่งแค่วิธีดึงผู้ใช้จาก session
173
+ (อีเมลต้องมาจาก session เท่านั้น; ตรวจรูป subscription จากเบราว์เซอร์ให้; ไม่ล็อกอิน = `MailError` kind `auth`) ใช้คู่กับ `<WebPushRow />` ของ `@suphark/ui/pwa`:
174
+
93
175
  ```ts
176
+ "use server";
177
+ import { createPushHandlers } from "@suphark/notify";
178
+
179
+ const push = createPushHandlers({
180
+ getUser: async () => {
181
+ const u = await getCurrentUser(); // ของแต่ละแอป
182
+ return u?.email ? { email: u.email, id: u.id } : null;
183
+ },
184
+ });
185
+ // ห่อเป็น async function ทีละตัว (ไฟล์ "use server" export ได้เฉพาะ async function)
186
+ export async function getVapidPublicKey() { return push.getPublicKey(); }
187
+ export async function registerPush(sub: PushSubscriptionJSON) { return push.subscribe(sub); }
188
+ export async function unregisterPush(endpoint: string) { return push.unsubscribe(endpoint); }
189
+ ```
190
+
191
+ **Service worker เสิร์ฟจากแพ็กเกจเอง** — ไม่ต้องเขียน `public/sw.js` และ **ไม่ควร `importScripts` จากโดเมนอื่น**
192
+ (service worker คุมทั้ง origin ของแอปและดักทุก request — โค้ดจากโดเมนอื่นรันเข้ามาได้ทุกแอปพร้อมกัน):
193
+
194
+ ```ts
195
+ // app/sw.js/route.ts
196
+ import { pushServiceWorker } from "@suphark/notify";
197
+ export const GET = pushServiceWorker({ icon: "/icon-192.png" }); // เปิดเฉพาะลิงก์ใน origin ของแอป
198
+ ```
199
+
200
+ เขียนเองทีละฟังก์ชันก็ได้ (ตัวอย่างเดิม):
201
+
202
+ ```ts
203
+ // ---- ฝั่งเซิร์ฟเวอร์ของแอป (server action) ----
204
+ "use server";
94
205
  import { getPushConfig, savePushSubscription, deletePushSubscription, listPushDevices } from "@suphark/notify";
95
206
 
96
- // ฝั่ง client: ขอสิทธิ์แล้วส่ง subscription ไปเก็บที่ hub ผ่าน server action ของแอป
97
- const { publicKey } = await getPushConfig(); // VAPID public key (ค่าสาธารณะ)
98
- const reg = await navigator.serviceWorker.register("/sw.js");
99
- const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey });
100
- await savePushSubscription(user.email, sub.toJSON(), { label: "Chrome บน Windows", piUserId: user.id });
207
+ export async function pushPublicKey() {
208
+ const { publicKey } = await getPushConfig(); // VAPID public key (ค่าสาธารณะ ส่งให้ client ได้)
209
+ return publicKey;
210
+ }
211
+ export async function savePush(subscription: PushSubscriptionJson, label?: string) {
212
+ const user = await requireAuth(); // อีเมลมาจาก session เท่านั้น ห้ามรับจาก client
213
+ return savePushSubscription(user.email, subscription, { label, piUserId: user.id });
214
+ }
215
+ export async function removePush(endpoint: string) {
216
+ const user = await requireAuth();
217
+ return deletePushSubscription(user.email, endpoint);
218
+ }
219
+ export async function myDevices() {
220
+ const user = await requireAuth();
221
+ return listPushDevices(user.email); // { devices: [{ id, label, enabled, endpointTail }] }
222
+ }
223
+ ```
101
224
 
102
- await listPushDevices(user.email); // { devices: [{ id, label, enabled, endpointTail }] }
103
- await deletePushSubscription(user.email, sub.endpoint); // ผู้ใช้ปิดที่เครื่องนั้น
225
+ ```ts
226
+ // ---- ฝั่งเบราว์เซอร์ (component ของแอป) ----
227
+ const reg = await navigator.serviceWorker.register("/sw.js");
228
+ const sub = await reg.pushManager.subscribe({
229
+ userVisibleOnly: true,
230
+ applicationServerKey: await pushPublicKey(),
231
+ });
232
+ await savePush(sub.toJSON(), `${navigator.userAgent}`);
104
233
  ```
105
234
 
235
+ **ไม่ใช้ SDK ก็ได้** — ทั้งหมดเป็น REST ธรรมดา เรียกจากเซิร์ฟเวอร์ของแอปด้วย `fetch` ได้เลย
236
+ (`Authorization: Bearer $PI_NOTIFY_KEY`): `GET /api/v1/push/config` · `POST`/`GET`/`DELETE /api/v1/recipients/:email/push`
237
+ SDK ให้เพิ่มแค่ timeout, การแปลง error เป็น `MailError` พร้อม `kind`, type และค่าเริ่มต้นของ URL/คีย์
238
+
106
239
  ส่งด้วย `notify({ channels: ["push"] })` (ไม่ระบุ channels = ส่งทุกช่องทางที่ผูกไว้ รวม push ทุกเครื่อง)
107
240
  service worker ของแอปจะได้ payload JSON `{ title, body, link? }` — `body` เป็นข้อความล้วนเสมอ:
108
241
 
@@ -258,6 +391,20 @@ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ b
258
391
 
259
392
  ## Changelog
260
393
 
394
+ - 0.6.0 — **hub ส่งซ้ำเอง** (เพิ่มอย่างเดียว — แอปที่ไม่ส่งตัวเลือกใหม่ได้พฤติกรรมเดิมทุกอย่าง; ต้องใช้ Pi Notify 2026-10-14+)
395
+ - `notify({ deliverWithin })` (วินาที 60–86400): รายการที่ค้างหรือพลาดชั่วคราวเข้าคิวถาวรของ hub แล้วส่งซ้ำตาม backoff (1 นาที → 5 นาที → 30 นาที → 2 ชม., สูงสุด 5 ครั้ง) · ผิดถาวรไม่ส่งซ้ำ · เลยเวลาแล้วไม่ส่ง · ไม่สลับช่องทาง
396
+ - `notify({ idempotencyKey })` → หัว `Idempotency-Key`: key+เนื้อหาเดิมได้ผลเดิม ไม่ส่งซ้ำ (จำ 7 วัน) · เนื้อหาต่างแต่ key เดิม = `invalid` (422) · คำขอเดิมยังทำอยู่ = `rate_limit` (409, `retryAfterSec` 5)
397
+ - `NotifyResultItem.status` เพิ่ม `"queued"` (+ `deliveryId`), `NotifyResult.queued?` / `NotifySafeResult.queued?` — `queued` นับเป็น "รับไว้แล้ว" ไม่ใช่ failed (`notifySafe().ok` เป็น true) · `status` ใหม่เกิดเฉพาะเมื่อแอปส่ง `deliverWithin` (ออกเป็น minor เพราะ union ของ `status` ที่แอปอาจ switch)
398
+
399
+ - 0.5.0 — **เขียนฝั่งแอปให้สั้นลง** (เพิ่มอย่างเดียว — ฟอร์มของ 0.4.0 ใช้ได้ทุกแบบ; Pi Notify รุ่นเก่าใช้ได้ ยกเว้น `code` ที่แกะจาก url ให้เอง)
400
+ - `notify({ to })` รับ user object `{ email }` และ array (กรอง null); ผู้รับเดี่ยวไม่มีอีเมล = `MailError` `invalid`
401
+ - `notifySafe()` / `sendMailSafe()` ไม่โยน; ไม่มี key/provider = `disabled`
402
+ - `@suphark/notify/next`: `notifyLater()` / `sendMailLater()` ส่งหลังตอบด้วย `after()` (optional peer `next >= 15`; core ไม่ import next)
403
+ - `getChannelStatus()`; `createLink()` คืน `code`
404
+ - `createPushHandlers({ getUser })` + `pushServiceWorker()` สำหรับ web push
405
+ - `renderNotifyEmail()` (+ `resolveFormat` / `toPlainText` / `looksLikeHtml`) — เทมเพลตอีเมลเดียวกับที่ Pi Notify ใช้ แอปไม่ต้องเขียน HTML "หัวข้อ + ย่อหน้า + ปุ่ม" เอง
406
+ - `getRecipient()` / `createLink()` รับ user object; `toEmails()`, `hasNotifyKey()`, type `Recipient`
407
+
261
408
  - 0.4.0 — **web push ผ่าน hub**: `getPushConfig()`, `savePushSubscription()`, `deletePushSubscription()`, `listPushDevices()` และ `notify({ channels: ["push"] })`; hub ถือคู่กุญแจ VAPID (มาตรฐาน ไม่ใช้ Firebase) subscription ผูกต่อแอป · `NotifyChannel` เพิ่ม `"push"` และ `reason` เพิ่ม `"no_push_provider"` (เพิ่มอย่างเดียว แต่ `NotifyChannel` เป็น type ที่แอปใช้ในลายเซ็นของตัวเอง จึงออกเป็น minor ตามกติกาข้อ 4)
262
409
 
263
410
  - 0.3.1 (additive ทั้งหมด — แอปที่ตั้ง `^0.3.0` รับได้เลย, ใช้ร่วมกับ Pi Notify ที่ deploy อยู่ได้ทันที)