@suphark/notify 0.3.1 → 0.5.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Suphark
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suphark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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,6 +87,151 @@ 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
+ ### เขียนให้สั้น: ส่งแบบไม่โยน / ส่งเบื้องหลัง / `to` เป็น user object (0.5.0)
98
+
99
+ `to` ของ `notify()` รับ **user object ที่มี `email`** (และ array ของมัน) ได้เลย — ไม่ต้องแกะ `.email` เอง
100
+ ผู้รับเดี่ยวที่ไม่มีอีเมลโยน `MailError` kind `invalid` (เป็น bug ของแอป ไม่เงียบ); ใน array กรอง `null`/`undefined` ทิ้งให้
101
+
102
+ ```ts
103
+ await notify({ to: user, title: "งานเสร็จแล้ว", body: "โปรเจกต์ A พร้อมส่งมอบ" });
104
+ await notify({ to: [owner, assignee], title: "มีการอัปเดต", body: "…" }); // null ใน array ถูกกรอง
105
+ ```
106
+
107
+ เลือกตามว่า **ต้องรู้ผลหรือไม่**:
108
+
109
+ | ต้องการ | ใช้ | พฤติกรรม |
110
+ |---|---|---|
111
+ | ต้องรู้ผล / ให้ cron บันทึก FAILED แล้วลองใหม่ | `notify()` / `sendMail()` | โยน `MailError` เมื่อล้มเหลว |
112
+ | แจ้งเตือนเป็นผลพลอยได้ของงานหลัก (ไม่ให้งานหลักพัง) | `notifySafe()` / `sendMailSafe()` | **ไม่โยน** คืน `{ ok, sent, failed, skipped, results, error?, disabled? }` |
113
+ | เหมือนข้างบน และไม่อยากให้หน้าเว็บรอ (Next.js) | `notifyLater()` / `sendMailLater()` จาก `@suphark/notify/next` | ส่งหลังตอบ response ด้วย `after()` คืน `void` |
114
+
115
+ ```ts
116
+ import { notifySafe } from "@suphark/notify";
117
+ const { ok } = await notifySafe({ to: user, title: "แจ้งเตือน", body: "เนื้อหา" });
118
+
119
+ import { notifyLater } from "@suphark/notify/next"; // Next.js เท่านั้น (next เป็น optional peer)
120
+ notifyLater({ to: user, title: "บันทึกแล้ว", body: "ระบบอัปเดตงานแล้ว" });
121
+ ```
122
+
123
+ - **ไม่มี `PI_NOTIFY_KEY`** → `{ ok: false, disabled: true }` ไม่ส่งและไม่ถือเป็น error (dev/test ไม่ต้องมี key) — เตือนครั้งเดียวใน dev, ทุกครั้งใน production เพื่อให้เห็นว่าลืมตั้ง
124
+ - `ok` = ไม่มี error และไม่มีรายการ `failed` — `skipped` (เช่นยังไม่ผูก Telegram) **ไม่ใช่** ความล้มเหลว; ดูรายตัวที่ `results`
125
+ - `notifyLater` ใช้ timeout 20 วิ (งานใน `after()` นับรวม `maxDuration` ของ function) และ log เมื่อล้มเหลวโดยไม่มีอีเมลผู้รับ — ส่งต่อเข้า logger ของแอปได้ด้วย `{ onError }`; นอก request scope (test/script) รันแบบ detached
126
+ - **ห้ามใช้ `…Later` เมื่อต้องรู้ผล** (เช่น cron digest ที่บันทึกสถานะ) — ใช้ `await notify()`
127
+
128
+ **อีเมลหน้าตาเดียวกับที่ Pi Notify ส่งแทนแชท** โดยไม่ต้องเขียน HTML เอง — `renderNotifyEmail()` (หัวข้อ + ย่อหน้า + ปุ่มเดียว; body เป็นข้อความล้วน ถูก escape ให้):
129
+
130
+ ```ts
131
+ import { renderNotifyEmail, sendMail } from "@suphark/notify";
132
+
133
+ await sendMail({
134
+ to: user.email,
135
+ ...renderNotifyEmail({
136
+ title: "บัญชีของคุณพร้อมใช้งานแล้ว",
137
+ body: "ผู้ดูแลอนุมัติบัญชีของคุณแล้ว",
138
+ link: "https://app.example/",
139
+ linkLabel: "เข้าสู่ระบบ", // ค่าเริ่มต้น "เปิดลิงก์"
140
+ accent: "#0f766e", // สีปุ่ม (hex เท่านั้น) ค่าเริ่มต้น #1d4ed8
141
+ appName: "Pi PDF", // ท้ายอีเมล
142
+ }),
143
+ });
144
+ ```
145
+
146
+ ### แจ้งเตือนบนเบราว์เซอร์ (web push)
147
+
148
+ hub ถือคู่กุญแจ VAPID ให้ (ไม่ต้องใช้ Firebase) — แอปเหลือแค่ service worker ของตัวเองกับปุ่มขอสิทธิ์
149
+ **subscription ผูกกับแอปที่เรียกเสมอ** เพราะ service worker อยู่ที่โดเมนของแอป ใช้ข้ามแอปไม่ได้
150
+
151
+ > **แพ็กเกจนี้รันฝั่งเซิร์ฟเวอร์เท่านั้น** เพราะถือ `PI_NOTIFY_KEY` — ห้าม import ใน component ที่ส่งถึงเบราว์เซอร์
152
+ > เบราว์เซอร์คุยกับเซิร์ฟเวอร์ของแอปตัวเอง (server action / route) แล้วเซิร์ฟเวอร์ค่อยคุยกับ hub
153
+
154
+ **ทางลัด (0.5.0):** `createPushHandlers({ getUser })` ทำ 3 ฟังก์ชันข้างล่างให้ — แอปส่งแค่วิธีดึงผู้ใช้จาก session
155
+ (อีเมลต้องมาจาก session เท่านั้น; ตรวจรูป subscription จากเบราว์เซอร์ให้; ไม่ล็อกอิน = `MailError` kind `auth`) ใช้คู่กับ `<WebPushRow />` ของ `@suphark/ui/pwa`:
156
+
157
+ ```ts
158
+ "use server";
159
+ import { createPushHandlers } from "@suphark/notify";
160
+
161
+ const push = createPushHandlers({
162
+ getUser: async () => {
163
+ const u = await getCurrentUser(); // ของแต่ละแอป
164
+ return u?.email ? { email: u.email, id: u.id } : null;
165
+ },
166
+ });
167
+ // ห่อเป็น async function ทีละตัว (ไฟล์ "use server" export ได้เฉพาะ async function)
168
+ export async function getVapidPublicKey() { return push.getPublicKey(); }
169
+ export async function registerPush(sub: PushSubscriptionJSON) { return push.subscribe(sub); }
170
+ export async function unregisterPush(endpoint: string) { return push.unsubscribe(endpoint); }
171
+ ```
172
+
173
+ **Service worker เสิร์ฟจากแพ็กเกจเอง** — ไม่ต้องเขียน `public/sw.js` และ **ไม่ควร `importScripts` จากโดเมนอื่น**
174
+ (service worker คุมทั้ง origin ของแอปและดักทุก request — โค้ดจากโดเมนอื่นรันเข้ามาได้ทุกแอปพร้อมกัน):
175
+
176
+ ```ts
177
+ // app/sw.js/route.ts
178
+ import { pushServiceWorker } from "@suphark/notify";
179
+ export const GET = pushServiceWorker({ icon: "/icon-192.png" }); // เปิดเฉพาะลิงก์ใน origin ของแอป
180
+ ```
181
+
182
+ เขียนเองทีละฟังก์ชันก็ได้ (ตัวอย่างเดิม):
183
+
184
+ ```ts
185
+ // ---- ฝั่งเซิร์ฟเวอร์ของแอป (server action) ----
186
+ "use server";
187
+ import { getPushConfig, savePushSubscription, deletePushSubscription, listPushDevices } from "@suphark/notify";
188
+
189
+ export async function pushPublicKey() {
190
+ const { publicKey } = await getPushConfig(); // VAPID public key (ค่าสาธารณะ ส่งให้ client ได้)
191
+ return publicKey;
192
+ }
193
+ export async function savePush(subscription: PushSubscriptionJson, label?: string) {
194
+ const user = await requireAuth(); // อีเมลมาจาก session เท่านั้น ห้ามรับจาก client
195
+ return savePushSubscription(user.email, subscription, { label, piUserId: user.id });
196
+ }
197
+ export async function removePush(endpoint: string) {
198
+ const user = await requireAuth();
199
+ return deletePushSubscription(user.email, endpoint);
200
+ }
201
+ export async function myDevices() {
202
+ const user = await requireAuth();
203
+ return listPushDevices(user.email); // { devices: [{ id, label, enabled, endpointTail }] }
204
+ }
205
+ ```
206
+
207
+ ```ts
208
+ // ---- ฝั่งเบราว์เซอร์ (component ของแอป) ----
209
+ const reg = await navigator.serviceWorker.register("/sw.js");
210
+ const sub = await reg.pushManager.subscribe({
211
+ userVisibleOnly: true,
212
+ applicationServerKey: await pushPublicKey(),
213
+ });
214
+ await savePush(sub.toJSON(), `${navigator.userAgent}`);
215
+ ```
216
+
217
+ **ไม่ใช้ SDK ก็ได้** — ทั้งหมดเป็น REST ธรรมดา เรียกจากเซิร์ฟเวอร์ของแอปด้วย `fetch` ได้เลย
218
+ (`Authorization: Bearer $PI_NOTIFY_KEY`): `GET /api/v1/push/config` · `POST`/`GET`/`DELETE /api/v1/recipients/:email/push`
219
+ SDK ให้เพิ่มแค่ timeout, การแปลง error เป็น `MailError` พร้อม `kind`, type และค่าเริ่มต้นของ URL/คีย์
220
+
221
+ ส่งด้วย `notify({ channels: ["push"] })` (ไม่ระบุ channels = ส่งทุกช่องทางที่ผูกไว้ รวม push ทุกเครื่อง)
222
+ service worker ของแอปจะได้ payload JSON `{ title, body, link? }` — `body` เป็นข้อความล้วนเสมอ:
223
+
224
+ ```js
225
+ self.addEventListener("push", (event) => {
226
+ const { title, body, link } = event.data.json();
227
+ event.waitUntil(self.registration.showNotification(title, { body, data: { link } }));
228
+ });
229
+ ```
230
+
231
+ - ไม่มีเครื่องไหนเปิดไว้ → `status: "skipped", reason: "channel_not_linked"` · hub ยังไม่ตั้ง provider → `no_push_provider`
232
+ - เบราว์เซอร์ถอนสิทธิ์ (404/410) → hub ปิด subscription นั้นให้เอง ไม่ต้องตามลบ
233
+ - เปลี่ยนคู่กุญแจ VAPID ที่ hub = ผู้ใช้ทุกคนต้องกดอนุญาตใหม่ (subscription ผูกกับกุญแจที่ใช้ตอน subscribe)
234
+
88
235
  ## โหมด local (ไม่ผ่าน hub)
89
236
 
90
237
  ไม่มี `PI_NOTIFY_KEY` = SDK ส่งตรงด้วย provider ที่ตั้งใน env ของแอปเอง (engine เดียวกับที่ hub ใช้)
@@ -226,6 +373,17 @@ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ b
226
373
 
227
374
  ## Changelog
228
375
 
376
+ - 0.5.0 — **เขียนฝั่งแอปให้สั้นลง** (เพิ่มอย่างเดียว — ฟอร์มของ 0.4.0 ใช้ได้ทุกแบบ; Pi Notify รุ่นเก่าใช้ได้ ยกเว้น `code` ที่แกะจาก url ให้เอง)
377
+ - `notify({ to })` รับ user object `{ email }` และ array (กรอง null); ผู้รับเดี่ยวไม่มีอีเมล = `MailError` `invalid`
378
+ - `notifySafe()` / `sendMailSafe()` ไม่โยน; ไม่มี key/provider = `disabled`
379
+ - `@suphark/notify/next`: `notifyLater()` / `sendMailLater()` ส่งหลังตอบด้วย `after()` (optional peer `next >= 15`; core ไม่ import next)
380
+ - `getChannelStatus()`; `createLink()` คืน `code`
381
+ - `createPushHandlers({ getUser })` + `pushServiceWorker()` สำหรับ web push
382
+ - `renderNotifyEmail()` (+ `resolveFormat` / `toPlainText` / `looksLikeHtml`) — เทมเพลตอีเมลเดียวกับที่ Pi Notify ใช้ แอปไม่ต้องเขียน HTML "หัวข้อ + ย่อหน้า + ปุ่ม" เอง
383
+ - `getRecipient()` / `createLink()` รับ user object; `toEmails()`, `hasNotifyKey()`, type `Recipient`
384
+
385
+ - 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)
386
+
229
387
  - 0.3.1 (additive ทั้งหมด — แอปที่ตั้ง `^0.3.0` รับได้เลย, ใช้ร่วมกับ Pi Notify ที่ deploy อยู่ได้ทันที)
230
388
  - **timeout**: `notify()` 65 วิ, `getRecipient()`/`createLink()` 10 วิ (`NotifyOptions.timeoutMs`, ค่าเริ่มต้นที่ `NOTIFY_TIMEOUT_MS`) — เดิมไม่มี timeout เลย
231
389
  - **abort**: mailer หมด `timeoutMs` แล้วยกเลิก fetch ของ provider `hub` ผ่าน `NormalizedMessage.signal` (field ใหม่ optional) ไม่ปล่อยคำขอค้าง