@topy-ai/maggie 0.7.51 → 0.7.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +48 -2
- package/README.md +66 -45
- package/bin/maggie.js +18 -7
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
- package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
- package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
- package/bundled-references/maggiedash-booking/PRD.md +22 -11
- package/bundled-references/maggiedash-booking/RULES.md +167 -0
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
- package/bundled-references/provider-adapter-contract.md +16 -3
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
- package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
- package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
- package/bundled-skills/maggie-booking/SKILL.md +47 -3
- package/bundled-skills/maggie-deployment/SKILL.md +21 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
- package/bundled-skills/maggie-deployment/references/vps.md +92 -0
- package/bundled-skills/maggie-design/SKILL.md +30 -3
- package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
- package/bundled-skills/maggie-feedback/SKILL.md +17 -0
- package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
- package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
- package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
- package/bundled-skills/maggie-mobile-app/SKILL.md +52 -5
- package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
- package/bundled-skills/maggie-social-share/SKILL.md +26 -4
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
- package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
- package/bundled-tools/clis/maggie_booking.py +15 -4
- package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
- package/bundled-tools/clis/maggie_booking_rules.py +476 -0
- package/bundled-tools/clis/maggie_bridge_host.py +77 -1
- package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
- package/bundled-tools/clis/maggie_design.py +11 -2
- package/bundled-tools/clis/maggie_design_lint.py +142 -3
- package/bundled-tools/clis/maggie_feedback.py +97 -15
- package/bundled-tools/clis/maggie_mobile.py +154 -8
- package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
- package/bundled-tools/clis/maggie_qa_fit.py +217 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
- package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
- package/bundled-tools/clis/maggie_seo_routes.py +154 -0
- package/bundled-tools/clis/maggie_service_migration.py +272 -0
- package/bundled-tools/clis/maggie_social_queue.py +2 -0
- package/bundled-tools/clis/maggie_social_share.py +52 -6
- package/bundled-tools/runtime/assistant_approval.py +98 -0
- package/package.json +1 -1
- package/references/provider-adapter-contract.md +16 -3
|
@@ -45,6 +45,7 @@ From the Astro starter:
|
|
|
45
45
|
npm run build:cloudflare
|
|
46
46
|
npx wrangler d1 migrations apply DB --local
|
|
47
47
|
npx wrangler dev
|
|
48
|
+
maggie deployment cloudflare-preflight --env production --env-file .env --verify-zone
|
|
48
49
|
npx wrangler deploy --env staging
|
|
49
50
|
npx wrangler deploy --env production
|
|
50
51
|
```
|
|
@@ -74,3 +75,21 @@ After deploy, check:
|
|
|
74
75
|
- D1 migration version, R2/media delivery, KV/session behavior, API Pull,
|
|
75
76
|
rewrite queue, and scheduled triggers are observable;
|
|
76
77
|
- staging has no production analytics or email recipients.
|
|
78
|
+
|
|
79
|
+
## Deploy preflight: never fall back to another account
|
|
80
|
+
|
|
81
|
+
When `CLOUDFLARE_API_TOKEN` or `CLOUDFLARE_ACCOUNT_ID` is empty — for example
|
|
82
|
+
a deploy script reading `CF_API_TOKEN` from `.env` — wrangler silently uses its
|
|
83
|
+
cached OAuth login, which may belong to another Cloudflare account. It uploads
|
|
84
|
+
a stray Worker there and then fails with `Can't infer zone from route ...
|
|
85
|
+
[code: 10082]`; the live site keeps the old build and the stray Worker has to
|
|
86
|
+
be deleted by hand.
|
|
87
|
+
|
|
88
|
+
`maggie deployment cloudflare-preflight` fails before `wrangler deploy` when
|
|
89
|
+
either variable is empty (from the environment or `--env-file`), names
|
|
90
|
+
near-miss variables that are set instead, warns about a cached OAuth login,
|
|
91
|
+
fails when the config's `account_id` differs from `CLOUDFLARE_ACCOUNT_ID`, and
|
|
92
|
+
with `--verify-zone` checks with read-only API calls that the token verifies
|
|
93
|
+
and that the account owns the zone of every route. Put `account_id` in
|
|
94
|
+
`wrangler.jsonc` so wrangler itself refuses another account, and run
|
|
95
|
+
`wrangler logout` on machines that only deploy with tokens.
|
|
@@ -155,6 +155,35 @@ inspect → plan → backup/migrate → npm ci → npm run build
|
|
|
155
155
|
Deploy staging first. A passing build does not prove Nginx, systemd, DNS, TLS,
|
|
156
156
|
robots, sitemap, or private-route correctness.
|
|
157
157
|
|
|
158
|
+
### One command per release
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
maggie deployment vps-release --host deploy@203.0.113.10 \
|
|
162
|
+
--release-root /srv/site --service site --env-file /etc/site.env \
|
|
163
|
+
--subdir app --ref main --health-url https://www.example.com/ \
|
|
164
|
+
--health-url https://www.example.com/sitemap.xml # dry run
|
|
165
|
+
# review the printed remote script, then add --confirm
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
It names the release `<UTC timestamp>-<commit>` (never reused), uploads a
|
|
169
|
+
`git archive` of that commit (no local build artefacts), installs and builds
|
|
170
|
+
on the host **as the deploy user** with the server env file loaded, refuses
|
|
171
|
+
files owned by anyone else, switches `current` atomically, restarts through
|
|
172
|
+
the narrow sudoers rule, checks each health URL (retrying only 000/502/503/504)
|
|
173
|
+
and then sends `--warm` requests before timing one, prunes to `--keep`
|
|
174
|
+
releases, and prints a roadmap line. `--install`, `--build` and `--restart`
|
|
175
|
+
override the defaults (`npm ci`, `npm run build`,
|
|
176
|
+
`sudo -n systemctl restart <service>`).
|
|
177
|
+
|
|
178
|
+
**Build as the deploy user, never with sudo.** `sudo bash -c 'set -a; .
|
|
179
|
+
/etc/app.env; npx astro build'` leaves root-owned `dist/` and cache files;
|
|
180
|
+
the retention prune then fails with "Permission denied" and the narrow sudoers
|
|
181
|
+
contract (systemctl only) cannot remove them. Make the env file readable by
|
|
182
|
+
the deploy group (`chgrp deploy /etc/app.env; chmod 640 /etc/app.env`)
|
|
183
|
+
instead. `vps-release` refuses to run as root, refuses an unreadable env
|
|
184
|
+
file, and reports releases it cannot prune with the one-time fix
|
|
185
|
+
(`sudo chown -R deploy: <release>`).
|
|
186
|
+
|
|
158
187
|
### Release retention
|
|
159
188
|
|
|
160
189
|
The retention policy is one explicit number, `--keep-releases N` (default 2),
|
|
@@ -198,6 +227,69 @@ maggie deployment secret-parity \
|
|
|
198
227
|
|
|
199
228
|
Run it after adding a secret and before enabling a host in a pool.
|
|
200
229
|
|
|
230
|
+
### Surgical multi-host copies
|
|
231
|
+
|
|
232
|
+
When a fix copies a few built files to several hosts instead of a full
|
|
233
|
+
release, first prove every host still holds the copies of the deployed
|
|
234
|
+
commit, so the copy does not hide a hand edit or a half-finished earlier
|
|
235
|
+
deploy:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
maggie deployment file-parity --host api-1 --host api-2 --host api-3 \
|
|
239
|
+
--root /srv/api/current --subdir api --ref <deployed commit> \
|
|
240
|
+
--path dist/server/entry.mjs --path dist/server/chunks/routes.mjs
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Each host's files are hashed over ssh (stdin script, no quoting through
|
|
244
|
+
ssh) and compared with `git show <ref>:<path>` and with the other hosts; a
|
|
245
|
+
file that differs or is missing fails, a file that does not exist at the ref
|
|
246
|
+
is reported as `new-file`.
|
|
247
|
+
|
|
248
|
+
### Judge latency after warm-up
|
|
249
|
+
|
|
250
|
+
The first requests after a restart start the workers (often 8-16 s with
|
|
251
|
+
several workers). Warm each host before deciding a release is slow:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
maggie deployment warm-check --url https://api.example.com/health --warm 4 --samples 5 --budget-ms 800
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`--warm` should be at least the number of worker processes; only the later
|
|
258
|
+
samples are judged.
|
|
259
|
+
|
|
260
|
+
### Failover must not replay writes
|
|
261
|
+
|
|
262
|
+
An edge or proxy with ordered upstreams may retry the next host when one is
|
|
263
|
+
down. Retrying is only safe when the first host never ran the request:
|
|
264
|
+
|
|
265
|
+
- Fail over on **connection errors and timeouts before a response**, and on
|
|
266
|
+
502/503/504 **only when the body is the gateway's own error page**
|
|
267
|
+
(HTML/non-JSON from the proxy). An application's JSON 5xx ("the AI plan
|
|
268
|
+
failed") is the app's answer: relay it to the client.
|
|
269
|
+
- **Never re-send a non-idempotent request** (POST/PATCH/DELETE) to another
|
|
270
|
+
upstream unless it carries an `Idempotency-Key` the app honours. nginx does
|
|
271
|
+
not retry non-idempotent requests unless `proxy_next_upstream
|
|
272
|
+
non_idempotent` is set — do not set it; keep `proxy_next_upstream error
|
|
273
|
+
timeout` for writes.
|
|
274
|
+
- Let the app mark its own errors (for example `X-App-Error: 1` or
|
|
275
|
+
`Content-Type: application/json`) so the edge can tell them apart.
|
|
276
|
+
|
|
277
|
+
A JSON 502 treated as "upstream down" replayed a create on a second host and
|
|
278
|
+
left an orphan record per failover.
|
|
279
|
+
|
|
280
|
+
### Local servers in checks
|
|
281
|
+
|
|
282
|
+
Start local servers with a pid file and stop them by pid; never
|
|
283
|
+
`pkill -f '<pattern>'` from the same shell command — the pattern also matches
|
|
284
|
+
the shell running the command, which kills it, and later steps silently do
|
|
285
|
+
not run:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
node dist/server/entry.mjs & echo $! > .maggie/server.pid
|
|
289
|
+
# ... checks ...
|
|
290
|
+
kill "$(cat .maggie/server.pid)" && rm -f .maggie/server.pid
|
|
291
|
+
```
|
|
292
|
+
|
|
201
293
|
### Environment isolation
|
|
202
294
|
|
|
203
295
|
A local `.env` must never point at the production database. Shared databases
|
|
@@ -333,7 +333,13 @@ the project's auth or device-capability implementation:
|
|
|
333
333
|
- `--viewport tablet=768x1024` overrides a plan viewport; the evidence and
|
|
334
334
|
screenshots must then match it.
|
|
335
335
|
- `--pattern` attaches acceptance rules from
|
|
336
|
-
[app UI patterns](references/app-ui-patterns.md) to the QA scenarios
|
|
336
|
+
[app UI patterns](references/app-ui-patterns.md) to the QA scenarios:
|
|
337
|
+
`tabbed-modal`, `editor-sheet-assistant`, `usage-hero`, `site-switcher`,
|
|
338
|
+
`background-job` (slow media generation: a job per workspace, Stop or
|
|
339
|
+
replace, one toast), `chat-composer` (one-line auto-grow composer), and the
|
|
340
|
+
no-page-scroll dashboard set `hash-tabs`, `right-drawer`,
|
|
341
|
+
`two-column-sheet`, `screen-pager` (check those with `maggie qa
|
|
342
|
+
fit-check`).
|
|
337
343
|
|
|
338
344
|
Expo Router files (`app/<route>.tsx`, `app/<route>/index.tsx`) are detected as
|
|
339
345
|
the route source. The plan never collects credentials or copies private data.
|
|
@@ -370,9 +376,27 @@ when `fullPage: true`). A top-level `shell.scrollOwnerCount` is ignored with a
|
|
|
370
376
|
warning, and a signature-only or scaled screenshot fails. The machine-readable
|
|
371
377
|
contract is
|
|
372
378
|
[mobile-app-surface-v1.schema.json](../../bundled-contracts/maggie-design/mobile-app-surface-v1.schema.json).
|
|
379
|
+
Native iOS modals need one more check. `<SafeAreaView>` inside an Expo Router
|
|
380
|
+
modal route (`presentation: "transparentModal"`, `"modal"`, …) or a React
|
|
381
|
+
Native `<Modal>` can measure a 0 top inset on iOS, putting the header and
|
|
382
|
+
Close under the notch or Dynamic Island; web screenshots at 390/768/1440
|
|
383
|
+
cannot show it:
|
|
384
|
+
|
|
385
|
+
maggie design safe-area-check --project apps/app
|
|
386
|
+
|
|
387
|
+
It flags those uses; pad with `useSafeAreaInsets()` from the root
|
|
388
|
+
`SafeAreaProvider` instead (`// maggie-allow-safe-area-view` marks a reviewed
|
|
389
|
+
exception). `app-init` adds an `ios-safe-area` scenario that needs a notched
|
|
390
|
+
device or simulator screenshot.
|
|
391
|
+
|
|
373
392
|
Keep `maggie design pressable-check` in the same loop for React Native Web
|
|
374
393
|
apps: together they caught nested pressables and shell regressions during
|
|
375
|
-
repeated sheet and modal changes.
|
|
394
|
+
repeated sheet and modal changes. Add `maggie design textinput-check`: a
|
|
395
|
+
`multiline` `TextInput` renders a 2-row `<textarea>` on web unless it has
|
|
396
|
+
`numberOfLines` or an explicit height, and auto-grow driven only by
|
|
397
|
+
`onContentSizeChange` grows but never shrinks on web (measure `scrollHeight`
|
|
398
|
+
after collapsing; see the `chat-composer` pattern). `// maggie-allow-textinput`
|
|
399
|
+
marks a reviewed exception.
|
|
376
400
|
|
|
377
401
|
### Onboarding wizards
|
|
378
402
|
|
|
@@ -706,7 +730,10 @@ It runs only when an `@import "tailwindcss"` entry exists (or with
|
|
|
706
730
|
`--tailwind-v4`), honours `@layer` blocks and `layer(...)` imports, and fails on
|
|
707
731
|
unlayered element or icon rules that set utility-competing properties.
|
|
708
732
|
Unlayered `html`/`body` rules and class rules that set `display` are reported
|
|
709
|
-
as warnings.
|
|
733
|
+
as warnings. In every project (Tailwind or not) it also warns about
|
|
734
|
+
`width: max-content` rules and `w-max` classes without `max-width`/overflow
|
|
735
|
+
handling: a tab strip or toolbar sized to its content makes a 390 px page
|
|
736
|
+
scroll sideways — give it `overflow-x: auto` inside a `min-width: 0` parent. Keep `css-cascade-check` for the rendered-browser proof.
|
|
710
737
|
|
|
711
738
|
### Palette role contract
|
|
712
739
|
|
|
@@ -64,3 +64,82 @@ The first block of an inbox or account screen when usage limits matter.
|
|
|
64
64
|
accessible name.
|
|
65
65
|
- Account-level actions (add a site) are visually separated from per-site
|
|
66
66
|
rows, and are audited at account scope, not under the open site.
|
|
67
|
+
|
|
68
|
+
## `background-job` — slow media generation as a background job
|
|
69
|
+
|
|
70
|
+
Image or video generation can take 1-2 minutes (more on a user's own
|
|
71
|
+
ChatGPT plan). Never run it inside the request that started it.
|
|
72
|
+
|
|
73
|
+
- **Start returns a job**: `POST .../generate` answers at once with
|
|
74
|
+
`{job: {id, itemId, startedAt}}`; the work runs in the background.
|
|
75
|
+
- **One job per workspace** (or per user): a second start answers
|
|
76
|
+
`409 {busy: {jobId, itemId}}`; the UI asks "Stop it and start this one?"
|
|
77
|
+
and, on yes, calls start with `replace: true`, which interrupts the old job.
|
|
78
|
+
- **State carries the job**: the item/list GET includes the running job, so
|
|
79
|
+
a reload, a second device or another tab shows it. Poll that GET every few
|
|
80
|
+
seconds while a job runs (no extra polling endpoint); stop polling when it
|
|
81
|
+
ends.
|
|
82
|
+
- **On the item**: a spinner over its thumbnail or preview, and the same
|
|
83
|
+
action button becomes **Stop**.
|
|
84
|
+
- **One toast** on done or failed (with the reason, e.g. "Your ChatGPT image
|
|
85
|
+
limit is used up"), never one per poll.
|
|
86
|
+
- **Stale jobs expire** (for example after 10 minutes without progress) so a
|
|
87
|
+
crashed worker never locks the workspace.
|
|
88
|
+
|
|
89
|
+
## `chat-composer` — one-line auto-grow composer (React Native Web)
|
|
90
|
+
|
|
91
|
+
A `multiline` `TextInput` on web renders a `<textarea>` with the browser's
|
|
92
|
+
default 2 rows, so an empty composer is ~56 px tall with the placeholder high
|
|
93
|
+
and 40 px buttons low.
|
|
94
|
+
|
|
95
|
+
- Give it an explicit single-line height (`numberOfLines={1}` or a
|
|
96
|
+
`style.height` equal to one line plus padding) and centre the row items.
|
|
97
|
+
- Auto-grow on web by measuring: set the height to the minimum, read
|
|
98
|
+
`scrollHeight`, then set `min(scrollHeight, max)`. `onContentSizeChange`
|
|
99
|
+
never reports a smaller size on web, so a composer driven only by it grows
|
|
100
|
+
but never shrinks after text is deleted.
|
|
101
|
+
- Align send/attach buttons to the bottom (`alignItems: "flex-end"`) so they
|
|
102
|
+
stay on the last line as it grows.
|
|
103
|
+
- `maggie design textinput-check` flags multiline inputs without a height and
|
|
104
|
+
`onContentSizeChange`-only auto-grow.
|
|
105
|
+
|
|
106
|
+
## Web dashboards that never scroll the page
|
|
107
|
+
|
|
108
|
+
For dense desktop dashboards (settings, bookings, reports) where the owner
|
|
109
|
+
wants no page scroll at a laptop viewport (1536x687 with browser chrome)
|
|
110
|
+
and a usable 390 px phone layout. Check with `maggie qa fit-check`.
|
|
111
|
+
|
|
112
|
+
### `hash-tabs` — tabs that keep state in the URL hash
|
|
113
|
+
|
|
114
|
+
- `role="tablist"` / `role="tab"` / `role="tabpanel"`; the active tab is
|
|
115
|
+
`#tab-id` in the URL, restored on load and on `hashchange` (back/forward).
|
|
116
|
+
- Emit a change event (for example `tabs:change` with the tab id) so
|
|
117
|
+
components inside a panel can re-measure when they become visible — a
|
|
118
|
+
hidden panel measures zero.
|
|
119
|
+
- The tab strip scrolls sideways on phones (`overflow-x: auto`,
|
|
120
|
+
`min-width: 0` on the flex parent); never `width: max-content` without a
|
|
121
|
+
scroll container (`maggie design layer-check` warns).
|
|
122
|
+
|
|
123
|
+
### `right-drawer` — details and forms in a drawer
|
|
124
|
+
|
|
125
|
+
- A `role="dialog"` panel from the right (`min(480px, 100vw)` wide, full
|
|
126
|
+
height) that owns its scroll; the page behind does not scroll.
|
|
127
|
+
- Disclosures with a lot of content ("Edit opening hours") open as a drawer
|
|
128
|
+
instead of expanding the page.
|
|
129
|
+
- Escape, the close button and the backdrop close it and return focus to the
|
|
130
|
+
opener.
|
|
131
|
+
|
|
132
|
+
### `two-column-sheet` — large modal with section quick-nav
|
|
133
|
+
|
|
134
|
+
- A large modal (about 1100 px, max 90 vh) for long settings: sections in two
|
|
135
|
+
columns from 1024 px, one column below; the body is the only scroll owner.
|
|
136
|
+
- A quick-nav (chips or a side list) scrolls the sheet to each section and
|
|
137
|
+
marks the current one.
|
|
138
|
+
|
|
139
|
+
### `screen-pager` — page size from the viewport
|
|
140
|
+
|
|
141
|
+
- The page size is the number of rows that fit the list's visible height;
|
|
142
|
+
re-measure on resize and on `tabs:change` when the list sits in a tab.
|
|
143
|
+
- Use it only for client-side lists. When the server already pages, use the
|
|
144
|
+
server pager — two pagers on one list showed half pages and double
|
|
145
|
+
"next" buttons.
|
|
@@ -175,6 +175,23 @@ maggie feedback session-summary --project . \
|
|
|
175
175
|
Evidence then reads `commit:<label>@<sha>`; unlabeled repositories become
|
|
176
176
|
`repo1`, `repo2`, so local folder names never leave the machine.
|
|
177
177
|
|
|
178
|
+
Each repository is read at `HEAD` unless you name a revision. Commits made in
|
|
179
|
+
a temporary worktree and pushed with `git push origin HEAD:main` are not on a
|
|
180
|
+
stale local `main`; the output then warns "local HEAD is N commit(s) behind
|
|
181
|
+
origin/main". Fetch, then read the remote branch with `--repo
|
|
182
|
+
api=../api@origin/main` (or `--rev origin/main` for every repository).
|
|
183
|
+
|
|
184
|
+
`Area: sentence` subjects ("Booking: waitlist offers expire after two hours")
|
|
185
|
+
are drafted as changes in that area, and the area or conventional-commit
|
|
186
|
+
scope is kept. A busy product repository is mostly project work, not skill
|
|
187
|
+
feedback: `--skill-relevant` keeps only commits that mention Maggie, a skill,
|
|
188
|
+
a CLI, MCP or the `--skill` name, and reports the rest as
|
|
189
|
+
`projectOnlyCommits`. A batch holds at most 50 items; when more commits
|
|
190
|
+
qualify, the draft groups them into one item per scope (with up to 20 commit
|
|
191
|
+
references each) and says so in `warnings`, instead of silently keeping the
|
|
192
|
+
first 50. The draft's `commits` block records how many were seen, drafted,
|
|
193
|
+
skipped and project-only.
|
|
194
|
+
|
|
178
195
|
Review the file: delete items that concern the project rather than a Maggie
|
|
179
196
|
skill or CLI, and replace every `REVIEW:` field (expected, actual, validation).
|
|
180
197
|
`maggie feedback batch` rejects a file that still contains `REVIEW:`
|
|
@@ -223,6 +223,18 @@ maggie skills smoke --out dist/marketplace --claude
|
|
|
223
223
|
Nothing is published; push the output folder to the marketplace repository
|
|
224
224
|
yourself after review.
|
|
225
225
|
|
|
226
|
+
## Directory towns and name search
|
|
227
|
+
|
|
228
|
+
A marketplace that lists businesses by town and lets people search names
|
|
229
|
+
uses one shared module, [`directory.mjs`](references/directory.mjs),
|
|
230
|
+
imported by the server and every node script (town pages, sitemaps, maps) —
|
|
231
|
+
never a copy. It parses UK post towns and US "City, ST", falls back to the
|
|
232
|
+
town field plus the state from the ZIP prefix when a US profile saved only
|
|
233
|
+
the street, keeps non-US places in a US market ("Kingston, Jamaica"), and
|
|
234
|
+
gives a normalised search key so "moonlake" finds "Moon Lake Spa", ranked
|
|
235
|
+
exact → prefix → word prefix → contains. See [directory towns and
|
|
236
|
+
search](references/directory-search.md) for the generated-column SQL.
|
|
237
|
+
|
|
226
238
|
## Rules
|
|
227
239
|
|
|
228
240
|
1. Extract the template detail page first and save title, description,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Marketplace directory: towns and name search
|
|
2
|
+
|
|
3
|
+
Use [`directory.mjs`](directory.mjs) as **one** module that the server and
|
|
4
|
+
every node script import (town pages, sitemaps, maps). It is plain
|
|
5
|
+
JavaScript so `node scripts/x.mjs` can import it without a build step; a
|
|
6
|
+
script that keeps its own copy drifts (a map script's copy had lost a rule
|
|
7
|
+
the site had fixed).
|
|
8
|
+
|
|
9
|
+
## Towns
|
|
10
|
+
|
|
11
|
+
`parseTown(address, market, locality, postcode)` returns
|
|
12
|
+
`{slug, name, label, region}` or `null`:
|
|
13
|
+
|
|
14
|
+
- UK: the post town before the postcode ("…, Leeds LS1 4AB" → Leeds),
|
|
15
|
+
skipping fragments such as "Unit 3", "Rear", "Farm" or "…Road"; falls back
|
|
16
|
+
to the separate town field.
|
|
17
|
+
- US: "City, ST 12345" → "City, ST". When the profile form saved only the
|
|
18
|
+
street, the separate town field plus the **state from the ZIP prefix**
|
|
19
|
+
("503 Maple Avenue West" + Vienna + 22180 → "Vienna, VA").
|
|
20
|
+
- Places listed in a US market but outside the US keep their country
|
|
21
|
+
("…, Kingston, Jamaica" → "Kingston, Jamaica").
|
|
22
|
+
|
|
23
|
+
Profile forms must store street, town and postcode/ZIP (and state when the
|
|
24
|
+
form has it); a form that saves only "street" drops the business from every
|
|
25
|
+
town page. Re-run the town build after changing the parser and compare
|
|
26
|
+
counts per town.
|
|
27
|
+
|
|
28
|
+
## Name search
|
|
29
|
+
|
|
30
|
+
People type "moonlake" for "Moon Lake Spa". Compare a normalised key —
|
|
31
|
+
lower case, accents removed, letters and digits only (`searchKey`) — as well
|
|
32
|
+
as the plain `ILIKE` match, and rank exact and prefix matches first
|
|
33
|
+
(`nameMatchRank`). In Postgres keep the key in a generated column with an
|
|
34
|
+
index:
|
|
35
|
+
|
|
36
|
+
```sql
|
|
37
|
+
ALTER TABLE businesses ADD COLUMN name_key text
|
|
38
|
+
GENERATED ALWAYS AS (regexp_replace(lower(unaccent_immutable(name)), '[^a-z0-9]+', '', 'g')) STORED;
|
|
39
|
+
CREATE INDEX businesses_name_key_trgm ON businesses USING gin (name_key gin_trgm_ops);
|
|
40
|
+
|
|
41
|
+
SELECT id, name,
|
|
42
|
+
CASE WHEN name_key = $2 THEN 0 WHEN name_key LIKE $2 || '%' THEN 1
|
|
43
|
+
WHEN name ILIKE $1 || '%' OR name ILIKE '% ' || $1 || '%' THEN 2 ELSE 3 END AS rank
|
|
44
|
+
FROM businesses
|
|
45
|
+
WHERE name ILIKE '%' || $1 || '%' OR name_key LIKE '%' || $2 || '%'
|
|
46
|
+
ORDER BY rank, name LIMIT 20;
|
|
47
|
+
-- $1 = the trimmed query, $2 = searchKey(query)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
(`unaccent` is not immutable; wrap it in an immutable SQL function, or drop
|
|
51
|
+
it when names are ASCII.) Suggestions use the same ranking.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maggie marketplace directory helpers: town parsing and name search.
|
|
3
|
+
*
|
|
4
|
+
* One plain-JavaScript module (no build step) imported by both the server and node scripts (sitemaps, maps,
|
|
5
|
+
* town pages), so the directory and every generated artefact count the same towns. Copies drift: a map script's
|
|
6
|
+
* private copy of the parser had already lost a rule when the site's was fixed.
|
|
7
|
+
*
|
|
8
|
+
* @typedef {{ slug: string, name: string, label: string, region: string | null }} Town
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const UK_POSTCODE = /\s*\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b\s*$/i;
|
|
12
|
+
const COUNTRY_PART = /^(uk|united kingdom|usa|us|united states|england|scotland|wales|northern ireland|ireland)$/i;
|
|
13
|
+
// Address fragments that sometimes sit where the post town should be.
|
|
14
|
+
const NOT_A_TOWN = /^(above|below|behind|rear|inside|within|retail|retail park|unit|suite|floor|ground floor|first floor|farm)$|\s(rd|road|lane|ln|street|st|avenue|ave|drive|dr|close|way|parade|terrace)\.?$/i;
|
|
15
|
+
|
|
16
|
+
/** @param {string} value */
|
|
17
|
+
export const slugifyTown = (value) => value
|
|
18
|
+
.normalize("NFKD").replace(/[̀-ͯ]/g, "")
|
|
19
|
+
.toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
20
|
+
|
|
21
|
+
// First three ZIP digits → state, for US addresses saved without one (forms that keep street, town and ZIP only).
|
|
22
|
+
/** @type {Array<[number, number, string]>} */
|
|
23
|
+
const ZIP3_STATES = [
|
|
24
|
+
[10, 27, "MA"], [28, 29, "RI"], [30, 38, "NH"], [39, 49, "ME"], [50, 59, "VT"], [60, 69, "CT"], [70, 89, "NJ"],
|
|
25
|
+
[100, 149, "NY"], [150, 196, "PA"], [197, 199, "DE"], [200, 200, "DC"], [201, 201, "VA"], [202, 205, "DC"],
|
|
26
|
+
[206, 219, "MD"], [220, 246, "VA"], [247, 268, "WV"], [270, 289, "NC"], [290, 299, "SC"], [300, 319, "GA"],
|
|
27
|
+
[320, 349, "FL"], [350, 369, "AL"], [370, 385, "TN"], [386, 397, "MS"], [398, 399, "GA"], [400, 427, "KY"],
|
|
28
|
+
[430, 459, "OH"], [460, 479, "IN"], [480, 499, "MI"], [500, 528, "IA"], [530, 549, "WI"], [550, 567, "MN"],
|
|
29
|
+
[569, 569, "DC"], [570, 577, "SD"], [580, 588, "ND"], [590, 599, "MT"], [600, 629, "IL"], [630, 658, "MO"],
|
|
30
|
+
[660, 679, "KS"], [680, 693, "NE"], [700, 714, "LA"], [716, 729, "AR"], [730, 732, "OK"], [733, 733, "TX"],
|
|
31
|
+
[734, 749, "OK"], [750, 799, "TX"], [800, 816, "CO"], [820, 831, "WY"], [832, 838, "ID"], [840, 847, "UT"],
|
|
32
|
+
[850, 865, "AZ"], [870, 884, "NM"], [885, 885, "TX"], [889, 898, "NV"], [900, 961, "CA"], [967, 968, "HI"],
|
|
33
|
+
[970, 979, "OR"], [980, 994, "WA"], [995, 999, "AK"],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** @param {string | null | undefined} zip @returns {string | null} */
|
|
37
|
+
export const stateFromZip = (zip) => {
|
|
38
|
+
const match = (zip || "").trim().match(/^(\d{3})\d{2}(-\d{4})?$/);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const prefix = Number(match[1]);
|
|
41
|
+
return ZIP3_STATES.find(([from, to]) => prefix >= from && prefix <= to)?.[2] || null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Places listed in a US market but outside the US ("…, Kingston, Jamaica"). Extend per market.
|
|
45
|
+
/** @type {Record<string, string>} */
|
|
46
|
+
export const OTHER_COUNTRIES = { jamaica: "JM", "puerto rico": "PR", bahamas: "BS", canada: "CA", mexico: "MX" };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The post town (UK: "…, Leeds LS1 4AB") or city + state (US: "…, Allen, TX 75013, United States") of a place.
|
|
50
|
+
* A US address saved as street only falls back to the place's town and ZIP fields
|
|
51
|
+
* ("503 Maple Avenue West" + "Vienna" + "22180" → "Vienna, VA"); a non-US place in a US market keeps its country.
|
|
52
|
+
*
|
|
53
|
+
* @param {string | null} address formatted address
|
|
54
|
+
* @param {"uk" | "us"} market
|
|
55
|
+
* @param {string | null} [locality] the separate town/city field, when the form has one
|
|
56
|
+
* @param {string | null} [postcode] the separate postcode/ZIP field
|
|
57
|
+
* @returns {Town | null}
|
|
58
|
+
*/
|
|
59
|
+
export function parseTown(address, market, locality, postcode) {
|
|
60
|
+
const parts = (address || "").split(",").map((part) => part.trim()).filter((part) => part && !COUNTRY_PART.test(part));
|
|
61
|
+
if (market === "us") {
|
|
62
|
+
const stateIndex = parts.findIndex((part) => /^[A-Z]{2}(\s+\d{5}(-\d{4})?)?$/.test(part));
|
|
63
|
+
if (stateIndex >= 1) {
|
|
64
|
+
const name = parts[stateIndex - 1].replace(/^\d+\s+/, "");
|
|
65
|
+
const region = parts[stateIndex].slice(0, 2);
|
|
66
|
+
return { slug: slugifyTown(`${name}-${region}`), name, label: `${name}, ${region}`, region };
|
|
67
|
+
}
|
|
68
|
+
const country = OTHER_COUNTRIES[(parts.at(-1) || "").toLowerCase()];
|
|
69
|
+
if (country && parts.length >= 2) {
|
|
70
|
+
const name = /** @type {string} */ (parts.at(-2));
|
|
71
|
+
return { slug: slugifyTown(`${name}-${country}`), name, label: `${name}, ${parts.at(-1)}`, region: country };
|
|
72
|
+
}
|
|
73
|
+
const region = stateFromZip(postcode || parts.join(" ").match(/\b\d{5}(?:-\d{4})?\b/)?.[0]);
|
|
74
|
+
const name = (locality || "").trim();
|
|
75
|
+
return region && name ? { slug: slugifyTown(`${name}-${region}`), name, label: `${name}, ${region}`, region } : null;
|
|
76
|
+
}
|
|
77
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
78
|
+
const name = parts[index].replace(UK_POSTCODE, "").replace(/^\d+[A-Z]?\s+/, "").trim();
|
|
79
|
+
if (!name || NOT_A_TOWN.test(name)) continue;
|
|
80
|
+
if (/\d/.test(name)) break;
|
|
81
|
+
return { slug: slugifyTown(name), name, label: name, region: null };
|
|
82
|
+
}
|
|
83
|
+
const name = (locality || "").trim();
|
|
84
|
+
return name ? { slug: slugifyTown(name), name, label: name, region: null } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Name search key: lower case, accents removed, letters and digits only, so "moonlake", "Moon-Lake" and
|
|
89
|
+
* "Moon Lake Spa" meet. Store it in a generated column (see directory-search.md) and compare both forms.
|
|
90
|
+
* @param {string | null | undefined} value
|
|
91
|
+
*/
|
|
92
|
+
export const searchKey = (value) => (value || "").normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rank names for a query: exact (0) → prefix (1) → word prefix (2) → contains (3); null when it does not match.
|
|
96
|
+
* Plain ILIKE on the display name and the normalised key are both tried.
|
|
97
|
+
* @param {string} name
|
|
98
|
+
* @param {string} query
|
|
99
|
+
* @returns {number | null}
|
|
100
|
+
*/
|
|
101
|
+
export function nameMatchRank(name, query) {
|
|
102
|
+
const key = searchKey(name);
|
|
103
|
+
const q = searchKey(query);
|
|
104
|
+
if (!q) return null;
|
|
105
|
+
const plain = (name || "").toLowerCase();
|
|
106
|
+
const raw = query.trim().toLowerCase();
|
|
107
|
+
if (key === q) return 0;
|
|
108
|
+
if (key.startsWith(q)) return 1;
|
|
109
|
+
if (plain.split(/[^a-z0-9]+/i).some((word) => word.startsWith(raw))) return 2;
|
|
110
|
+
if (key.includes(q) || plain.includes(raw)) return 3;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
@@ -25,6 +25,12 @@ auth, payments, or data; the host app does.
|
|
|
25
25
|
- Before browser QA, run `maggie qa app-preflight --project . --app-dir apps/app`.
|
|
26
26
|
It catches both mistakes and warns about rotating refresh tokens and
|
|
27
27
|
production-backed local servers (see `maggie-qa-workflow`).
|
|
28
|
+
- After an edit, prove the browser runs it: add `--base-url
|
|
29
|
+
http://localhost:8081 --expect-symbol <name from the edit>`. The preflight
|
|
30
|
+
fetches the served entry bundle and fails when the name is missing (stale
|
|
31
|
+
watcher, wrong folder, another session's server), and reports which process
|
|
32
|
+
holds the port and when it started — a server killed (exit 137) and
|
|
33
|
+
replaced by another session shows up as a different pid or start time.
|
|
28
34
|
- Design and validate signed-in routes with `maggie design app-init`,
|
|
29
35
|
`app-collect` and `app-validate` (see `maggie-design`, including the
|
|
30
36
|
[app UI patterns](../maggie-design/references/app-ui-patterns.md)).
|
|
@@ -58,6 +64,11 @@ with `--config-json` when the config is dynamic), `eas.json` and
|
|
|
58
64
|
future binary would accept any JS update, including one that needs a new
|
|
59
65
|
native module. Use one top-level `{"policy": "fingerprint"}`.
|
|
60
66
|
|
|
67
|
+
It also fails when a `package.json` script runs `eas update --non-interactive`
|
|
68
|
+
without `--environment` (EAS refuses it), and warns when an update script has
|
|
69
|
+
no `eas fingerprint:compare` gate or when `eas.json` is not in
|
|
70
|
+
`.fingerprintignore` under the fingerprint policy.
|
|
71
|
+
|
|
61
72
|
It warns about missing export-compliance (`usesNonExemptEncryption`), an
|
|
62
73
|
unpinned `cli.version`, per-platform-only runtime versions (what
|
|
63
74
|
`eas update:configure` writes) and tab-indented `app.json` that
|
|
@@ -88,10 +99,35 @@ prints the exact commands with the cost notice and runs nothing:
|
|
|
88
99
|
→ TestFlight (iOS) or the Play internal track (Android). Add testers in
|
|
89
100
|
App Store Connect / Play Console; internal testing needs no review.
|
|
90
101
|
2. Without `--auto-submit`: `npx eas-cli submit -p ios --profile production --latest`.
|
|
91
|
-
3. JS-only fixes: `npx eas-cli
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
3. JS-only fixes: first `npx eas-cli fingerprint:compare --build-id <installed build>`
|
|
103
|
+
— it must report no difference — then
|
|
104
|
+
`npx eas-cli update --channel production --environment production --platform ios --non-interactive --message "…"`.
|
|
105
|
+
`--environment` is mandatory in non-interactive mode. A native change (new
|
|
106
|
+
module, permission, SDK upgrade) needs a new build; the fingerprint policy
|
|
107
|
+
stops an incompatible update from reaching old binaries.
|
|
108
|
+
|
|
109
|
+
### The fingerprint includes `eas.json`
|
|
110
|
+
|
|
111
|
+
Editing `eas.json` after a build — even only adding `submit.ios.ascAppId` —
|
|
112
|
+
changes the local fingerprint. An update published from that tree targets a
|
|
113
|
+
runtime no installed build has, and EAS reports no error. Add `eas.json` to
|
|
114
|
+
`.fingerprintignore` with the next native build so it stops affecting the
|
|
115
|
+
runtime; until then, publish with the build's own config files:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
maggie mobile plan --project . --app-dir apps/app --platform ios --build-ref <build commit>
|
|
119
|
+
maggie mobile ota --project . --app-dir apps/app --platform ios \
|
|
120
|
+
--build-ref <build commit> --build-id <installed EAS build id> --message "Fix copy" # dry run
|
|
121
|
+
# add --confirm to run it
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`plan --build-ref` lists which of `eas.json` / `package.json` differ from the
|
|
125
|
+
build commit. `ota --confirm` puts the build commit's copies in place, runs
|
|
126
|
+
`eas fingerprint:compare --json` and compares the two hashes (the command
|
|
127
|
+
exits 0 even when they differ), publishes the update only on a match, and
|
|
128
|
+
always restores the working copies — checked by hash — even when a step
|
|
129
|
+
fails. A mismatch with the build's own files means the change needs a new
|
|
130
|
+
native build. `MAGGIE_MOBILE_EAS` overrides the `npx eas-cli` invocation.
|
|
95
131
|
|
|
96
132
|
Show the plan's `costNotice` and wait for confirmation before step 1.
|
|
97
133
|
|
|
@@ -106,7 +142,18 @@ Show the plan's `costNotice` and wait for confirmation before step 1.
|
|
|
106
142
|
and every permission's purpose string.
|
|
107
143
|
- Account deletion must be reachable in the app when sign-up is.
|
|
108
144
|
|
|
109
|
-
## 6.
|
|
145
|
+
## 6. In-person payments and push
|
|
146
|
+
|
|
147
|
+
Tap to Pay and push notifications are server features with a native client;
|
|
148
|
+
the host's payment and notification code stays the source of truth. See
|
|
149
|
+
[payments and push](references/payments-and-push.md) for Stripe Terminal
|
|
150
|
+
(Tap to Pay on iPhone entitlement, Android NFC, one Terminal location per
|
|
151
|
+
branch, the connection-token endpoint on the business's connected account)
|
|
152
|
+
and Expo push as one more channel of the host's notification worker (device
|
|
153
|
+
table, tickets and receipts, pruning `DeviceNotRegistered` tokens, actionable
|
|
154
|
+
notifications).
|
|
155
|
+
|
|
156
|
+
## 7. Readiness report
|
|
110
157
|
|
|
111
158
|
Before a store submission, record: preflight passed, app-validate passed for
|
|
112
159
|
each signed-in route, QA run summary (`maggie qa summary`), the build and
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# In-person payments and push for an Expo business app
|
|
2
|
+
|
|
3
|
+
Both features need a development or store build (not Expo Go) and a server
|
|
4
|
+
that already owns payments and notifications. The app is a thin client.
|
|
5
|
+
|
|
6
|
+
## Tap to Pay and card readers (Stripe Terminal)
|
|
7
|
+
|
|
8
|
+
Server-driven readers (WisePOS E, S700) need no app at all: the server sends
|
|
9
|
+
the amount to the reader and records the payment when Stripe reports the
|
|
10
|
+
PaymentIntent succeeded. Tap to Pay turns the phone itself into the reader
|
|
11
|
+
and needs the native SDK.
|
|
12
|
+
|
|
13
|
+
**Account.** Payments go to the business's own connected account (direct
|
|
14
|
+
charges). Every Terminal call — locations, readers, connection tokens,
|
|
15
|
+
PaymentIntents — passes `stripeAccount: <connected account>`. Refuse reader
|
|
16
|
+
setup until the account can take charges ("Connect payments first").
|
|
17
|
+
|
|
18
|
+
**Locations.** One Terminal location per branch, created once from the
|
|
19
|
+
branch address and stored on the branch (`stripe_terminal_location_id`).
|
|
20
|
+
Readers and Tap to Pay sessions are registered to the branch the device is
|
|
21
|
+
used at; never share one location across branches (receipts, reporting and
|
|
22
|
+
reader discovery are per location).
|
|
23
|
+
|
|
24
|
+
**Connection token endpoint.** `POST /api/.../terminal/connection-token`,
|
|
25
|
+
signed-in staff only, returns `stripe.terminal.connectionTokens.create({location},
|
|
26
|
+
{stripeAccount})`. Scope it to the staff member's business and branch; never
|
|
27
|
+
cache a token server-side.
|
|
28
|
+
|
|
29
|
+
**App.** `@stripe/stripe-terminal-react-native` with its Expo config plugin:
|
|
30
|
+
|
|
31
|
+
- iOS: Tap to Pay on iPhone needs the
|
|
32
|
+
`com.apple.developer.proximity-reader.payment.acceptance` entitlement,
|
|
33
|
+
requested from Apple for the team and added to the provisioning profile,
|
|
34
|
+
iPhone XS or later on a current iOS, and Apple's merchant education screens
|
|
35
|
+
on first use. Location permission is required by the SDK.
|
|
36
|
+
- Android: NFC hardware, a supported Android version, Google Play services,
|
|
37
|
+
and no developer options / rooted device in live mode; the SDK refuses
|
|
38
|
+
otherwise — show a clear "this phone can't take cards" message.
|
|
39
|
+
- Discover with the Tap to Pay discovery method, connect to the branch's
|
|
40
|
+
location, collect and confirm the PaymentIntent created **by the server**
|
|
41
|
+
(amount and metadata come from the sale, never from the app).
|
|
42
|
+
|
|
43
|
+
**Testing.** Test mode with the simulated reader (`simulated-wpe` for
|
|
44
|
+
server-driven readers; the SDK's simulated Tap to Pay reader on device). Keep
|
|
45
|
+
a test case per outcome: approved, declined, cancelled on the device,
|
|
46
|
+
network lost after the tap (reconcile from the webhook, never charge twice).
|
|
47
|
+
|
|
48
|
+
**Store review.** Services consumed outside the app (a treatment at the
|
|
49
|
+
salon) must not use in-app purchase (App Store guideline 3.1.3(e), goods and
|
|
50
|
+
services outside of the app); do not sell digital goods through the reader.
|
|
51
|
+
|
|
52
|
+
## Push as a notification channel
|
|
53
|
+
|
|
54
|
+
Push is one more channel of the host's notification worker (beside e-mail and
|
|
55
|
+
SMS), not a separate system.
|
|
56
|
+
|
|
57
|
+
- **Device table**: `user_id`, `business_id`, `expo_push_token`, `platform`,
|
|
58
|
+
`app_version`, `last_seen_at`, `disabled_at`. Register after sign-in and
|
|
59
|
+
permission, re-register on every app start (tokens change), and delete on
|
|
60
|
+
sign-out. One user may have several devices.
|
|
61
|
+
- **Sending**: the worker batches up to 100 messages per request to the Expo
|
|
62
|
+
push API with the project's access token, and stores each **ticket id**
|
|
63
|
+
with the outbox row. Use the same once-only guard as e-mail (outbox key per
|
|
64
|
+
event + recipient + device).
|
|
65
|
+
- **Receipts**: about 15 minutes later, fetch receipts for the stored ticket
|
|
66
|
+
ids. `DeviceNotRegistered` → disable the token; `MessageTooBig`,
|
|
67
|
+
`MessageRateExceeded`, `InvalidCredentials` → alert, do not retry blindly.
|
|
68
|
+
- **Pruning**: disable tokens not seen for 90 days and tokens that failed
|
|
69
|
+
with `DeviceNotRegistered`; never keep sending to dead tokens.
|
|
70
|
+
- **Actionable notifications**: register categories in the app
|
|
71
|
+
(`setNotificationCategoryAsync`, e.g. Accept / Decline for a booking
|
|
72
|
+
request). The action calls the normal API with the signed-in session; a
|
|
73
|
+
notification action is never an approval by itself for anything risky.
|
|
74
|
+
- **Content**: no private details on the lock screen by default ("New booking
|
|
75
|
+
request" rather than the client's name and treatment); let the business
|
|
76
|
+
opt in to details.
|
|
77
|
+
- **Preferences**: per event type and channel, stored server-side, honoured
|
|
78
|
+
by the worker before queueing.
|