@pplethai/cli 0.4.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.
@@ -1,36 +1,82 @@
1
- // Audience-bound push notifications.
1
+ // Push notifications: Broadcast and Direct.
2
2
  //
3
- // Your app sends *content* — a title and body, never phone numbers or user
4
- // ids — plus an optional path-only self-link. The platform resolves the
5
- // recipients server-side (this app's App Users ∩ its current publication-tier
6
- // audience), joins `linkPath` to this app's known entry, and enforces the
7
- // per-app rate limit. This is the whole privacy model: an app can reach the
8
- // people who use it, and no one else; and it can deep-link into itself, not
9
- // into another team's mini app.
3
+ // Every send states its audience, and the two kinds follow from that:
10
4
  //
11
- // See docs/integration/today-sso-changes.md §6 for the contract.
5
+ // • Broadcast (การแจ้งเตือนทุกคน) — `{ kind: "all" }`, to everyone this app
6
+ // can reach: its App Users ∩ its current publication-tier audience.
7
+ // • Direct (การแจ้งเตือนรายบุคคล) — `{ kind: "direct", recipients }`, to the
8
+ // people this app names, by PPLE ID `sub` or mobile number.
9
+ //
10
+ // Naming narrows the audience and never widens it — the rule, and what
11
+ // `not_reachable` may be read as, are in shared/api.ts. The privacy model in
12
+ // one line: an app can reach the people who use it, say which of them it
13
+ // means, and reach no one else; and it can deep-link into itself, not into
14
+ // another team's mini app.
15
+ //
16
+ // See docs/integration/direct-notifications.md for the contract and
17
+ // docs/adr/0017 for why naming recipients is safe but per-recipient *reasons*
18
+ // are not.
12
19
  //
13
20
  // Wiring: the platform provisions NOTIFY_API_BASE and this app's own
14
21
  // NotificationApiKey as NOTIFY_API_KEY, so `POST {NOTIFY_API_BASE}/notifications`
15
- // lands on PPLE Today's audience-bound send route. The key is the whole
16
- // address — PPLE Today resolves it to this app and derives the audience from
17
- // that, so the request carries neither an app id nor any recipient.
22
+ // lands on PPLE Today's send route. The key is the whole address — PPLE Today
23
+ // resolves it to this app and derives the reachable set from that, so the
24
+ // request carries no app id.
18
25
  //
19
26
  // The wire shape below is PPLE Today's, not this app's: your code passes the
20
- // friendlier `{ title, body, linkPath? }` and this is where it becomes
21
- // `{ content: { header, message }, linkPath? }`. Keeping that translation in
22
- // one place means the rest of the app never has to know the platform's
23
- // vocabulary.
24
- import type { Notification } from "../shared/api";
27
+ // friendlier `{ audience, title, body, linkPath? }` and this is where it
28
+ // becomes `{ audience, content: { header, message }, linkPath? }`. Keeping that
29
+ // translation in one place means the rest of the app never has to know the
30
+ // platform's vocabulary.
31
+ import type { Notification, NotificationDelivery, NotificationRecipient } from "../shared/api";
25
32
 
26
33
  interface NotifyEnv {
27
34
  NOTIFY_API_BASE: string;
28
35
  NOTIFY_API_KEY?: string;
29
36
  }
30
37
 
38
+ /**
39
+ * Every refusal this app can get back, named in terms a Builder can act on.
40
+ *
41
+ * - `notifications_not_configured` — no key bound (e.g. local dev).
42
+ * - `invalid_request` — the send itself was malformed: no audience, an
43
+ * unknown `kind`, a `direct` send with no `recipients` array.
44
+ * - `invalid_recipients` — the list was: empty, over the 200 cap, or an entry
45
+ * naming neither or both of `sub`/`phone`.
46
+ * - `idempotency_key_conflict` — that key was already used with a list of a
47
+ * different length, so its stored outcome cannot be replayed onto this one.
48
+ * - `quota_exceeded` — **nothing was delivered.** Quota is checked up front
49
+ * against the named count, so the call is rejected whole rather than
50
+ * half-sent.
51
+ * - `not_permitted` — this key may not use this route.
52
+ * - `send_failed` — anything else.
53
+ */
54
+ export type NotifyError =
55
+ | "notifications_not_configured"
56
+ | "invalid_request"
57
+ | "invalid_recipients"
58
+ | "idempotency_key_conflict"
59
+ | "quota_exceeded"
60
+ | "not_permitted"
61
+ | "send_failed";
62
+
63
+ /**
64
+ * What the platform reports alongside a `quota_exceeded` refusal, so an app
65
+ * can back off rather than guess. Denominated in **deliveries**, not calls: a
66
+ * Direct send debits one unit per name it addressed (delivered or not), and a
67
+ * Broadcast debits its audience size.
68
+ */
69
+ export interface NotifyQuota {
70
+ dailyQuota?: number;
71
+ remaining?: number;
72
+ /** When the budget resets (Asia/Bangkok midnight). */
73
+ resetAt?: string;
74
+ }
75
+
31
76
  export type NotifyResult =
32
- | { ok: true }
33
- | { ok: false; status: number; error: string };
77
+ | { ok: true; audience: "all" }
78
+ | { ok: true; audience: "direct"; results: NotificationDelivery[] }
79
+ | { ok: false; status: number; error: NotifyError; quota?: NotifyQuota };
34
80
 
35
81
  export async function sendNotification(
36
82
  env: NotifyEnv,
@@ -48,17 +94,99 @@ export async function sendNotification(
48
94
  authorization: `Bearer ${env.NOTIFY_API_KEY}`,
49
95
  "content-type": "application/json",
50
96
  },
51
- // Content (+ optional self-link). The audience is resolved by the platform.
52
97
  body: JSON.stringify({
98
+ // Stated on every send, including a broadcast. There is no shape of this
99
+ // request that leaves the audience to be guessed.
100
+ audience: input.audience,
53
101
  content: { header: input.title, message: input.body },
54
102
  ...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
103
+ ...(input.idempotencyKey !== undefined
104
+ ? { idempotencyKey: input.idempotencyKey }
105
+ : {}),
55
106
  }),
56
107
  });
57
108
 
58
- if (res.ok) return { ok: true };
109
+ if (!res.ok) {
110
+ const error = errorFor(res.status);
111
+ const quota = error === "quota_exceeded" ? quotaOf(await readJson(res)) : undefined;
112
+ return { ok: false, status: res.status, error, ...(quota ? { quota } : {}) };
113
+ }
114
+
115
+ if (input.audience.kind === "all") return { ok: true, audience: "all" };
59
116
  return {
60
- ok: false,
61
- status: res.status,
62
- error: res.status === 429 ? "rate_limited" : "send_failed",
117
+ ok: true,
118
+ audience: "direct",
119
+ results: zipResults(input.audience.recipients, await readJson(res)),
63
120
  };
64
121
  }
122
+
123
+ /**
124
+ * The platform splits its refusals: schema-level ones answer 422 (its whole
125
+ * API validates uniformly, and this route does not get to be the exception),
126
+ * recipient-level ones answer 400. Both are refusals — none is a broadcast,
127
+ * none is a silent no-op — but a client that branches on 400 alone is wrong.
128
+ * The status table lives in docs/integration/direct-notifications.md.
129
+ */
130
+ function errorFor(status: number): NotifyError {
131
+ switch (status) {
132
+ case 422:
133
+ return "invalid_request";
134
+ case 400:
135
+ return "invalid_recipients";
136
+ case 403:
137
+ return "not_permitted";
138
+ case 409:
139
+ return "idempotency_key_conflict";
140
+ case 429:
141
+ return "quota_exceeded";
142
+ default:
143
+ return "send_failed";
144
+ }
145
+ }
146
+
147
+ async function readJson(res: Response): Promise<unknown> {
148
+ return res.json().catch(() => null);
149
+ }
150
+
151
+ /**
152
+ * The three figures the contract says a 429 carries, read from the top level
153
+ * of the refusal body. If the platform ever nests them under an envelope, this
154
+ * is the one place to adjust — the refusal itself is reported either way.
155
+ */
156
+ function quotaOf(body: unknown): NotifyQuota | undefined {
157
+ if (typeof body !== "object" || body === null) return undefined;
158
+ const { dailyQuota, remaining, resetAt } = body as Record<string, unknown>;
159
+ const quota: NotifyQuota = {
160
+ ...(typeof dailyQuota === "number" ? { dailyQuota } : {}),
161
+ ...(typeof remaining === "number" ? { remaining } : {}),
162
+ ...(typeof resetAt === "string" ? { resetAt } : {}),
163
+ };
164
+ return Object.keys(quota).length > 0 ? quota : undefined;
165
+ }
166
+
167
+ /**
168
+ * Pair each recipient this app named with what the platform said about it.
169
+ * The results come back in the order they were named, one per entry, so this
170
+ * pairs them by position and hands back the caller's *own* entry — ready to
171
+ * match onto the list the app holds.
172
+ *
173
+ * Two rules, both in the direction of not lying: a list of a different length
174
+ * is not the answer to this send, so none of it is trusted; and anything not
175
+ * affirmatively reported `delivered` is reported `not_reachable`. See
176
+ * shared/api.ts for what that status may and may not be read as.
177
+ */
178
+ function zipResults(
179
+ recipients: readonly NotificationRecipient[],
180
+ body: unknown,
181
+ ): NotificationDelivery[] {
182
+ const reported = (body as { results?: unknown } | null)?.results;
183
+ const results =
184
+ Array.isArray(reported) && reported.length === recipients.length ? reported : [];
185
+ return recipients.map((recipient, i) => ({
186
+ recipient,
187
+ status:
188
+ (results[i] as { status?: unknown } | undefined)?.status === "delivered"
189
+ ? "delivered"
190
+ : "not_reachable",
191
+ }));
192
+ }
@@ -17,8 +17,8 @@ export interface Fakes {
17
17
  /**
18
18
  * A copy of the Worker wired to fake PPLE ID / notification services (via an
19
19
  * injected fetch) and the real local D1 + R2 from the test runtime. Records
20
- * the requests sent to the notification endpoint so tests can assert the
21
- * content-only contract.
20
+ * the requests sent to the notification endpoint so tests can assert the wire
21
+ * shape — above all that every send states its audience.
22
22
  */
23
23
  export function makeHarness(fakes: Fakes = {}, envOverrides: Partial<Env> = {}) {
24
24
  const calls = { notify: [] as Request[] };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { Note, UploadedFile } from "../shared/api";
2
+ import type { Note, Notification, UploadedFile } from "../shared/api";
3
3
  import { makeHarness } from "./harness";
4
4
 
5
5
  describe("health", () => {
@@ -174,32 +174,123 @@ describe("R2 upload (files)", () => {
174
174
  });
175
175
  });
176
176
 
177
- describe("audience-bound notifications", () => {
177
+ // Shaped like what the platform actually binds, so these tests exercise the
178
+ // real `${base}/notifications` the Worker will call in production.
179
+ const NOTIFY_ENV = {
180
+ NOTIFY_API_BASE: "https://today.test/external",
181
+ NOTIFY_API_KEY: "app-key",
182
+ };
183
+
184
+ /** A platform that accepts everything and reports every named entry delivered. */
185
+ const accepts = (body: unknown = null) => ({
186
+ notify: () => (body === null ? new Response(null, { status: 202 }) : Response.json(body)),
187
+ });
188
+
189
+ describe("notifications: the audience is required", () => {
190
+ it("is a compile error to omit it, not a runtime one", () => {
191
+ // @ts-expect-error — `audience` is required. The dangerous bug in this
192
+ // feature is omission: a dropped field turning a message meant for one
193
+ // person into a message to every App User. The discriminated union kills
194
+ // it at the compiler, which is where it is cheapest.
195
+ const missingAudience: Notification = { title: "Hi", body: "there" };
196
+ expect(missingAudience).toBeTruthy();
197
+ });
198
+
199
+ it("refuses a send with no audience before it reaches the platform", async () => {
200
+ const h = makeHarness(accepts(), NOTIFY_ENV);
201
+ const res = await h.request("/api/notify", {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({ title: "Hi", body: "there" }),
205
+ });
206
+ expect(res.status).toBe(400);
207
+ expect(await res.json()).toMatchObject({ error: "invalid_body" });
208
+ // Never a broadcast, never a silent no-op.
209
+ expect(h.calls.notify).toHaveLength(0);
210
+ });
211
+
212
+ it("refuses an empty recipient list before it reaches the platform", async () => {
213
+ const h = makeHarness(accepts(), NOTIFY_ENV);
214
+ const res = await h.request("/api/notify", {
215
+ method: "POST",
216
+ headers: { "content-type": "application/json" },
217
+ body: JSON.stringify({
218
+ audience: { kind: "direct", recipients: [] },
219
+ title: "Hi",
220
+ body: "there",
221
+ }),
222
+ });
223
+ expect(res.status).toBe(400);
224
+ expect(h.calls.notify).toHaveLength(0);
225
+ });
226
+
227
+ it("refuses more than 200 recipients rather than truncating them", async () => {
228
+ const h = makeHarness(accepts(), NOTIFY_ENV);
229
+ const res = await h.request("/api/notify", {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json" },
232
+ body: JSON.stringify({
233
+ audience: {
234
+ kind: "direct",
235
+ recipients: Array.from({ length: 201 }, (_, i) => ({ sub: `u-${i}` })),
236
+ },
237
+ title: "Hi",
238
+ body: "there",
239
+ }),
240
+ });
241
+ expect(res.status).toBe(400);
242
+ expect(h.calls.notify).toHaveLength(0);
243
+ });
244
+
245
+ it("refuses an entry naming both a sub and a phone", async () => {
246
+ const h = makeHarness(accepts(), NOTIFY_ENV);
247
+ const res = await h.request("/api/notify", {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({
251
+ audience: {
252
+ kind: "direct",
253
+ recipients: [{ sub: "u-1", phone: "0812345678" }],
254
+ },
255
+ title: "Hi",
256
+ body: "there",
257
+ }),
258
+ });
259
+ expect(res.status).toBe(400);
260
+ expect(h.calls.notify).toHaveLength(0);
261
+ });
262
+ });
263
+
264
+ describe("Broadcast Notification (การแจ้งเตือนทุกคน)", () => {
178
265
  it("reports not-configured when no key is set", async () => {
179
266
  const h = makeHarness();
180
267
  const res = await h.request("/api/notify", {
181
268
  method: "POST",
182
269
  headers: { "content-type": "application/json" },
183
- body: JSON.stringify({ title: "Hi", body: "there" }),
270
+ body: JSON.stringify({
271
+ audience: { kind: "all" },
272
+ title: "Hi",
273
+ body: "there",
274
+ }),
184
275
  });
185
276
  expect(res.status).toBe(503);
186
277
  expect(await res.json()).toMatchObject({ error: "notifications_not_configured" });
187
278
  });
188
279
 
189
- it("sends content only (never phone numbers) to the platform endpoint", async () => {
190
- const h = makeHarness(
191
- { notify: () => new Response(null, { status: 202 }) },
192
- // Shaped like what the platform actually binds, so the test exercises
193
- // the real `${base}/notifications` the Worker will call in production.
194
- { NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
195
- );
280
+ it("states its audience explicitly and names no one", async () => {
281
+ const h = makeHarness(accepts(), NOTIFY_ENV);
196
282
 
197
283
  const res = await h.request("/api/notify", {
198
284
  method: "POST",
199
285
  headers: { "content-type": "application/json" },
200
- body: JSON.stringify({ title: "Event tonight", body: "Doors at 7" }),
286
+ body: JSON.stringify({
287
+ audience: { kind: "all" },
288
+ title: "Event tonight",
289
+ body: "Doors at 7",
290
+ }),
201
291
  });
202
292
  expect(res.status).toBe(200);
293
+ expect(await res.json()).toEqual({ ok: true });
203
294
 
204
295
  expect(h.calls.notify).toHaveLength(1);
205
296
  const sent = h.calls.notify[0]!;
@@ -207,21 +298,20 @@ describe("audience-bound notifications", () => {
207
298
  // PPLE Today's wire shape, which src/notify.ts translates into.
208
299
  const payload = await sent.json<Record<string, unknown>>();
209
300
  expect(payload).toEqual({
301
+ audience: { kind: "all" },
210
302
  content: { header: "Event tonight", message: "Doors at 7" },
211
303
  });
212
- expect(JSON.stringify(payload)).not.toMatch(/phone|recipient|number/i);
304
+ expect(JSON.stringify(payload)).not.toMatch(/phone|recipient/i);
213
305
  });
214
306
 
215
307
  it("forwards optional linkPath on the platform wire shape", async () => {
216
- const h = makeHarness(
217
- { notify: () => new Response(null, { status: 202 }) },
218
- { NOTIFY_API_BASE: "https://today.test/external", NOTIFY_API_KEY: "app-key" },
219
- );
308
+ const h = makeHarness(accepts(), NOTIFY_ENV);
220
309
 
221
310
  const res = await h.request("/api/notify", {
222
311
  method: "POST",
223
312
  headers: { "content-type": "application/json" },
224
313
  body: JSON.stringify({
314
+ audience: { kind: "all" },
225
315
  title: "Check-in open",
226
316
  body: "Tap to open the form",
227
317
  linkPath: "/check-in",
@@ -231,8 +321,144 @@ describe("audience-bound notifications", () => {
231
321
 
232
322
  const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
233
323
  expect(payload).toEqual({
324
+ audience: { kind: "all" },
234
325
  content: { header: "Check-in open", message: "Tap to open the form" },
235
326
  linkPath: "/check-in",
236
327
  });
237
328
  });
238
329
  });
330
+
331
+ describe("Direct Notification (การแจ้งเตือนรายบุคคล)", () => {
332
+ const recipients = [{ sub: "u-42" }, { phone: "+66812345678" }];
333
+
334
+ const sendDirect = (h: ReturnType<typeof makeHarness>, extra: object = {}) =>
335
+ h.request("/api/notify", {
336
+ method: "POST",
337
+ headers: { "content-type": "application/json" },
338
+ body: JSON.stringify({
339
+ audience: { kind: "direct", recipients },
340
+ title: "Reimbursement approved",
341
+ body: "Your claim was approved",
342
+ ...extra,
343
+ }),
344
+ });
345
+
346
+ it("names recipients by sub or phone and reports each one's outcome", async () => {
347
+ const h = makeHarness(
348
+ accepts({
349
+ results: [
350
+ { recipient: { sub: "u-42" }, status: "delivered" },
351
+ { recipient: { phone: "+66812345678" }, status: "not_reachable" },
352
+ ],
353
+ }),
354
+ NOTIFY_ENV,
355
+ );
356
+
357
+ const res = await sendDirect(h);
358
+ expect(res.status).toBe(200);
359
+ // Every named entry comes back, echoed as the caller named it, so a
360
+ // Builder can match a result straight onto the list it holds.
361
+ expect(await res.json()).toEqual({
362
+ ok: true,
363
+ results: [
364
+ { recipient: { sub: "u-42" }, status: "delivered" },
365
+ { recipient: { phone: "+66812345678" }, status: "not_reachable" },
366
+ ],
367
+ });
368
+
369
+ const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
370
+ expect(payload).toEqual({
371
+ audience: { kind: "direct", recipients },
372
+ content: {
373
+ header: "Reimbursement approved",
374
+ message: "Your claim was approved",
375
+ },
376
+ });
377
+ });
378
+
379
+ it("forwards an idempotencyKey so a retry after a timeout is safe", async () => {
380
+ const h = makeHarness(accepts({ results: [] }), NOTIFY_ENV);
381
+ await sendDirect(h, { idempotencyKey: "claim-123" });
382
+ const payload = await h.calls.notify[0]!.json<Record<string, unknown>>();
383
+ expect(payload).toMatchObject({ idempotencyKey: "claim-123" });
384
+ });
385
+
386
+ const unconfirmed = {
387
+ ok: true,
388
+ results: [
389
+ { recipient: { sub: "u-42" }, status: "not_reachable" },
390
+ { recipient: { phone: "+66812345678" }, status: "not_reachable" },
391
+ ],
392
+ };
393
+
394
+ it("never claims a delivery the platform did not confirm", async () => {
395
+ // A body this helper cannot read is not evidence of delivery.
396
+ const h = makeHarness(accepts({ unexpected: true }), NOTIFY_ENV);
397
+ expect(await (await sendDirect(h)).json()).toEqual(unconfirmed);
398
+ });
399
+
400
+ it("trusts none of a result list that is not one-per-named-entry", async () => {
401
+ // Results are paired by position, so a list of a different length is not
402
+ // the answer to this send — reading it would attribute someone's delivery
403
+ // to the wrong person.
404
+ const h = makeHarness(
405
+ accepts({ results: [{ recipient: { sub: "u-42" }, status: "delivered" }] }),
406
+ NOTIFY_ENV,
407
+ );
408
+ expect(await (await sendDirect(h)).json()).toEqual(unconfirmed);
409
+ });
410
+ });
411
+
412
+ describe("notification refusals map to legible errors", () => {
413
+ const refuses = (status: number, body: unknown = null) => ({
414
+ notify: () =>
415
+ body === null
416
+ ? new Response(null, { status })
417
+ : Response.json(body, { status }),
418
+ });
419
+
420
+ const send = (h: ReturnType<typeof makeHarness>) =>
421
+ h.request("/api/notify", {
422
+ method: "POST",
423
+ headers: { "content-type": "application/json" },
424
+ body: JSON.stringify({
425
+ audience: { kind: "direct", recipients: [{ sub: "u-42" }] },
426
+ title: "Hi",
427
+ body: "there",
428
+ }),
429
+ });
430
+
431
+ // The platform splits schema-level refusals (422) from recipient-level ones
432
+ // (400); a client that branches on 400 alone is wrong. See the status table
433
+ // in docs/integration/direct-notifications.md.
434
+ it.each([
435
+ [422, "invalid_request"],
436
+ [400, "invalid_recipients"],
437
+ [403, "not_permitted"],
438
+ [409, "idempotency_key_conflict"],
439
+ [429, "quota_exceeded"],
440
+ [500, "send_failed"],
441
+ ])("maps %i to %s", async (status, error) => {
442
+ const h = makeHarness(refuses(status), NOTIFY_ENV);
443
+ const res = await send(h);
444
+ expect(res.status).toBe(status);
445
+ expect(await res.json()).toMatchObject({ error });
446
+ });
447
+
448
+ it("passes the quota figures through on a 429 so an app can back off", async () => {
449
+ const h = makeHarness(
450
+ refuses(429, {
451
+ // Deliveries, not calls — the budget's denomination changed with
452
+ // Direct Notifications, and nothing was delivered by this call.
453
+ dailyQuota: 1000,
454
+ remaining: 3,
455
+ resetAt: "2026-08-15T00:00:00+07:00",
456
+ }),
457
+ NOTIFY_ENV,
458
+ );
459
+ expect(await (await send(h)).json()).toMatchObject({
460
+ error: "quota_exceeded",
461
+ quota: { dailyQuota: 1000, remaining: 3, resetAt: "2026-08-15T00:00:00+07:00" },
462
+ });
463
+ });
464
+ });