@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.
Files changed (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -45
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +30 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +52 -5
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +11 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +142 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +154 -8
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. 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. Keep `css-cascade-check` for the rendered-browser proof.
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 update --channel production --platform ios --message "…"`.
92
- A native change (new module, permission, SDK upgrade) needs a new build;
93
- the fingerprint policy stops an incompatible update from reaching old
94
- binaries.
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. Readiness report
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.