@pplethai/cli 0.4.2 → 0.5.1
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/dist/bin.js +1 -1
- package/package.json +1 -1
- package/skills/pple-platform/SKILL.md +10 -7
- package/skills/pple-platform/evals/evals.json +2 -2
- package/skills/pple-platform/references/mini-app.md +67 -31
- package/skills/pple-platform/references/project-scaffold.md +1 -1
- package/templates/golden-path/CLAUDE.md +7 -3
- package/templates/golden-path/README.md +5 -2
- package/templates/golden-path/shared/api.ts +84 -3
- package/templates/golden-path/src/index.ts +14 -3
- package/templates/golden-path/src/notify.ts +152 -24
- package/templates/golden-path/test/harness.ts +2 -2
- package/templates/golden-path/test/smoke.test.ts +242 -16
- package/templates/golden-path/web/App.tsx +139 -9
package/dist/bin.js
CHANGED
|
@@ -12,7 +12,7 @@ ${t.stdout}`.split(`
|
|
|
12
12
|
`;await g.writeFile(e,`${i}${bt}
|
|
13
13
|
`)}var Te=3e3,Se=3*6e4,Re=10*6e4,Ie=20;async function Rt(t,e){let r=await y(t);if(r.apiBase!==""&&r.apiBase!==t.config.apiBase)throw new s("\u0E41\u0E2D\u0E1B\u0E19\u0E35\u0E49\u0E2D\u0E22\u0E39\u0E48\u0E1A\u0E19 PPLE Platform \u0E04\u0E19\u0E25\u0E30\u0E15\u0E31\u0E27\u0E01\u0E31\u0E1A\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A\u0E2D\u0E22\u0E39\u0E48",`\u0E41\u0E2D\u0E1B\u0E19\u0E35\u0E49\u0E16\u0E39\u0E01\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E44\u0E27\u0E49\u0E01\u0E31\u0E1A ${r.apiBase}`);let n=await p(t),i=await n.app(r.slug);if(i.status==="retired")throw new s(`"${i.name}" \u0E16\u0E39\u0E01\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27`,"\u0E41\u0E2D\u0E1B\u0E17\u0E35\u0E48\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49 \u0E2B\u0E32\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E32\u0E23\u0E2A\u0E32\u0E19\u0E07\u0E32\u0E19\u0E19\u0E35\u0E49\u0E15\u0E48\u0E2D \u0E43\u0E2B\u0E49\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B\u0E43\u0E2B\u0E21\u0E48\u0E43\u0E19\u0E04\u0E2D\u0E19\u0E42\u0E0B\u0E25");if(i.status!=="ready")throw new s(`"${i.name}" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E23\u0E31\u0E1A\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07`,i.error?`\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1E\u0E1A\u0E1B\u0E31\u0E0D\u0E2B\u0E32: ${i.error}`:"\u0E15\u0E23\u0E27\u0E08\u0E2A\u0E2D\u0E1A\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai \u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E43\u0E19\u0E2D\u0E35\u0E01\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48");let o=await n.repoToken(r.slug);if(await U(t,r.dir),await I(t,r.dir))t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u2026"),await L(t,r.dir,e.message??`\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15 ${i.name}`,o);else if(await Pt(t,r.dir,o)){t.out.say(`\u0E44\u0E21\u0E48\u0E21\u0E35\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E43\u0E2B\u0E21\u0E48 \u2014 ${i.name} \u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27\u0E17\u0E35\u0E48 ${i.url}`);return}t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2A\u0E48\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u2026"),await $t(t,r.dir,o);let u=await vt(t,r.dir);t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E34\u0E25\u0E14\u0E4C\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u2026 (\u0E1B\u0E01\u0E15\u0E34\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E25\u0E32\u0E23\u0E32\u0E27\u0E2B\u0E19\u0E36\u0E48\u0E07\u0E19\u0E32\u0E17\u0E35)");let a=await Le(t,n,r.slug,u);Oe(t,i,a)}async function Le(t,e,r,n){let i=t.now(),o;for(;;){let a=(await e.deploys(r)).find(fe=>fe.commit===n);if(a&&(o=a,a.status!=="building"))return a;if(t.now()-i>=(o?Re:Se))throw new s(o?"\u0E2A\u0E48\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E41\u0E25\u0E49\u0E27 \u0E41\u0E15\u0E48\u0E01\u0E32\u0E23\u0E1A\u0E34\u0E25\u0E14\u0E4C\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E25\u0E32\u0E19\u0E32\u0E19\u0E01\u0E27\u0E48\u0E32\u0E17\u0E35\u0E48\u0E04\u0E32\u0E14\u0E44\u0E27\u0E49":"\u0E2A\u0E48\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E41\u0E25\u0E49\u0E27 \u0E41\u0E15\u0E48\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E40\u0E23\u0E34\u0E48\u0E21\u0E1A\u0E34\u0E25\u0E14\u0E4C",`\u0E15\u0E34\u0E14\u0E15\u0E32\u0E21\u0E08\u0E19\u0E40\u0E2A\u0E23\u0E47\u0E08\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai/apps/${r}`);await t.sleep(Te)}}var Ne="superseded by a newer deploy";function Oe(t,e,r){if(r.status==="failed"){if(r.error===Ne)throw new s("\u0E21\u0E35\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E21\u0E32\u0E41\u0E17\u0E19\u0E17\u0E35\u0E48\u0E04\u0E23\u0E31\u0E49\u0E07\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E01\u0E33\u0E25\u0E31\u0E07\u0E23\u0E2D\u0E2D\u0E22\u0E39\u0E48",`\u0E15\u0E34\u0E14\u0E15\u0E32\u0E21\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E19\u0E31\u0E49\u0E19\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai/apps/${e.slug}`);let i=r.log?je(r.log,Ie):r.error;throw i&&(t.out.say(),t.out.say(i),t.out.say()),new s(`\u0E1A\u0E34\u0E25\u0E14\u0E4C ${e.name} \u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08 \u0E2A\u0E34\u0E48\u0E07\u0E17\u0E35\u0E48\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48\u0E08\u0E36\u0E07\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07`,i?"\u0E2A\u0E48\u0E07\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E14\u0E49\u0E32\u0E19\u0E1A\u0E19\u0E43\u0E2B\u0E49 AI coding agent \u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13 \u2014 \u0E43\u0E19\u0E19\u0E31\u0E49\u0E19\u0E1A\u0E2D\u0E01\u0E27\u0E48\u0E32\u0E15\u0E49\u0E2D\u0E07\u0E41\u0E01\u0E49\u0E2D\u0E30\u0E44\u0E23":`\u0E14\u0E39\u0E23\u0E32\u0E22\u0E25\u0E30\u0E40\u0E2D\u0E35\u0E22\u0E14\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 https://console.ppleth.ai/apps/${e.slug}`)}let n=r.migrations.applied??[];if(n.length>0&&t.out.say(`\u0E43\u0E0A\u0E49 migration ${n.length} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23\u0E01\u0E31\u0E1A\u0E10\u0E32\u0E19\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E41\u0E25\u0E49\u0E27`),r.status==="unverified"){t.out.say(`\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15 ${e.name} \u0E41\u0E25\u0E49\u0E27 \u0E41\u0E15\u0E48\u0E41\u0E2D\u0E1B\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E1A\u0E2A\u0E19\u0E2D\u0E07`),t.out.say(`\u0E23\u0E2D\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48 \u0E41\u0E25\u0E49\u0E27\u0E40\u0E1B\u0E34\u0E14 ${e.url}`);return}t.out.say(`${e.name} \u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E44\u0E14\u0E49\u0E41\u0E25\u0E49\u0E27\u0E17\u0E35\u0E48 ${e.url}`)}function je(t,e){return t.split(`
|
|
14
14
|
`).slice(-e).join(`
|
|
15
|
-
`).trim()}import Be from"node:fs/promises";import Ue from"node:path";async function It(t){let e=await y(t),n=await(await p(t)).env(e.slug);if(n.length===0){t.out.say("\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46"),t.out.say("\u0E15\u0E31\u0E27\u0E41\u0E1B\u0E23\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E31\u0E49\u0E07\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B");return}for(let i of n)i.secret?t.out.say(`${i.key}=(secret)`):t.out.say(`${i.key}=${i.value??""}`);t.out.say(""),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Lt(t,e){let r=await y(t),n=await Me(t,e),o=await(await p(t)).setEnv(r.slug,e.key,n,e.secret);t.out.say(`\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01 ${o.key}${o.secret?" (secret)":""} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Nt(t,e){let r=await y(t);await(await p(t)).deleteEnv(r.slug,e),t.out.say(`\u0E25\u0E1A ${e} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Me(t,e){if(e.file!==void 0&&e.value!==void 0)throw new s("\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E2B\u0E23\u0E37\u0E2D\u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2D\u0E22\u0E48\u0E32\u0E07\u0E43\u0E14\u0E2D\u0E22\u0E48\u0E32\u0E07\u0E2B\u0E19\u0E36\u0E48\u0E07 \u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E17\u0E31\u0E49\u0E07\u0E2A\u0E2D\u0E07\u0E2D\u0E22\u0E48\u0E32\u0E07");if(e.file!==void 0){let r=Ue.resolve(t.cwd,e.file),n=await Be.readFile(r,"utf8").catch(()=>null);if(n===null)throw new s(`\u0E2D\u0E48\u0E32\u0E19\u0E44\u0E1F\u0E25\u0E4C ${e.file} \u0E44\u0E21\u0E48\u0E44\u0E14\u0E49`,"\u0E15\u0E23\u0E27\u0E08\u0E2A\u0E2D\u0E1A\u0E1E\u0E32\u0E18\u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07");return n}if(e.value!==void 0)return e.value;if(t.interactive)throw new s("`pple env set` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E04\u0E48\u0E32","\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2B\u0E23\u0E37\u0E2D pipe \u0E17\u0E32\u0E07 stdin");return t.readStdin()}var Fe=20,Ge=/[\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]/g;function l(t,e){let r=t.replace(Ge,"").length;return t+" ".repeat(Math.max(0,e-r))}var Ke={ready:"\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19",provisioning:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E08\u0E31\u0E14\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27",retired:"\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27"},Ot={draft:"\u0E09\u0E1A\u0E31\u0E1A\u0E23\u0E48\u0E32\u0E07",beta:"\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",live:"\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07"},He={owner:"\u0E40\u0E08\u0E49\u0E32\u0E02\u0E2D\u0E07",collaborator:"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"},jt={live:"\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48",building:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E34\u0E25\u0E14\u0E4C",unverified:"\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27"},Ve={pending:"\u0E23\u0E2D\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23",approved:"\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E41\u0E25\u0E49\u0E27",denied:"\u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18\u0E41\u0E25\u0E49\u0E27"};function v(t,e){return t[e]??e}async function Bt(t,e){let r=await Je(t,e),n=await p(t),i=await n.app(r),o=i.role!=="collaborator",[u,a,c,P]=await Promise.all([n.metrics(r),n.deploys(r),n.env(r),o?Ye(n,r):Promise.resolve(null)]);We(t,i),Xe(t,u),ze(t,a),Ze(t,c),P&&(Qe(t,i,P.requests,P.invites),tr(t,P.collaborators),er(t,P.requests)),t.out.say(""),t.out.say("\u0E2B\u0E32\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E42\u0E04\u0E27\u0E15\u0E32 \u0E04\u0E48\u0E32 env \u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32 \u0E2B\u0E23\u0E37\u0E2D\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \u0E43\u0E0A\u0E49 https://console.ppleth.ai")}async function Je(t,e){if(e)return e;try{return(await y(t)).slug}catch(r){throw r instanceof s&&r.message==="\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E41\u0E2D\u0E1B PPLE"?new s("\u0E41\u0E2D\u0E1B\u0E44\u0E2B\u0E19? `pple info` \u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 App Link \u0E2B\u0E23\u0E37\u0E2D slug","\u0E40\u0E02\u0E49\u0E32\u0E44\u0E1B\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B \u0E43\u0E2A\u0E48 slug (`pple info <slug>`) \u0E2B\u0E23\u0E37\u0E2D\u0E40\u0E1B\u0E34\u0E14 https://console.ppleth.ai"):r}}async function Ye(t,e){let[r,n,i]=await Promise.all([t.collaborators(e),t.requests(e),t.invites(e)]);return{collaborators:r,requests:n,invites:i}}function We(t,e){t.out.say(e.name),t.out.say(` ${l("slug",18)}${e.slug}`),t.out.say(` ${l("URL",18)}${e.url}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`),t.out.say(` ${l("\u0E2A\u0E16\u0E32\u0E19\u0E30",18)}${v(Ke,e.status)}`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48",18)}${v(Ot,e.tier??"draft")}`),e.role&&t.out.say(` ${l("\u0E1A\u0E17\u0E1A\u0E32\u0E17\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",18)}${v(He,e.role)}`),e.deploy&&t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E1B\u0E31\u0E08\u0E08\u0E38\u0E1A\u0E31\u0E19",18)}${v(jt,e.deploy.status)}`+(e.deploy.message?` \u2014 ${e.deploy.message}`:"")),e.error&&t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14",18)}${e.error}`)}function Xe(t,e){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E41\u0E25\u0E30\u0E42\u0E04\u0E27\u0E15\u0E32\u0E17\u0E23\u0E31\u0E1E\u0E22\u0E32\u0E01\u0E23"),t.out.say(` ${l("CPU \u0E15\u0E48\u0E2D request",20)}${e.limits.cpuMs} ms`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19",20)}${_(e.usage.notificationsSent)}`+(e.usage.notificationsSent===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":` \u0E08\u0E32\u0E01\u0E42\u0E04\u0E27\u0E15\u0E32 ${_(e.limits.notificationsPerDay)} \u0E04\u0E23\u0E31\u0E49\u0E07\u0E27\u0E31\u0E19\u0E19\u0E35\u0E49`)),t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14 (24 \u0E0A\u0E21.)",20)}${_(e.usage.errors)}`+(e.usage.errors===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":e.usage.errors===0?" (\u0E44\u0E21\u0E48\u0E21\u0E35\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":e.usage.requests===null?" (\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":` \u0E08\u0E32\u0E01\u0E17\u0E31\u0E49\u0E07\u0E2B\u0E21\u0E14 ${_(e.usage.requests)} \u0E04\u0E33\u0E02\u0E2D`)),t.out.say(` ${l("\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E2D\u0E1B",20)}${_(e.usage.appUsers)}`+(e.usage.appUsers===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":""))}function _(t){return t===null?"\u2014":String(t)}function ze(t,e){if(t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.commit.slice(0,7),i=r.message?` \u2014 ${r.message}`:"",o=v(jt,r.status);t.out.say(` ${l(o,13)} ${n}${i}`)}}function Ze(t,e){if(t.out.say(""),t.out.say("\u0E04\u0E48\u0E32 env"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46");return}for(let r of e)r.secret?t.out.say(` ${r.key}=(secret)`):t.out.say(` ${r.key}=${r.value??""}`)}function Qe(t,e,r,n){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48"),t.out.say(` ${l("\u0E23\u0E30\u0E14\u0E31\u0E1A",18)}${v(Ot,e.tier??"draft")}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`);let i=r.find(o=>o.type==="publish"&&o.status==="pending");if(i&&t.out.say(` ${l("\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E23\u0E2D\u0E2D\u0E22\u0E39\u0E48",18)}${Ut(i)}`),e.tier==="beta"){let o=n.filter(u=>u.status!=="DECLINED").length;t.out.say(` ${l("\u0E04\u0E33\u0E40\u0E0A\u0E34\u0E0D\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",18)}\u0E43\u0E0A\u0E49\u0E44\u0E1B ${o} \u0E08\u0E32\u0E01 ${Fe} \u0E17\u0E35\u0E48\u0E19\u0E31\u0E48\u0E07`)}}function tr(t,e){if(t.out.say(""),t.out.say("\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e){let n=r.maskedName??"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32",i=r.phoneNumber?` (${r.phoneNumber})`:"";t.out.say(` ${n}${i}`)}}function er(t,e){if(t.out.say(""),t.out.say("\u0E1B\u0E23\u0E30\u0E27\u0E31\u0E15\u0E34\u0E04\u0E33\u0E02\u0E2D"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.reason?` \u2014 ${r.reason}`:"",i=v(Ve,r.status);t.out.say(` ${l(i,13)} ${Ut(r)}${n}`)}}function Ut(t){if(t.type==="publish"){let r=Array.isArray(t.payload.roles)?t.payload.roles:[],n=typeof t.payload.customAddress=="string"?` \xB7 \u0E17\u0E35\u0E48 ${t.payload.customAddress}`:"";return`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \xB7 \u0E02\u0E36\u0E49\u0E19\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07 \xB7 ${r.length===0?"\u0E17\u0E38\u0E01\u0E04\u0E19":r.join(", ")}${n}`}if(t.type==="retire")return"\u0E04\u0E33\u0E02\u0E2D\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07 \xB7 \u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E2D\u0E1B";let e=[];return typeof t.payload.cpuMs=="number"&&e.push(`CPU ${t.payload.cpuMs} ms/request`),typeof t.payload.notificationsPerDay=="number"&&e.push(`\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19 ${t.payload.notificationsPerDay} \u0E04\u0E23\u0E31\u0E49\u0E07/\u0E27\u0E31\u0E19`),`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E42\u0E04\u0E27\u0E15\u0E32 \xB7 \u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21 ${e.length>0?e.join(" \u0E41\u0E25\u0E30 "):"\u0E42\u0E04\u0E27\u0E15\u0E32"}`}import Kt from"node:fs/promises";import b from"node:path";import E from"node:fs/promises";import Mt from"node:path";var rr=new Set([".git","node_modules","dist",".wrangler",".dev.vars",".env"]),nr=new Map([["gitignore",".gitignore"]]),ir="pple-app",or=new Set(["package.json","package-lock.json","wrangler.jsonc"]);async function qt(t,e,r){if(await E.readdir(t).catch(()=>null)===null)throw new s("\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E41\u0E2D\u0E1B PPLE \u0E43\u0E19\u0E01\u0E32\u0E23\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49",`\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E43\u0E2B\u0E21\u0E48 (\`${m}\`) \u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07`);let i=[];return await Ft({from:t,to:e,prefix:"",slug:r,written:i}),i.sort()}async function Ft(t){for(let e of await E.readdir(t.from,{withFileTypes:!0})){if(rr.has(e.name))continue;let r=nr.get(e.name)??e.name,n=Mt.join(t.from,e.name),i=Mt.join(t.to,r),o=t.prefix===""?r:`${t.prefix}/${r}`;if(e.isDirectory()){await E.mkdir(i,{recursive:!0}),await Ft({...t,from:n,to:i,prefix:o});continue}if(or.has(r)){let u=await E.readFile(n,"utf8");await E.writeFile(i,sr(u,ir,t.slug))}else await E.copyFile(n,i);t.written.push(o)}}function sr(t,e,r){return t.split(e).join(r)}var ar=3e3,ur=5*6e4;async function Ht(t,e){let r=await p(t),n=e.slug?await r.app(e.slug):await pr(t,r,await cr(t,e.name));if(!n.repo)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E44\u0E21\u0E48\u0E40\u0E2A\u0E23\u0E47\u0E08`,`\u0E23\u0E2D\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 \`pple init ${n.slug}\` \u0E2B\u0E32\u0E01\u0E22\u0E31\u0E07\u0E40\u0E1B\u0E47\u0E19\u0E41\u0E1A\u0E1A\u0E19\u0E35\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`);n.status==="provisioning"&&t.out.say("\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026");let i=b.resolve(t.cwd,e.dir??n.slug);await mr(i);let o=await r.repoToken(n.slug);if(t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E43\u0E19 ${b.basename(i)}/\u2026`),await ht(t,fr(t,n.repo),i,o),await gr(i))throw new s(`"${n.name}" \u0E21\u0E35\u0E42\u0E04\u0E49\u0E14\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27 \u0E08\u0E36\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E30\u0E44\u0E23\u0E15\u0E49\u0E2D\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32`,"\u0E17\u0E33\u0E07\u0E32\u0E19\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E31\u0E49\u0E19\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 `pple deploy` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E19\u0E33\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07");await qt(t.templateDir,i,n.slug),await Dt(i,{slug:n.slug,apiBase:t.config.apiBase,url:n.url}),await U(t,i),await I(t,i)&&await L(t,i,"\u0E40\u0E23\u0E34\u0E48\u0E21\u0E08\u0E32\u0E01\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E21\u0E32\u0E15\u0E23\u0E10\u0E32\u0E19\u0E02\u0E2D\u0E07 PPLE",o);let u=await lr(t,i);dr(t,n,i,u)}async function lr(t,e){t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E41\u0E1E\u0E47\u0E01\u0E40\u0E01\u0E08\u0E41\u0E25\u0E30\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21\u0E10\u0E32\u0E19\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u2026");let r=[["npm",["install"]],["npm",["run","db:migrate:local"]]];for(let[n,i]of r){let o=await t.run(n,i,{cwd:e}).catch(()=>null);if(!o||o.code!==0)return!1}return!0}async function cr(t,e){if(await V(t))throw new s("\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E01\u0E31\u0E1A\u0E41\u0E2D\u0E1B PPLE \u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27","\u0E23\u0E31\u0E19 `pple info` \u0E2B\u0E23\u0E37\u0E2D `pple deploy` \u0E17\u0E35\u0E48\u0E19\u0E35\u0E48 \u0E2B\u0E23\u0E37\u0E2D\u0E22\u0E49\u0E32\u0E22\u0E44\u0E1B\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B\u0E43\u0E2B\u0E21\u0E48");if(e!==void 0){let n=e.trim();if(!n)throw new s("`--name` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E15\u0E31\u0E27\u0E2D\u0E22\u0E48\u0E32\u0E07: `pple init --name "\u0E25\u0E07\u0E17\u0E30\u0E40\u0E1A\u0E35\u0E22\u0E19\u0E2D\u0E32\u0E2A\u0E32\u0E2A\u0E21\u0E31\u0E04\u0E23"`');return n}if(!t.interactive)throw new s("`pple init` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"` \u0E2B\u0E23\u0E37\u0E2D\u0E23\u0E31\u0E19\u0E43\u0E19\u0E40\u0E17\u0E2D\u0E23\u0E4C\u0E21\u0E34\u0E19\u0E31\u0E25\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E43\u0E2B\u0E49\u0E23\u0E30\u0E1A\u0E1A\u0E16\u0E32\u0E21');let r=(await t.ask("\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B:")).trim();if(!r)throw new s("\u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B",'\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"`');return r}async function pr(t,e,r){t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2A\u0E23\u0E49\u0E32\u0E07 "${r}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026`);let n=await e.createApp(r);if(Gt(n))return n;t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E23\u0E2D\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E2B\u0E49\u0E40\u0E2A\u0E23\u0E47\u0E08\u2026");let i=t.now();for(;;){if(n.status==="failed")throw new s(`\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08`,n.error?`${n.error} slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`:`slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`);if(t.now()-i>=ur)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`,`\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E14\u0E49\u0E27\u0E22 \`pple init ${n.slug}\` \u0E43\u0E19\u0E2D\u0E35\u0E01\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48`);if(await t.sleep(ar),n=await e.app(n.slug),Gt(n))return n}}function Gt(t){return t.status==="ready"&&t.repo!==null}function fr(t,e){return`${t.config.repoBase}/${e}.git`}async function mr(t){let e=await Kt.readdir(t).catch(()=>null);if(e!==null&&e.length>0)throw new s(`\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C ${b.basename(t)}/ \u0E21\u0E35\u0E44\u0E1F\u0E25\u0E4C\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27`,"\u0E23\u0E31\u0E19 `pple init` \u0E17\u0E35\u0E48\u0E2D\u0E37\u0E48\u0E19 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--dir <folder>` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19")}async function gr(t){return Kt.access(b.join(t,"package.json")).then(()=>!0).catch(()=>!1)}function dr(t,e,r,n){let i=b.basename(r);t.out.say(),t.out.say(`"${e.name}" \u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E2B\u0E49\u0E17\u0E33\u0E07\u0E32\u0E19\u0E41\u0E25\u0E49\u0E27\u0E43\u0E19 ${i}/`),t.out.say(`\u0E41\u0E2D\u0E1B\u0E08\u0E30\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 ${e.url}`),t.out.say(),t.out.say("\u0E02\u0E31\u0E49\u0E19\u0E15\u0E48\u0E2D\u0E44\u0E1B:"),t.out.say(` cd ${i}`),n||(t.out.say(" npm install"),t.out.say(" npm run db:migrate:local")),t.out.say(" npm run dev # \u0E14\u0E39\u0E1C\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u0E19\u0E35\u0E49"),t.out.say(" pple deploy # \u0E19\u0E33\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07")}import W from"node:fs/promises";import X from"node:path";var yr=1440*60*1e3,Vt="update-check.json",wr="config.json",hr=new Set(["upgrade","help","--help","-h"]);function Jt(t){return t.length>0&&!hr.has(t[0])}function z(t,e){t.out.say(`\u0E21\u0E35 pple \u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19\u0E43\u0E2B\u0E21\u0E48 (${e}) \u0E04\u0E38\u0E13\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 ${t.version}`)}function Z(t){t.out.say("pple \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E1C\u0E48\u0E32\u0E19 npm \u0E01\u0E32\u0E23\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E17\u0E35\u0E48\u0E19\u0E35\u0E48\u0E2D\u0E32\u0E08\u0E17\u0E33\u0E43\u0E2B\u0E49\u0E04\u0E38\u0E13"),t.out.say("\u0E21\u0E35 pple \u0E2A\u0E2D\u0E07\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E14\u0E49\u0E27\u0E22\u0E27\u0E34\u0E18\u0E35\u0E40\u0E14\u0E34\u0E21\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E44\u0E27\u0E49"),t.out.say(`\u0E2B\u0E23\u0E37\u0E2D\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E14\u0E49\u0E27\u0E22 npm:
|
|
15
|
+
`).trim()}import Be from"node:fs/promises";import Ue from"node:path";async function It(t){let e=await y(t),n=await(await p(t)).env(e.slug);if(n.length===0){t.out.say("\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46"),t.out.say("\u0E15\u0E31\u0E27\u0E41\u0E1B\u0E23\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E31\u0E49\u0E07\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B");return}for(let i of n)i.secret?t.out.say(`${i.key}=(secret)`):t.out.say(`${i.key}=${i.value??""}`);t.out.say(""),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Lt(t,e){let r=await y(t),n=await Me(t,e),o=await(await p(t)).setEnv(r.slug,e.key,n,e.secret);t.out.say(`\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01 ${o.key}${o.secret?" (secret)":""} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Nt(t,e){let r=await y(t);await(await p(t)).deleteEnv(r.slug,e),t.out.say(`\u0E25\u0E1A ${e} \u0E41\u0E25\u0E49\u0E27`),t.out.say("\u0E08\u0E30\u0E21\u0E35\u0E1C\u0E25\u0E43\u0E19\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E04\u0E23\u0E31\u0E49\u0E07\u0E16\u0E31\u0E14\u0E44\u0E1B")}async function Me(t,e){if(e.file!==void 0&&e.value!==void 0)throw new s("\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E2B\u0E23\u0E37\u0E2D\u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2D\u0E22\u0E48\u0E32\u0E07\u0E43\u0E14\u0E2D\u0E22\u0E48\u0E32\u0E07\u0E2B\u0E19\u0E36\u0E48\u0E07 \u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E17\u0E31\u0E49\u0E07\u0E2A\u0E2D\u0E07\u0E2D\u0E22\u0E48\u0E32\u0E07");if(e.file!==void 0){let r=Ue.resolve(t.cwd,e.file),n=await Be.readFile(r,"utf8").catch(()=>null);if(n===null)throw new s(`\u0E2D\u0E48\u0E32\u0E19\u0E44\u0E1F\u0E25\u0E4C ${e.file} \u0E44\u0E21\u0E48\u0E44\u0E14\u0E49`,"\u0E15\u0E23\u0E27\u0E08\u0E2A\u0E2D\u0E1A\u0E1E\u0E32\u0E18\u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07");return n}if(e.value!==void 0)return e.value;if(t.interactive)throw new s("`pple env set` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E04\u0E48\u0E32","\u0E2A\u0E48\u0E07\u0E04\u0E48\u0E32\u0E21\u0E32\u0E40\u0E1B\u0E47\u0E19 argument \u0E1C\u0E48\u0E32\u0E19 `--file` \u0E2B\u0E23\u0E37\u0E2D pipe \u0E17\u0E32\u0E07 stdin");return t.readStdin()}var Fe=20,Ge=/[\u0E31\u0E34-\u0E3A\u0E47-\u0E4E]/g;function l(t,e){let r=t.replace(Ge,"").length;return t+" ".repeat(Math.max(0,e-r))}var Ke={ready:"\u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19",provisioning:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E08\u0E31\u0E14\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27",retired:"\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E25\u0E49\u0E27"},Ot={draft:"\u0E09\u0E1A\u0E31\u0E1A\u0E23\u0E48\u0E32\u0E07",beta:"\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",live:"\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07"},He={owner:"\u0E40\u0E08\u0E49\u0E32\u0E02\u0E2D\u0E07",collaborator:"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"},jt={live:"\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E2D\u0E22\u0E39\u0E48",building:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E1A\u0E34\u0E25\u0E14\u0E4C",unverified:"\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E22\u0E37\u0E19\u0E22\u0E31\u0E19",failed:"\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27"},Ve={pending:"\u0E23\u0E2D\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23",approved:"\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E41\u0E25\u0E49\u0E27",denied:"\u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18\u0E41\u0E25\u0E49\u0E27"};function v(t,e){return t[e]??e}async function Bt(t,e){let r=await Je(t,e),n=await p(t),i=await n.app(r),o=i.role!=="collaborator",[u,a,c,P]=await Promise.all([n.metrics(r),n.deploys(r),n.env(r),o?Ye(n,r):Promise.resolve(null)]);We(t,i),Xe(t,u),ze(t,a),Ze(t,c),P&&(Qe(t,i,P.requests,P.invites),tr(t,P.collaborators),er(t,P.requests)),t.out.say(""),t.out.say("\u0E2B\u0E32\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E42\u0E04\u0E27\u0E15\u0E32 \u0E04\u0E48\u0E32 env \u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32 \u0E2B\u0E23\u0E37\u0E2D\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \u0E43\u0E0A\u0E49 https://console.ppleth.ai")}async function Je(t,e){if(e)return e;try{return(await y(t)).slug}catch(r){throw r instanceof s&&r.message==="\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E41\u0E2D\u0E1B PPLE"?new s("\u0E41\u0E2D\u0E1B\u0E44\u0E2B\u0E19? `pple info` \u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 App Link \u0E2B\u0E23\u0E37\u0E2D slug","\u0E40\u0E02\u0E49\u0E32\u0E44\u0E1B\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B \u0E43\u0E2A\u0E48 slug (`pple info <slug>`) \u0E2B\u0E23\u0E37\u0E2D\u0E40\u0E1B\u0E34\u0E14 https://console.ppleth.ai"):r}}async function Ye(t,e){let[r,n,i]=await Promise.all([t.collaborators(e),t.requests(e),t.invites(e)]);return{collaborators:r,requests:n,invites:i}}function We(t,e){t.out.say(e.name),t.out.say(` ${l("slug",18)}${e.slug}`),t.out.say(` ${l("URL",18)}${e.url}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`),t.out.say(` ${l("\u0E2A\u0E16\u0E32\u0E19\u0E30",18)}${v(Ke,e.status)}`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48",18)}${v(Ot,e.tier??"draft")}`),e.role&&t.out.say(` ${l("\u0E1A\u0E17\u0E1A\u0E32\u0E17\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13",18)}${v(He,e.role)}`),e.deploy&&t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E1B\u0E31\u0E08\u0E08\u0E38\u0E1A\u0E31\u0E19",18)}${v(jt,e.deploy.status)}`+(e.deploy.message?` \u2014 ${e.deploy.message}`:"")),e.error&&t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14",18)}${e.error}`)}function Xe(t,e){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E41\u0E25\u0E30\u0E42\u0E04\u0E27\u0E15\u0E32\u0E17\u0E23\u0E31\u0E1E\u0E22\u0E32\u0E01\u0E23"),t.out.say(` ${l("CPU \u0E15\u0E48\u0E2D request",20)}${e.limits.cpuMs} ms`),t.out.say(` ${l("\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E16\u0E36\u0E07\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A",20)}${_(e.usage.notificationsSent)}`+(e.usage.notificationsSent===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":` \u0E23\u0E32\u0E22 \u0E08\u0E32\u0E01\u0E42\u0E04\u0E27\u0E15\u0E32 ${_(e.limits.notificationsPerDay)} \u0E23\u0E32\u0E22/\u0E27\u0E31\u0E19`)),t.out.say(` ${l("\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\u0E25\u0E32\u0E14 (24 \u0E0A\u0E21.)",20)}${_(e.usage.errors)}`+(e.usage.errors===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":e.usage.errors===0?" (\u0E44\u0E21\u0E48\u0E21\u0E35\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":e.usage.requests===null?" (\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E25\u0E49\u0E21\u0E40\u0E2B\u0E25\u0E27\u0E43\u0E19 24 \u0E0A\u0E31\u0E48\u0E27\u0E42\u0E21\u0E07\u0E17\u0E35\u0E48\u0E1C\u0E48\u0E32\u0E19\u0E21\u0E32)":` \u0E08\u0E32\u0E01\u0E17\u0E31\u0E49\u0E07\u0E2B\u0E21\u0E14 ${_(e.usage.requests)} \u0E04\u0E33\u0E02\u0E2D`)),t.out.say(` ${l("\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E2D\u0E1B",20)}${_(e.usage.appUsers)}`+(e.usage.appUsers===null?" (\u0E14\u0E39\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E43\u0E19\u0E02\u0E13\u0E30\u0E19\u0E35\u0E49)":""))}function _(t){return t===null?"\u2014":String(t)}function ze(t,e){if(t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E14\u0E35\u0E1E\u0E25\u0E2D\u0E22\u0E25\u0E48\u0E32\u0E2A\u0E38\u0E14"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.commit.slice(0,7),i=r.message?` \u2014 ${r.message}`:"",o=v(jt,r.status);t.out.say(` ${l(o,13)} ${n}${i}`)}}function Ze(t,e){if(t.out.say(""),t.out.say("\u0E04\u0E48\u0E32 env"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E14 \u0E46");return}for(let r of e)r.secret?t.out.say(` ${r.key}=(secret)`):t.out.say(` ${r.key}=${r.value??""}`)}function Qe(t,e,r,n){t.out.say(""),t.out.say("\u0E01\u0E32\u0E23\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48"),t.out.say(` ${l("\u0E23\u0E30\u0E14\u0E31\u0E1A",18)}${v(Ot,e.tier??"draft")}`),e.customAddress&&t.out.say(` ${l("\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E40\u0E2D\u0E07",18)}${e.customAddress}`);let i=r.find(o=>o.type==="publish"&&o.status==="pending");if(i&&t.out.say(` ${l("\u0E04\u0E33\u0E02\u0E2D\u0E17\u0E35\u0E48\u0E23\u0E2D\u0E2D\u0E22\u0E39\u0E48",18)}${Ut(i)}`),e.tier==="beta"){let o=n.filter(u=>u.status!=="DECLINED").length;t.out.say(` ${l("\u0E04\u0E33\u0E40\u0E0A\u0E34\u0E0D\u0E17\u0E14\u0E25\u0E2D\u0E07\u0E43\u0E0A\u0E49",18)}\u0E43\u0E0A\u0E49\u0E44\u0E1B ${o} \u0E08\u0E32\u0E01 ${Fe} \u0E17\u0E35\u0E48\u0E19\u0E31\u0E48\u0E07`)}}function tr(t,e){if(t.out.say(""),t.out.say("\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e){let n=r.maskedName??"\u0E1C\u0E39\u0E49\u0E23\u0E48\u0E27\u0E21\u0E1E\u0E31\u0E12\u0E19\u0E32",i=r.phoneNumber?` (${r.phoneNumber})`:"";t.out.say(` ${n}${i}`)}}function er(t,e){if(t.out.say(""),t.out.say("\u0E1B\u0E23\u0E30\u0E27\u0E31\u0E15\u0E34\u0E04\u0E33\u0E02\u0E2D"),e.length===0){t.out.say(" \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35");return}for(let r of e.slice(0,10)){let n=r.reason?` \u2014 ${r.reason}`:"",i=v(Ve,r.status);t.out.say(` ${l(i,13)} ${Ut(r)}${n}`)}}function Ut(t){if(t.type==="publish"){let r=Array.isArray(t.payload.roles)?t.payload.roles:[],n=typeof t.payload.customAddress=="string"?` \xB7 \u0E17\u0E35\u0E48 ${t.payload.customAddress}`:"";return`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48 \xB7 \u0E02\u0E36\u0E49\u0E19\u0E40\u0E1C\u0E22\u0E41\u0E1E\u0E23\u0E48\u0E08\u0E23\u0E34\u0E07 \xB7 ${r.length===0?"\u0E17\u0E38\u0E01\u0E04\u0E19":r.join(", ")}${n}`}if(t.type==="retire")return"\u0E04\u0E33\u0E02\u0E2D\u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07 \xB7 \u0E1B\u0E25\u0E14\u0E23\u0E30\u0E27\u0E32\u0E07\u0E41\u0E2D\u0E1B";let e=[];return typeof t.payload.cpuMs=="number"&&e.push(`CPU ${t.payload.cpuMs} ms/request`),typeof t.payload.notificationsPerDay=="number"&&e.push(`\u0E01\u0E32\u0E23\u0E41\u0E08\u0E49\u0E07\u0E40\u0E15\u0E37\u0E2D\u0E19\u0E16\u0E36\u0E07\u0E1C\u0E39\u0E49\u0E23\u0E31\u0E1A ${t.payload.notificationsPerDay} \u0E23\u0E32\u0E22/\u0E27\u0E31\u0E19`),`\u0E04\u0E33\u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E42\u0E04\u0E27\u0E15\u0E32 \xB7 \u0E02\u0E2D\u0E40\u0E1E\u0E34\u0E48\u0E21 ${e.length>0?e.join(" \u0E41\u0E25\u0E30 "):"\u0E42\u0E04\u0E27\u0E15\u0E32"}`}import Kt from"node:fs/promises";import b from"node:path";import E from"node:fs/promises";import Mt from"node:path";var rr=new Set([".git","node_modules","dist",".wrangler",".dev.vars",".env"]),nr=new Map([["gitignore",".gitignore"]]),ir="pple-app",or=new Set(["package.json","package-lock.json","wrangler.jsonc"]);async function qt(t,e,r){if(await E.readdir(t).catch(()=>null)===null)throw new s("\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E41\u0E2D\u0E1B PPLE \u0E43\u0E19\u0E01\u0E32\u0E23\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49",`\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07 CLI \u0E43\u0E2B\u0E21\u0E48 (\`${m}\`) \u0E41\u0E25\u0E49\u0E27\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07`);let i=[];return await Ft({from:t,to:e,prefix:"",slug:r,written:i}),i.sort()}async function Ft(t){for(let e of await E.readdir(t.from,{withFileTypes:!0})){if(rr.has(e.name))continue;let r=nr.get(e.name)??e.name,n=Mt.join(t.from,e.name),i=Mt.join(t.to,r),o=t.prefix===""?r:`${t.prefix}/${r}`;if(e.isDirectory()){await E.mkdir(i,{recursive:!0}),await Ft({...t,from:n,to:i,prefix:o});continue}if(or.has(r)){let u=await E.readFile(n,"utf8");await E.writeFile(i,sr(u,ir,t.slug))}else await E.copyFile(n,i);t.written.push(o)}}function sr(t,e,r){return t.split(e).join(r)}var ar=3e3,ur=5*6e4;async function Ht(t,e){let r=await p(t),n=e.slug?await r.app(e.slug):await pr(t,r,await cr(t,e.name));if(!n.repo)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E44\u0E21\u0E48\u0E40\u0E2A\u0E23\u0E47\u0E08`,`\u0E23\u0E2D\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 \`pple init ${n.slug}\` \u0E2B\u0E32\u0E01\u0E22\u0E31\u0E07\u0E40\u0E1B\u0E47\u0E19\u0E41\u0E1A\u0E1A\u0E19\u0E35\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`);n.status==="provisioning"&&t.out.say("\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13\u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026");let i=b.resolve(t.cwd,e.dir??n.slug);await mr(i);let o=await r.repoToken(n.slug);if(t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E43\u0E19 ${b.basename(i)}/\u2026`),await ht(t,fr(t,n.repo),i,o),await gr(i))throw new s(`"${n.name}" \u0E21\u0E35\u0E42\u0E04\u0E49\u0E14\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27 \u0E08\u0E36\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2D\u0E30\u0E44\u0E23\u0E15\u0E49\u0E2D\u0E07\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32`,"\u0E17\u0E33\u0E07\u0E32\u0E19\u0E43\u0E19\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E31\u0E49\u0E19\u0E41\u0E25\u0E49\u0E27\u0E23\u0E31\u0E19 `pple deploy` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E19\u0E33\u0E01\u0E32\u0E23\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E41\u0E1B\u0E25\u0E07\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07");await qt(t.templateDir,i,n.slug),await Dt(i,{slug:n.slug,apiBase:t.config.apiBase,url:n.url}),await U(t,i),await I(t,i)&&await L(t,i,"\u0E40\u0E23\u0E34\u0E48\u0E21\u0E08\u0E32\u0E01\u0E40\u0E17\u0E21\u0E40\u0E1E\u0E25\u0E15\u0E21\u0E32\u0E15\u0E23\u0E10\u0E32\u0E19\u0E02\u0E2D\u0E07 PPLE",o);let u=await lr(t,i);dr(t,n,i,u)}async function lr(t,e){t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E41\u0E1E\u0E47\u0E01\u0E40\u0E01\u0E08\u0E41\u0E25\u0E30\u0E40\u0E15\u0E23\u0E35\u0E22\u0E21\u0E10\u0E32\u0E19\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u2026");let r=[["npm",["install"]],["npm",["run","db:migrate:local"]]];for(let[n,i]of r){let o=await t.run(n,i,{cwd:e}).catch(()=>null);if(!o||o.code!==0)return!1}return!0}async function cr(t,e){if(await V(t))throw new s("\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E19\u0E35\u0E49\u0E40\u0E0A\u0E37\u0E48\u0E2D\u0E21\u0E01\u0E31\u0E1A\u0E41\u0E2D\u0E1B PPLE \u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27","\u0E23\u0E31\u0E19 `pple info` \u0E2B\u0E23\u0E37\u0E2D `pple deploy` \u0E17\u0E35\u0E48\u0E19\u0E35\u0E48 \u0E2B\u0E23\u0E37\u0E2D\u0E22\u0E49\u0E32\u0E22\u0E44\u0E1B\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B\u0E43\u0E2B\u0E21\u0E48");if(e!==void 0){let n=e.trim();if(!n)throw new s("`--name` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E15\u0E31\u0E27\u0E2D\u0E22\u0E48\u0E32\u0E07: `pple init --name "\u0E25\u0E07\u0E17\u0E30\u0E40\u0E1A\u0E35\u0E22\u0E19\u0E2D\u0E32\u0E2A\u0E32\u0E2A\u0E21\u0E31\u0E04\u0E23"`');return n}if(!t.interactive)throw new s("`pple init` \u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B",'\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"` \u0E2B\u0E23\u0E37\u0E2D\u0E23\u0E31\u0E19\u0E43\u0E19\u0E40\u0E17\u0E2D\u0E23\u0E4C\u0E21\u0E34\u0E19\u0E31\u0E25\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E43\u0E2B\u0E49\u0E23\u0E30\u0E1A\u0E1A\u0E16\u0E32\u0E21');let r=(await t.ask("\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B:")).trim();if(!r)throw new s("\u0E15\u0E49\u0E2D\u0E07\u0E23\u0E30\u0E1A\u0E38\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E41\u0E2D\u0E1B",'\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--name "\u0E0A\u0E37\u0E48\u0E2D\u0E41\u0E2D\u0E1B\u0E02\u0E2D\u0E07\u0E04\u0E38\u0E13"`');return r}async function pr(t,e,r){t.out.say(`\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2A\u0E23\u0E49\u0E32\u0E07 "${r}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u2026`);let n=await e.createApp(r);if(Gt(n))return n;t.out.say("\u0E01\u0E33\u0E25\u0E31\u0E07\u0E23\u0E2D\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E43\u0E2B\u0E49\u0E40\u0E2A\u0E23\u0E47\u0E08\u2026");let i=t.now();for(;;){if(n.status==="failed")throw new s(`\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 "${n.name}" \u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08`,n.error?`${n.error} slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`:`slug \u0E02\u0E2D\u0E07\u0E41\u0E2D\u0E1B\u0E04\u0E37\u0E2D ${n.slug} \u2014 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21 \u0E2B\u0E23\u0E37\u0E2D\u0E25\u0E2D\u0E07 \`pple init ${n.slug}\` \u0E20\u0E32\u0E22\u0E2B\u0E25\u0E31\u0E07`);if(t.now()-i>=ur)throw new s(`"${n.name}" \u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E19\u0E41\u0E1E\u0E25\u0E15\u0E1F\u0E2D\u0E23\u0E4C\u0E21`,`\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48\u0E14\u0E49\u0E27\u0E22 \`pple init ${n.slug}\` \u0E43\u0E19\u0E2D\u0E35\u0E01\u0E2A\u0E31\u0E01\u0E04\u0E23\u0E39\u0E48`);if(await t.sleep(ar),n=await e.app(n.slug),Gt(n))return n}}function Gt(t){return t.status==="ready"&&t.repo!==null}function fr(t,e){return`${t.config.repoBase}/${e}.git`}async function mr(t){let e=await Kt.readdir(t).catch(()=>null);if(e!==null&&e.length>0)throw new s(`\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C ${b.basename(t)}/ \u0E21\u0E35\u0E44\u0E1F\u0E25\u0E4C\u0E2D\u0E22\u0E39\u0E48\u0E41\u0E25\u0E49\u0E27`,"\u0E23\u0E31\u0E19 `pple init` \u0E17\u0E35\u0E48\u0E2D\u0E37\u0E48\u0E19 \u0E2B\u0E23\u0E37\u0E2D\u0E43\u0E2A\u0E48 `--dir <folder>` \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E42\u0E1F\u0E25\u0E40\u0E14\u0E2D\u0E23\u0E4C\u0E2D\u0E37\u0E48\u0E19")}async function gr(t){return Kt.access(b.join(t,"package.json")).then(()=>!0).catch(()=>!1)}function dr(t,e,r,n){let i=b.basename(r);t.out.say(),t.out.say(`"${e.name}" \u0E1E\u0E23\u0E49\u0E2D\u0E21\u0E43\u0E2B\u0E49\u0E17\u0E33\u0E07\u0E32\u0E19\u0E41\u0E25\u0E49\u0E27\u0E43\u0E19 ${i}/`),t.out.say(`\u0E41\u0E2D\u0E1B\u0E08\u0E30\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48 ${e.url}`),t.out.say(),t.out.say("\u0E02\u0E31\u0E49\u0E19\u0E15\u0E48\u0E2D\u0E44\u0E1B:"),t.out.say(` cd ${i}`),n||(t.out.say(" npm install"),t.out.say(" npm run db:migrate:local")),t.out.say(" npm run dev # \u0E14\u0E39\u0E1C\u0E25\u0E1A\u0E19\u0E40\u0E04\u0E23\u0E37\u0E48\u0E2D\u0E07\u0E19\u0E35\u0E49"),t.out.say(" pple deploy # \u0E19\u0E33\u0E02\u0E36\u0E49\u0E19\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E08\u0E23\u0E34\u0E07")}import W from"node:fs/promises";import X from"node:path";var yr=1440*60*1e3,Vt="update-check.json",wr="config.json",hr=new Set(["upgrade","help","--help","-h"]);function Jt(t){return t.length>0&&!hr.has(t[0])}function z(t,e){t.out.say(`\u0E21\u0E35 pple \u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19\u0E43\u0E2B\u0E21\u0E48 (${e}) \u0E04\u0E38\u0E13\u0E43\u0E0A\u0E49\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 ${t.version}`)}function Z(t){t.out.say("pple \u0E0A\u0E38\u0E14\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E1C\u0E48\u0E32\u0E19 npm \u0E01\u0E32\u0E23\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E17\u0E35\u0E48\u0E19\u0E35\u0E48\u0E2D\u0E32\u0E08\u0E17\u0E33\u0E43\u0E2B\u0E49\u0E04\u0E38\u0E13"),t.out.say("\u0E21\u0E35 pple \u0E2A\u0E2D\u0E07\u0E40\u0E27\u0E2D\u0E23\u0E4C\u0E0A\u0E31\u0E19 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E2D\u0E31\u0E1B\u0E40\u0E14\u0E15\u0E14\u0E49\u0E27\u0E22\u0E27\u0E34\u0E18\u0E35\u0E40\u0E14\u0E34\u0E21\u0E17\u0E35\u0E48\u0E04\u0E38\u0E13\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E44\u0E27\u0E49"),t.out.say(`\u0E2B\u0E23\u0E37\u0E2D\u0E15\u0E34\u0E14\u0E15\u0E31\u0E49\u0E07\u0E14\u0E49\u0E27\u0E22 npm:
|
|
16
16
|
|
|
17
17
|
${m}`)}function Q(t){return`${_t(t)}
|
|
18
18
|
|
package/package.json
CHANGED
|
@@ -25,8 +25,9 @@ into three pillars on party-managed infrastructure:
|
|
|
25
25
|
`pple auth login` (device flow).
|
|
26
26
|
2. **Mini-app integration** — apps run inside PPLE Today's WebView using
|
|
27
27
|
`@pplethai/pple-today-miniapp-sdk` (and the same SDK in a plain browser),
|
|
28
|
-
and send audience-bound notifications
|
|
29
|
-
|
|
28
|
+
and send audience-bound notifications — a **Broadcast** to everyone this
|
|
29
|
+
app can reach, or a **Direct** send to people it names — through the
|
|
30
|
+
platform-provisioned notify helper.
|
|
30
31
|
3. **PPLE Design System** — `@pplethai/components`, a shadcn/ui-based React
|
|
31
32
|
library with party branding. Docs: https://components.pplethai.org/
|
|
32
33
|
|
|
@@ -44,7 +45,7 @@ central-team core service — uniformity is deliberate (ADR-0002):
|
|
|
44
45
|
| Files | R2 |
|
|
45
46
|
| Validation | Zod schemas in `shared/` |
|
|
46
47
|
| Auth | PPLE ID (OIDC `userinfo`); local `DEV_AUTH` bypass only |
|
|
47
|
-
| Notifications|
|
|
48
|
+
| Notifications| `src/notify.ts` — every send states its audience (`all` or `direct`) |
|
|
48
49
|
| Deploy | `pple deploy` (platform build → tenant Worker); never personal cloud |
|
|
49
50
|
|
|
50
51
|
**Not the Builder App path** (do not resurrect these for new apps): Express +
|
|
@@ -99,10 +100,12 @@ Read only the file(s) relevant to the task at hand:
|
|
|
99
100
|
- **Never mention git, branches, remotes, GitHub, Cloudflare accounts, or
|
|
100
101
|
personal deploy credentials** in the happy path. Talk about the app's
|
|
101
102
|
code and `pple init` / `pple deploy` only.
|
|
102
|
-
- **Notifications are audience-bound
|
|
103
|
-
`
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
- **Notifications are audience-bound.** Every send states its audience:
|
|
104
|
+
Broadcast `{ kind: "all" }` or Direct `{ kind: "direct", recipients }`
|
|
105
|
+
(by `sub` or `phone`). Naming narrows the reachable set and never widens
|
|
106
|
+
it; `not_reachable` is one collapsed status — do not decompose it.
|
|
107
|
+
Self-links only (`linkPath` into this app). Shape in `shared/api.ts`;
|
|
108
|
+
details in [mini-app.md](references/mini-app.md).
|
|
106
109
|
- **Stack is Workers + D1 + R2.** Bind D1 parameters; never concatenate SQL.
|
|
107
110
|
One Worker, same origin — no CORS between SPA and API.
|
|
108
111
|
- **`DEV_AUTH` must be unset in production.** Its absence is what forces real
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"id": 2,
|
|
12
|
-
"prompt": "
|
|
13
|
-
"expected_output": "Use src/notify.ts
|
|
12
|
+
"prompt": "When a reimbursement is approved, send a push notification to that one person. We already have their PPLE ID sub. Use the PPLE notification service the platform wired up.",
|
|
13
|
+
"expected_output": "Use src/notify.ts sendNotification with required audience { kind: \"direct\", recipients: [{ sub }] } (or { phone }); title+body; optional path-only linkPath self-link into this app; naming narrows the reachable set and never widens it; not_reachable is one collapsed status — do not decompose it; never omit audience; never reimplement the wire POST; never free-form / cross-app destinations; never legacy /external/notifications/send with PHONE_NUMBER; Broadcast to everyone this app can reach is { kind: \"all\" }",
|
|
14
14
|
"files": []
|
|
15
15
|
},
|
|
16
16
|
{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Mini-app integration &
|
|
1
|
+
# Mini-app integration & notifications
|
|
2
2
|
|
|
3
3
|
## Running inside PPLE Today
|
|
4
4
|
|
|
@@ -14,54 +14,87 @@ Builder Apps ship as **mini-apps**: web apps opened in PPLE Today's WebView.
|
|
|
14
14
|
the Console; Builders do not register them manually with the Today team.
|
|
15
15
|
- Design mobile-first; test at ~375 px width.
|
|
16
16
|
|
|
17
|
-
## Push notifications (
|
|
17
|
+
## Push notifications (Broadcast and Direct)
|
|
18
18
|
|
|
19
|
-
Builder Apps send push notifications through
|
|
20
|
-
|
|
19
|
+
Builder Apps send push notifications through `sendNotification` in
|
|
20
|
+
`src/notify.ts`. Every send **states its audience** — the two kinds follow
|
|
21
|
+
from that:
|
|
21
22
|
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
- **
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
- **Broadcast** (การแจ้งเตือนทุกคน) — `{ kind: "all" }`, to everyone this app
|
|
24
|
+
can reach: its App Users ∩ its current publication-tier audience.
|
|
25
|
+
- **Direct** (การแจ้งเตือนรายบุคคล) — `{ kind: "direct", recipients }`, to
|
|
26
|
+
the people this app names, by PPLE ID `sub` or mobile number.
|
|
27
|
+
|
|
28
|
+
The audience is a **required discriminated union**. Omitting it is a compile
|
|
29
|
+
error, not a silent broadcast — that is the dangerous bug this shape exists
|
|
30
|
+
to prevent. Do not default a missing audience to `all`.
|
|
31
|
+
|
|
32
|
+
Naming **narrows** the reachable set and never widens it. A named person who
|
|
33
|
+
has never opened this app receives nothing and comes back `not_reachable`.
|
|
34
|
+
That status is **one collapsed reason** covering "no PPLE ID at all", "never
|
|
35
|
+
opened this app", "outside the tier", and "opted out". Do not decompose it,
|
|
36
|
+
surface it as a guess, or infer *why*. Use it to prune stale names — a send
|
|
37
|
+
debits the reach it requested (every named recipient, or the Broadcast
|
|
38
|
+
audience size), so leftover entries cost quota until removed.
|
|
39
|
+
|
|
40
|
+
`NOTIFY_API_BASE` and `NOTIFY_API_KEY` are provisioned bindings. The key *is*
|
|
41
|
+
the address — no app id on the wire. Recipients belong only on a Direct send,
|
|
42
|
+
on this same route.
|
|
29
43
|
|
|
30
44
|
### App-facing shape (`shared/api.ts`)
|
|
31
45
|
|
|
46
|
+
Call `sendNotification` with a `Notification` from `shared/api.ts`. Do not
|
|
47
|
+
invent a parallel payload or POST the wire shape yourself.
|
|
48
|
+
|
|
32
49
|
```ts
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
import { sendNotification } from "./notify";
|
|
51
|
+
|
|
52
|
+
await sendNotification(env, {
|
|
53
|
+
audience: {
|
|
54
|
+
kind: "direct",
|
|
55
|
+
recipients: [{ sub: "…" }, { phone: "0812345678" }],
|
|
56
|
+
},
|
|
57
|
+
// or: { kind: "all" },
|
|
58
|
+
title: "Reimbursement approved",
|
|
59
|
+
body: "Your claim was approved",
|
|
60
|
+
linkPath: "/claims/123", // optional path-only self-link
|
|
61
|
+
});
|
|
36
62
|
```
|
|
37
63
|
|
|
64
|
+
A recipient entry names **exactly one** of `sub` or `phone`. Cap is 200 per
|
|
65
|
+
call — over it is a refusal, never a silent truncation, so chunk a long list
|
|
66
|
+
deliberately. Optional `idempotencyKey` makes a retry after a timeout safe.
|
|
67
|
+
|
|
38
68
|
Optional `linkPath` is a deep link into **this** app only. The platform joins
|
|
39
69
|
it to the app's known entry and rejects absolute URLs, schemes, `..`, or
|
|
40
70
|
anything that would leave this app. Free-form destinations that could address
|
|
41
71
|
another mini app (e.g. `content.link`) are not available on this route.
|
|
42
|
-
Omit `linkPath`
|
|
43
|
-
|
|
72
|
+
Omit `linkPath` when the tap should open the notification centre.
|
|
73
|
+
|
|
74
|
+
A Direct send returns each named entry as `delivered` or `not_reachable`,
|
|
75
|
+
echoed as the caller named it. A Broadcast names no one, so there is nothing
|
|
76
|
+
to enumerate.
|
|
44
77
|
|
|
45
78
|
### Wire shape (`src/notify.ts`)
|
|
46
79
|
|
|
47
|
-
Keep the platform vocabulary translation in `notify.ts` only
|
|
80
|
+
Keep the platform vocabulary translation in `notify.ts` only. Your routes
|
|
81
|
+
call `sendNotification`; they do not POST this themselves:
|
|
48
82
|
|
|
49
83
|
```ts
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
|
|
59
|
-
}),
|
|
60
|
-
});
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
audience: input.audience,
|
|
86
|
+
content: { header: input.title, message: input.body },
|
|
87
|
+
...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
|
|
88
|
+
...(input.idempotencyKey !== undefined
|
|
89
|
+
? { idempotencyKey: input.idempotencyKey }
|
|
90
|
+
: {}),
|
|
91
|
+
})
|
|
61
92
|
```
|
|
62
93
|
|
|
63
94
|
- Missing config → fail loud (`notifications_not_configured` / 503), not silent.
|
|
64
|
-
- HTTP 429 → `
|
|
95
|
+
- HTTP 429 → `quota_exceeded` (**nothing was delivered**). The helper also
|
|
96
|
+
maps the other refusals (`invalid_request`, `invalid_recipients`,
|
|
97
|
+
`idempotency_key_conflict`, `not_permitted`).
|
|
65
98
|
- Prefer fire-and-forget from route handlers with error logging — do not fail
|
|
66
99
|
the user's primary action solely because notify is slow.
|
|
67
100
|
|
|
@@ -69,7 +102,10 @@ await fetch(`${env.NOTIFY_API_BASE}/notifications`, {
|
|
|
69
102
|
|
|
70
103
|
Do **not** call the legacy central-team endpoint that accepts a
|
|
71
104
|
`PHONE_NUMBER` audience (`/external/notifications/send` with raw numbers).
|
|
72
|
-
|
|
73
|
-
`mini-app-example` code addressing
|
|
105
|
+
Naming a phone on `sendNotification` is the Direct contract; that unbound
|
|
106
|
+
path is not. If you see older docs or `mini-app-example` code addressing
|
|
107
|
+
phones on `/external/notifications/send`, ignore them for new apps.
|
|
74
108
|
Do **not** invent free-form link / absolute URL fields — self-links use
|
|
75
109
|
`linkPath` only.
|
|
110
|
+
Do **not** treat `not_reachable` as "not a party member" versus "not a user
|
|
111
|
+
of this app". That distinction is the one fact this status exists to hide.
|
|
@@ -18,7 +18,7 @@ my-app/ # folder name matches the App Slug
|
|
|
18
18
|
│ ├── auth.ts # PPLE ID userinfo + DEV_AUTH bypass
|
|
19
19
|
│ ├── db.ts # D1 helpers
|
|
20
20
|
│ ├── files.ts # R2 helpers
|
|
21
|
-
│ ├── notify.ts #
|
|
21
|
+
│ ├── notify.ts # Broadcast and Direct notifications
|
|
22
22
|
│ └── env.ts # Env bindings type
|
|
23
23
|
├── web/ # React SPA (Vite)
|
|
24
24
|
│ ├── main.tsx # bootstrap → App | error
|
|
@@ -57,9 +57,13 @@ Where the token comes from:
|
|
|
57
57
|
- **Validate input with the Zod schemas** in `shared/api.ts`.
|
|
58
58
|
- **Always bind D1 query parameters** (`?` + `.bind(...)`) — never build SQL
|
|
59
59
|
with string concatenation.
|
|
60
|
-
- **
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
- **Every notification states its audience** — `{ kind: "all" }` (Broadcast,
|
|
61
|
+
การแจ้งเตือนทุกคน) or `{ kind: "direct", recipients }` (Direct,
|
|
62
|
+
การแจ้งเตือนรายบุคคล), naming people by PPLE ID `sub` or mobile number.
|
|
63
|
+
Required, so omitting it is a compile error — never work around that by
|
|
64
|
+
defaulting to `all`. Links stay path-only (`linkPath`) into this app. The
|
|
65
|
+
rules that come with naming are in `shared/api.ts`; read them before
|
|
66
|
+
changing anything here.
|
|
63
67
|
- **Mobile-first.** The app runs in a phone-sized WebView. Use the design
|
|
64
68
|
system's components and the layout classes in `web/app.css`.
|
|
65
69
|
- **Add a DB table** by writing a new `migrations/000N_*.sql` (never edit an
|
|
@@ -17,7 +17,7 @@ what you want to your AI coding agent and edit the demo cards. See
|
|
|
17
17
|
| **Identity** | `src/auth.ts` + `web/pple.ts` | validates PPLE ID tokens; browser PPLE ID login; local mock personas (`src/dev-personas.ts`) |
|
|
18
18
|
| **Database** | D1, `src/db.ts` + `migrations/` | the Notes card |
|
|
19
19
|
| **Files** | R2, `src/files.ts` | the Files card |
|
|
20
|
-
| **Notifications** | `src/notify.ts` | the Notify card (
|
|
20
|
+
| **Notifications** | `src/notify.ts` | the Notify card (Broadcast or Direct; optional `linkPath`) |
|
|
21
21
|
| **Design system** | `@pplethai/components` | every screen |
|
|
22
22
|
|
|
23
23
|
One Worker serves the API under `/api/*` and the React app (`web/`) for
|
|
@@ -61,6 +61,9 @@ you, so a freshly created app is ready to `npm run dev` straight away.)
|
|
|
61
61
|
- **Add an API route:** add it under `/api` in `src/index.ts`.
|
|
62
62
|
- **Change the UI:** edit `web/App.tsx` using `@pplethai/components`.
|
|
63
63
|
- **Send notifications:** the platform provisions `NOTIFY_API_KEY`; the helper
|
|
64
|
-
in `src/notify.ts`
|
|
64
|
+
in `src/notify.ts` speaks the shipped contract. Every send states its
|
|
65
|
+
audience — everyone this app can reach, or the people it names by PPLE ID
|
|
66
|
+
`sub` or mobile number. Naming only ever narrows that set: someone who has
|
|
67
|
+
never opened this app receives nothing.
|
|
65
68
|
|
|
66
69
|
The demo cards (Notes / Files / Notify) are examples — replace them.
|
|
@@ -68,9 +68,62 @@ export const UploadedFileSchema = z.object({
|
|
|
68
68
|
export type UploadedFile = z.infer<typeof UploadedFileSchema>;
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* One person a Direct Notification names: a PPLE ID `sub` **or** a mobile
|
|
72
|
+
* number, exactly one per entry. Both are accepted because a Builder's source
|
|
73
|
+
* of truth may be its own traffic (every request carries `actor.sub` and
|
|
74
|
+
* `actor.mobile`) or a list it holds by number.
|
|
75
|
+
*
|
|
76
|
+
* Strict objects, so an entry naming both is rejected here rather than at the
|
|
77
|
+
* platform. TypeScript alone cannot catch that one — `{ sub, phone }` is
|
|
78
|
+
* assignable to the union — so this runtime check is the one that matters.
|
|
79
|
+
*/
|
|
80
|
+
export const NotificationRecipientSchema = z.union([
|
|
81
|
+
z.strictObject({ sub: z.string().trim().min(1) }),
|
|
82
|
+
z.strictObject({ phone: z.string().trim().min(1) }),
|
|
83
|
+
]);
|
|
84
|
+
export type NotificationRecipient = z.infer<typeof NotificationRecipientSchema>;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The platform's per-call cap. Over it is a refusal, never a silent
|
|
88
|
+
* truncation, so chunk a long list deliberately rather than discovering half
|
|
89
|
+
* your people were dropped.
|
|
90
|
+
*/
|
|
91
|
+
export const MAX_DIRECT_RECIPIENTS = 200;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Who a send is for — **required**, and a discriminated union on purpose.
|
|
95
|
+
*
|
|
96
|
+
* - `all` — a **Broadcast Notification** (การแจ้งเตือนทุกคน) to everyone this
|
|
97
|
+
* app can reach: its App Users ∩ its current publication-tier audience.
|
|
98
|
+
* - `direct` — a **Direct Notification** (การแจ้งเตือนรายบุคคล) to the people
|
|
99
|
+
* it names.
|
|
100
|
+
*
|
|
101
|
+
* Naming **narrows** that reachable set and never widens it: a named person
|
|
102
|
+
* who has not opened this app receives nothing and comes back
|
|
103
|
+
* `not_reachable`. The union is required because the dangerous bug here is
|
|
104
|
+
* omission — a dropped field silently turning a message meant for one person
|
|
105
|
+
* into a message to every App User. Leaving the audience out is a compile
|
|
106
|
+
* error here and a refusal at the platform; it is never a broadcast.
|
|
107
|
+
*/
|
|
108
|
+
export const NotificationAudienceSchema = z.discriminatedUnion("kind", [
|
|
109
|
+
z.object({ kind: z.literal("all") }),
|
|
110
|
+
z.object({
|
|
111
|
+
kind: z.literal("direct"),
|
|
112
|
+
recipients: z
|
|
113
|
+
.array(NotificationRecipientSchema)
|
|
114
|
+
.min(1)
|
|
115
|
+
.max(MAX_DIRECT_RECIPIENTS),
|
|
116
|
+
}),
|
|
117
|
+
]);
|
|
118
|
+
export type NotificationAudience = z.infer<typeof NotificationAudienceSchema>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A notification to send. The privacy model in one line: this app can reach
|
|
122
|
+
* the people who already use it — and now say which of them it means — and no
|
|
123
|
+
* one else (see src/notify.ts and docs/integration/direct-notifications.md).
|
|
124
|
+
*
|
|
125
|
+
* Content is uniform across every recipient of a call; a personalized message
|
|
126
|
+
* is one call per person.
|
|
74
127
|
*
|
|
75
128
|
* Optional `linkPath` is a path-only deep link into *this* app (must start
|
|
76
129
|
* with `/`). The platform joins it to this app's known entry and rejects
|
|
@@ -78,6 +131,7 @@ export type UploadedFile = z.infer<typeof UploadedFileSchema>;
|
|
|
78
131
|
* address another mini app are not accepted.
|
|
79
132
|
*/
|
|
80
133
|
export const NotificationSchema = z.object({
|
|
134
|
+
audience: NotificationAudienceSchema,
|
|
81
135
|
title: z.string().trim().min(1).max(120),
|
|
82
136
|
body: z.string().trim().min(1).max(500),
|
|
83
137
|
linkPath: z
|
|
@@ -85,5 +139,32 @@ export const NotificationSchema = z.object({
|
|
|
85
139
|
.trim()
|
|
86
140
|
.regex(/^\/(?!\/).*/, "linkPath must be a path starting with a single /")
|
|
87
141
|
.optional(),
|
|
142
|
+
/**
|
|
143
|
+
* Optional: makes retrying after a network timeout safe instead of
|
|
144
|
+
* double-notifying everyone who already received it. Reusing a key with a
|
|
145
|
+
* recipient list of a different length is a conflict, not a replay.
|
|
146
|
+
*/
|
|
147
|
+
idempotencyKey: z.string().trim().min(1).optional(),
|
|
88
148
|
});
|
|
89
149
|
export type Notification = z.infer<typeof NotificationSchema>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What happened to one named recipient of a Direct Notification.
|
|
153
|
+
*
|
|
154
|
+
* `not_reachable` is **one collapsed reason**, deliberately: no PPLE ID
|
|
155
|
+
* account at all, an account that has never opened this app, someone outside
|
|
156
|
+
* this app's current tier audience, or someone opted out. Do not decompose it,
|
|
157
|
+
* and do not show a Builder a guess about which applies — separating "not a
|
|
158
|
+
* user of this app" from "no such person" would turn every Builder App into a
|
|
159
|
+
* lookup service answering *"is this number a party workforce member?"* about
|
|
160
|
+
* people who never consented to it. See docs/adr/0017.
|
|
161
|
+
*
|
|
162
|
+
* What it *is* good for: pruning. A stale entry costs quota on every send
|
|
163
|
+
* until it is removed, because a send debits the reach it requested, not the
|
|
164
|
+
* reach it achieved.
|
|
165
|
+
*/
|
|
166
|
+
export const NotificationDeliverySchema = z.object({
|
|
167
|
+
recipient: NotificationRecipientSchema,
|
|
168
|
+
status: z.enum(["delivered", "not_reachable"]),
|
|
169
|
+
});
|
|
170
|
+
export type NotificationDelivery = z.infer<typeof NotificationDeliverySchema>;
|
|
@@ -88,15 +88,26 @@ export function createApp(overrides: AppOverrides = {}) {
|
|
|
88
88
|
return new Response(obj.body, { headers });
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
// --- Notification demo:
|
|
91
|
+
// --- Notification demo: Broadcast and Direct sends ---
|
|
92
|
+
// The audience is required by the schema, so a send that forgets to state it
|
|
93
|
+
// is refused here and never reaches the platform as a broadcast.
|
|
92
94
|
app.post("/notify", async (c) => {
|
|
93
95
|
const parsed = NotificationSchema.safeParse(
|
|
94
96
|
await c.req.json().catch(() => null),
|
|
95
97
|
);
|
|
96
98
|
if (!parsed.success) return c.json({ error: "invalid_body" }, 400);
|
|
97
99
|
const result = await sendNotification(c.env, parsed.data, fetchImpl);
|
|
98
|
-
if (result.ok)
|
|
99
|
-
|
|
100
|
+
if (!result.ok) {
|
|
101
|
+
return c.json(
|
|
102
|
+
{ error: result.error, ...(result.quota ? { quota: result.quota } : {}) },
|
|
103
|
+
result.status as ContentfulStatusCode,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
// A Direct send answers for each recipient it named; a Broadcast names no
|
|
107
|
+
// one, so there is nothing to enumerate.
|
|
108
|
+
return c.json(
|
|
109
|
+
result.audience === "direct" ? { ok: true, results: result.results } : { ok: true },
|
|
110
|
+
);
|
|
100
111
|
});
|
|
101
112
|
|
|
102
113
|
return app;
|
|
@@ -1,36 +1,82 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Push notifications: Broadcast and Direct.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
21
|
-
// `{ content: { header, message }, linkPath? }`. Keeping that
|
|
22
|
-
// one place means the rest of the app never has to know the
|
|
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:
|
|
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)
|
|
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:
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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({
|
|
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("
|
|
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({
|
|
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
|
|
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
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Badge,
|
|
2
3
|
Button,
|
|
3
4
|
Card,
|
|
4
5
|
CardContent,
|
|
@@ -15,12 +16,24 @@ import {
|
|
|
15
16
|
Input,
|
|
16
17
|
Navbar,
|
|
17
18
|
Spinner,
|
|
19
|
+
Tabs,
|
|
20
|
+
TabsContent,
|
|
21
|
+
TabsList,
|
|
22
|
+
TabsTrigger,
|
|
18
23
|
Textarea,
|
|
19
24
|
showToast,
|
|
20
25
|
} from "@pplethai/components";
|
|
21
26
|
import { ChevronDown, LogOut, Plus, Send, Upload, User } from "lucide-react";
|
|
22
27
|
import { useEffect, useRef, useState } from "react";
|
|
23
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
Me,
|
|
30
|
+
Note,
|
|
31
|
+
NotificationAudience,
|
|
32
|
+
NotificationDelivery,
|
|
33
|
+
NotificationRecipient,
|
|
34
|
+
UploadedFile,
|
|
35
|
+
} from "../shared/api";
|
|
36
|
+
import { MAX_DIRECT_RECIPIENTS } from "../shared/api";
|
|
24
37
|
import { api, apiJson, logout } from "./pple";
|
|
25
38
|
|
|
26
39
|
function bytes(n: number): string {
|
|
@@ -280,24 +293,84 @@ function FilesCard() {
|
|
|
280
293
|
);
|
|
281
294
|
}
|
|
282
295
|
|
|
283
|
-
// ---
|
|
296
|
+
// --- Notifications: Broadcast and Direct ----------------------------------
|
|
297
|
+
//
|
|
298
|
+
// Two modes, one card. The audience is always stated — there is no way to
|
|
299
|
+
// start a send from this UI without choosing one, which mirrors the schema in
|
|
300
|
+
// shared/api.ts, where leaving it out is a compile error.
|
|
301
|
+
|
|
302
|
+
/** Builder-facing Thai for every refusal this card can receive. */
|
|
303
|
+
const NOTIFY_ERRORS: Record<string, string> = {
|
|
304
|
+
// This app's own Zod check (src/index.ts), before anything is sent.
|
|
305
|
+
invalid_body: `ข้อมูลไม่ครบหรือไม่ถูกต้อง — ต้องมีหัวข้อ ข้อความ และกลุ่มผู้รับ ไม่เกิน ${MAX_DIRECT_RECIPIENTS} คน`,
|
|
306
|
+
invalid_request: "คำขอไม่ถูกต้อง — ตรวจสอบว่าระบุกลุ่มผู้รับแล้ว",
|
|
307
|
+
invalid_recipients: `รายชื่อผู้รับไม่ถูกต้อง — ต้องมีอย่างน้อยหนึ่งคน ไม่เกิน ${MAX_DIRECT_RECIPIENTS} คน และหนึ่งบรรทัดระบุได้อย่างเดียวระหว่าง sub กับเบอร์โทรศัพท์`,
|
|
308
|
+
idempotency_key_conflict: "คีย์กันส่งซ้ำถูกใช้กับรายชื่อจำนวนอื่นไปแล้ว",
|
|
309
|
+
quota_exceeded: "โควตาการแจ้งเตือนหมด — ยังไม่มีใครได้รับข้อความนี้ ลองใหม่หลังโควตารีเซ็ต",
|
|
310
|
+
not_permitted: "คีย์การแจ้งเตือนนี้ไม่มีสิทธิ์ส่งผ่านช่องทางนี้",
|
|
311
|
+
send_failed: "ส่งไม่สำเร็จ",
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* One entry per line (commas work too). An entry that is all digits — with or
|
|
316
|
+
* without a leading `+` — is sent as a mobile number; anything else as a PPLE
|
|
317
|
+
* ID `sub`. Never both in one entry: the platform refuses those.
|
|
318
|
+
*/
|
|
319
|
+
function parseRecipients(text: string): NotificationRecipient[] {
|
|
320
|
+
return text
|
|
321
|
+
.split(/[\n,]/)
|
|
322
|
+
.map((entry) => entry.trim())
|
|
323
|
+
.filter(Boolean)
|
|
324
|
+
.map((entry) =>
|
|
325
|
+
/^\+?[\d\s-]+$/.test(entry)
|
|
326
|
+
? { phone: entry.replace(/[\s-]/g, "") }
|
|
327
|
+
: { sub: entry },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function recipientLabel(r: NotificationRecipient): string {
|
|
332
|
+
return "sub" in r ? r.sub : r.phone;
|
|
333
|
+
}
|
|
334
|
+
|
|
284
335
|
function NotifyCard() {
|
|
336
|
+
const [kind, setKind] = useState<NotificationAudience["kind"]>("all");
|
|
337
|
+
const [recipientsText, setRecipientsText] = useState("");
|
|
285
338
|
const [title, setTitle] = useState("");
|
|
286
339
|
const [body, setBody] = useState("");
|
|
287
340
|
const [sending, setSending] = useState(false);
|
|
341
|
+
const [results, setResults] = useState<NotificationDelivery[] | null>(null);
|
|
342
|
+
|
|
343
|
+
const recipients = parseRecipients(recipientsText);
|
|
344
|
+
// The cap is the platform's, and going over it is a refusal rather than a
|
|
345
|
+
// truncation — so hold the send here and say so, rather than letting a
|
|
346
|
+
// Builder discover it from an error toast.
|
|
347
|
+
const tooMany = recipients.length > MAX_DIRECT_RECIPIENTS;
|
|
348
|
+
const ready =
|
|
349
|
+
!!title.trim() &&
|
|
350
|
+
!!body.trim() &&
|
|
351
|
+
(kind === "all" || (recipients.length > 0 && !tooMany));
|
|
288
352
|
|
|
289
353
|
async function send() {
|
|
290
|
-
if (!
|
|
354
|
+
if (!ready) return;
|
|
291
355
|
setSending(true);
|
|
356
|
+
setResults(null);
|
|
292
357
|
try {
|
|
358
|
+
// The audience travels with the send, always. Choosing it is the first
|
|
359
|
+
// thing this form does, not an afterthought a dropped field can undo.
|
|
360
|
+
const audience: NotificationAudience =
|
|
361
|
+
kind === "all" ? { kind: "all" } : { kind: "direct", recipients };
|
|
293
362
|
const res = await api("/api/notify", {
|
|
294
363
|
method: "POST",
|
|
295
364
|
headers: { "content-type": "application/json" },
|
|
296
|
-
body: JSON.stringify({ title, body }),
|
|
365
|
+
body: JSON.stringify({ audience, title, body }),
|
|
297
366
|
});
|
|
298
|
-
const data = (await res.json().catch(() => ({}))) as {
|
|
367
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
368
|
+
error?: string;
|
|
369
|
+
results?: NotificationDelivery[];
|
|
370
|
+
};
|
|
299
371
|
if (res.ok) {
|
|
300
372
|
showToast({ title: "ส่งการแจ้งเตือนแล้ว", variant: "success" });
|
|
373
|
+
setResults(data.results ?? null);
|
|
301
374
|
setTitle("");
|
|
302
375
|
setBody("");
|
|
303
376
|
} else if (data.error === "notifications_not_configured") {
|
|
@@ -307,7 +380,11 @@ function NotifyCard() {
|
|
|
307
380
|
variant: "warning",
|
|
308
381
|
});
|
|
309
382
|
} else {
|
|
310
|
-
showToast({
|
|
383
|
+
showToast({
|
|
384
|
+
title: "ส่งไม่สำเร็จ",
|
|
385
|
+
description: NOTIFY_ERRORS[data.error ?? ""] ?? data.error,
|
|
386
|
+
variant: "error",
|
|
387
|
+
});
|
|
311
388
|
}
|
|
312
389
|
} catch (e) {
|
|
313
390
|
showToast({ title: "ส่งไม่สำเร็จ", description: message(e), variant: "error" });
|
|
@@ -321,12 +398,45 @@ function NotifyCard() {
|
|
|
321
398
|
<CardHeader>
|
|
322
399
|
<CardTitle>แจ้งเตือนผู้ใช้</CardTitle>
|
|
323
400
|
<CardDescription>
|
|
324
|
-
|
|
325
|
-
|
|
401
|
+
ส่งถึงผู้ใช้ของแอปนี้ — ทุกคน หรือระบุรายบุคคลด้วย PPLE ID (sub)
|
|
402
|
+
หรือเบอร์โทรศัพท์ การระบุชื่อเป็นการ<em>จำกัด</em>ผู้รับให้แคบลงเท่านั้น
|
|
403
|
+
ไม่เคยขยายออก คนที่ยังไม่เคยเปิดแอปนี้จะไม่ได้รับ
|
|
326
404
|
</CardDescription>
|
|
327
405
|
</CardHeader>
|
|
328
406
|
<CardContent>
|
|
329
407
|
<div className="stack">
|
|
408
|
+
<Tabs
|
|
409
|
+
value={kind}
|
|
410
|
+
onValueChange={(v) => {
|
|
411
|
+
setKind(v as NotificationAudience["kind"]);
|
|
412
|
+
setResults(null);
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
<TabsList>
|
|
416
|
+
<TabsTrigger value="all">การแจ้งเตือนทุกคน</TabsTrigger>
|
|
417
|
+
<TabsTrigger value="direct">การแจ้งเตือนรายบุคคล</TabsTrigger>
|
|
418
|
+
</TabsList>
|
|
419
|
+
<TabsContent value="all">
|
|
420
|
+
<p className="muted">
|
|
421
|
+
ส่งถึงผู้ใช้แอปนี้ทุกคน — นับโควตาตามจำนวนผู้รับทั้งหมด
|
|
422
|
+
</p>
|
|
423
|
+
</TabsContent>
|
|
424
|
+
<TabsContent value="direct">
|
|
425
|
+
<div className="stack">
|
|
426
|
+
<Textarea
|
|
427
|
+
value={recipientsText}
|
|
428
|
+
placeholder={"บรรทัดละหนึ่งคน\n0812345678\nu-42"}
|
|
429
|
+
onChange={(e) => setRecipientsText(e.target.value)}
|
|
430
|
+
/>
|
|
431
|
+
<p className="muted">
|
|
432
|
+
ระบุได้สูงสุด {MAX_DIRECT_RECIPIENTS} คนต่อครั้ง (ตอนนี้{" "}
|
|
433
|
+
{recipients.length} คน{tooMany ? " — เกินจำนวน ต้องแบ่งส่งเป็นรอบ" : ""})
|
|
434
|
+
— ทุกชื่อที่ระบุถูกนับโควตา แม้จะส่งไม่ถึง
|
|
435
|
+
จึงควรตัดรายชื่อที่ส่งไม่ถึงออก
|
|
436
|
+
</p>
|
|
437
|
+
</div>
|
|
438
|
+
</TabsContent>
|
|
439
|
+
</Tabs>
|
|
330
440
|
<Input
|
|
331
441
|
value={title}
|
|
332
442
|
placeholder="หัวข้อ"
|
|
@@ -337,10 +447,30 @@ function NotifyCard() {
|
|
|
337
447
|
placeholder="ข้อความ"
|
|
338
448
|
onChange={(e) => setBody(e.target.value)}
|
|
339
449
|
/>
|
|
340
|
-
<Button onClick={send} disabled={sending || !
|
|
450
|
+
<Button onClick={send} disabled={sending || !ready}>
|
|
341
451
|
{sending ? <Spinner /> : <Icon icon={Send} />}
|
|
342
452
|
ส่งการแจ้งเตือน
|
|
343
453
|
</Button>
|
|
454
|
+
{results && (
|
|
455
|
+
<div className="stack">
|
|
456
|
+
<ul className="list">
|
|
457
|
+
{results.map((r, i) => (
|
|
458
|
+
<li key={`${recipientLabel(r.recipient)}-${i}`} className="list__item">
|
|
459
|
+
<span>{recipientLabel(r.recipient)}</span>
|
|
460
|
+
<Badge variant={r.status === "delivered" ? "default" : "outline"}>
|
|
461
|
+
{r.status === "delivered" ? "ส่งแล้ว" : "ส่งไม่ถึง"}
|
|
462
|
+
</Badge>
|
|
463
|
+
</li>
|
|
464
|
+
))}
|
|
465
|
+
</ul>
|
|
466
|
+
{/* One collapsed reason, on purpose: the app is never told which
|
|
467
|
+
of these applies (docs/adr/0017). Do not guess in the UI. */}
|
|
468
|
+
<p className="muted">
|
|
469
|
+
“ส่งไม่ถึง” หมายถึงยังไม่เคยเปิดแอปนี้ อยู่นอกกลุ่มผู้รับ
|
|
470
|
+
ไม่มีบัญชี PPLE ID หรือปิดรับการแจ้งเตือน — ระบบไม่ระบุว่าเป็นข้อใด
|
|
471
|
+
</p>
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
344
474
|
</div>
|
|
345
475
|
</CardContent>
|
|
346
476
|
</Card>
|