abmp-npm 10.3.13 → 10.3.15
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/.claude/skills/wix-data-query/SKILL.md +121 -0
- package/.claude/skills/wix-data-query/references/members-data-latest.md +125 -0
- package/.claude/skills/wix-data-query/references/recipes.md +210 -0
- package/backend/__tests__/url-validation.test.js +69 -0
- package/package.json +1 -1
- package/pages/personalDetails.js +23 -40
- package/public/Utils/personalDetailsUtils.js +5 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wix-data-query
|
|
3
|
+
description: Query the ABMP/ASCP/AHP Wix CMS collections (MembersDataLatest, SiteConfigs, etc.) via the Wix Data REST API to investigate member data issues. Use when a bug report, Monday ticket, or support escalation references a specific member ID, profile slug, or email — e.g. "services don't appear on the website", "book now link missing", "member still showing after they dropped", "expired license rendering", "address/lat-long is wrong", "upgraded membership didn't sync". Also use when you need a collection's real field names, types, or query operators before writing backend code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Wix Data queries for member data investigations
|
|
7
|
+
|
|
8
|
+
Read the live CMS record before theorising. Most "the website is wrong" tickets are
|
|
9
|
+
answered in one query: the site renders what is in `MembersDataLatest`, so if the field
|
|
10
|
+
is wrong there, the bug is in the sync (`backend/daily-pull/`), not in the UI.
|
|
11
|
+
|
|
12
|
+
## 1. Pick the site
|
|
13
|
+
|
|
14
|
+
Every association is a **separate Wix site with its own copy of the collections**. A member
|
|
15
|
+
who exists on ABMP may not exist on ASCP. Always confirm which site the ticket is about —
|
|
16
|
+
the profile URL tells you (`abmpmembers.com` → ABMP, `ascpskincare.com` → ASCP).
|
|
17
|
+
|
|
18
|
+
| Site | siteId | Env |
|
|
19
|
+
| ---------------------- | -------------------------------------- | ---- |
|
|
20
|
+
| ABMP Members Directory | `384d680a-2870-4086-bda0-9894ce4503b8` | prod |
|
|
21
|
+
| ASCP Members Directory | `1cb02bba-3a36-45e0-bdb4-1a1a2cfe2fdc` | prod |
|
|
22
|
+
| AHP Members Directory | `5553798e-c71e-4a58-9b9e-515803823429` | prod |
|
|
23
|
+
| Test ABMP Members | `cd9fca47-63d3-4538-b26c-1f91ad0a9420` | test |
|
|
24
|
+
| Test ASCP Members | `8c031731-3f58-4d5f-b7dc-6ccabd1b5722` | test |
|
|
25
|
+
| Test AHP Members | `4535a35f-439d-4558-8e68-9000258e2a2a` | test |
|
|
26
|
+
|
|
27
|
+
Collection IDs are in [`public/consts.js`](../../../public/consts.js) under `COLLECTIONS`.
|
|
28
|
+
The main one is `MembersDataLatest`.
|
|
29
|
+
|
|
30
|
+
## 2. Pick the auth path
|
|
31
|
+
|
|
32
|
+
**Path A — Wix MCP (preferred when the `CallWixSiteAPI` tool is available).** No secrets to
|
|
33
|
+
handle; auth is already managed. This is the path used to validate every recipe in this skill.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
CallWixSiteAPI(
|
|
37
|
+
siteId: "384d680a-2870-4086-bda0-9894ce4503b8",
|
|
38
|
+
url: "https://www.wixapis.com/wix-data/v2/items/query",
|
|
39
|
+
method: "POST",
|
|
40
|
+
sourceDocUrl: "https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/query-data-items",
|
|
41
|
+
body: { ...see recipes... }
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Path B — raw REST**, for scripts, CI, or any session without the MCP. Requires an admin API
|
|
46
|
+
key from the [API Keys Manager](https://manage.wix.com/account/api-keys). Two headers, per the
|
|
47
|
+
[auth docs](https://dev.wix.com/docs/api-reference/articles/authentication/api-keys/make-api-calls-with-an-api-key):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -s -X POST 'https://www.wixapis.com/wix-data/v2/items/query' \
|
|
51
|
+
-H "Authorization: $WIX_API_KEY" \
|
|
52
|
+
-H "wix-site-id: 384d680a-2870-4086-bda0-9894ce4503b8" \
|
|
53
|
+
-H 'Content-Type: application/json' \
|
|
54
|
+
-d '{"dataCollectionId":"MembersDataLatest","query":{"filter":{"memberId":731898},"paging":{"limit":1}}}'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Read the key from the environment (`$WIX_API_KEY`). Never paste a key into a file, a commit,
|
|
58
|
+
a Monday comment, or a chat message. `MembersDataLatest` is `read: ADMIN` /
|
|
59
|
+
`itemRead: PRIVILEGED`, so a visitor token will return nothing — this is expected, not a bug.
|
|
60
|
+
|
|
61
|
+
## 3. Query
|
|
62
|
+
|
|
63
|
+
Full cookbook with copy-paste bodies: [references/recipes.md](references/recipes.md).
|
|
64
|
+
The single most common one — look a member up by profile slug:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"dataCollectionId": "MembersDataLatest",
|
|
69
|
+
"query": { "filter": { "url": "karriknowles" }, "paging": { "limit": 1 } }
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
By numeric PAC member ID (note: **number, not string**):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"dataCollectionId": "MembersDataLatest",
|
|
78
|
+
"query": { "filter": { "memberId": 731898 }, "paging": { "limit": 1 } }
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 4. Read the result against the field reference
|
|
83
|
+
|
|
84
|
+
[references/members-data-latest.md](references/members-data-latest.md) lists every field, its
|
|
85
|
+
type, and — importantly — the **traps**. The ones that cause wrong conclusions:
|
|
86
|
+
|
|
87
|
+
- **`firstName`, `lastName`, `phone`, `toShowPhone` are encrypted.** They support only
|
|
88
|
+
`EQ`, `NE`, `HAS_SOME`, `EXISTS`. A `CONTAINS`/`STARTS_WITH` name search **fails** — it does
|
|
89
|
+
not silently return nothing, it errors. Search by `fullName` (not encrypted) instead.
|
|
90
|
+
- **Duplicate legacy field pairs exist**: `showAbmp` _and_ `showABMP`, `apiBookingUrl` _and_
|
|
91
|
+
`APIBookingUrl`. Check both before concluding a value is missing.
|
|
92
|
+
- **A `show*` boolean gates almost every "X doesn't appear on the site" ticket.** The data can
|
|
93
|
+
be perfectly correct and still not render because `showBookingUrl` / `showWebsite` /
|
|
94
|
+
`showContactForm` / `showName` is `false`. Check the flag before blaming the sync.
|
|
95
|
+
- **`isVisible` and `action`** control directory presence. `action: "drop"` sets
|
|
96
|
+
`isVisible: false` (see `backend/daily-pull/process-member-methods.js`). A member who should
|
|
97
|
+
have been dropped but is still listed will show `action` other than `drop`, or
|
|
98
|
+
`isVisible: true` — that points at the PAC API payload, not at Wix.
|
|
99
|
+
- **Two date formats, and mixing them fails silently.** `_createdDate` / `_updatedDate` are
|
|
100
|
+
real `DATETIME` fields using `{"$date":"...Z"}`. But dates inside the `memberships` and
|
|
101
|
+
`licenses` arrays are **plain ISO strings** (`"2027-06-12T00:00:00"` — no `Z`, no ms) and
|
|
102
|
+
must be compared as strings. Verified on ABMP prod: filtering
|
|
103
|
+
`memberships.expiration` with `$date` returns **0**; the same filter as a string returns
|
|
104
|
+
**1008**. A zero here is far more often a wrong filter than a clean bill of health.
|
|
105
|
+
- **Totals:** `returnTotalCount` does not return a `total` on this collection. Use
|
|
106
|
+
`POST https://www.wixapis.com/wix-data/v2/items/count` (body: `dataCollectionId` +
|
|
107
|
+
top-level `filter`, no `query` wrapper) → `{"totalCount": N}`.
|
|
108
|
+
|
|
109
|
+
## 5. Rules
|
|
110
|
+
|
|
111
|
+
- **Read-only by default.** Query, count, distinct, aggregate, get-schema are all fine to run
|
|
112
|
+
unprompted during an investigation.
|
|
113
|
+
- **Never write to a production collection without explicit approval in this conversation.**
|
|
114
|
+
Inserts, updates, patches, and `TRUNCATE` change live member-facing data. State exactly what
|
|
115
|
+
you intend to change and on which site, and wait for a yes. When a fix needs testing, use the
|
|
116
|
+
Test site IDs above.
|
|
117
|
+
- **Don't paste member PII into external systems.** These records contain real names, emails,
|
|
118
|
+
phone numbers, and home addresses. Quote the minimum needed — a member ID and the one wrong
|
|
119
|
+
field — when writing a Monday comment or a commit message.
|
|
120
|
+
- Ground endpoints in docs, not memory. If you need an endpoint this skill doesn't cover, find
|
|
121
|
+
it with `SearchWixRESTDocumentation` first.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# `MembersDataLatest` field reference
|
|
2
|
+
|
|
3
|
+
Captured from the live **ABMP Members Directory** collection schema
|
|
4
|
+
(`GET https://www.wixapis.com/wix-data/v2/collections/MembersDataLatest`) on 2026-08-04,
|
|
5
|
+
collection revision `102`. Re-run that call to refresh — the schema is the source of truth,
|
|
6
|
+
this file is a convenience copy.
|
|
7
|
+
|
|
8
|
+
Permissions: `read/insert/update/remove: ADMIN`, `dataPermissions.itemRead: PRIVILEGED`.
|
|
9
|
+
Paging modes: `OFFSET`, `CURSOR`. Supports `COUNT`, `DISTINCT`, `AGGREGATE`.
|
|
10
|
+
|
|
11
|
+
## Identity
|
|
12
|
+
|
|
13
|
+
| Field | Type | Notes |
|
|
14
|
+
| ------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `_id` | TEXT | Wix item ID (system) |
|
|
16
|
+
| `memberId` | **NUMBER** | PAC member ID. Filter with a number, not a string. |
|
|
17
|
+
| `url` | TEXT | Profile slug, e.g. `karriknowles` → `/profile/karriknowles`. Uniqueness enforced by `ensureUniqueUrl` in `backend/daily-pull/process-member-methods.js`. |
|
|
18
|
+
| `generatedUrl` | BOOLEAN | True when the slug was auto-generated rather than PAC-supplied |
|
|
19
|
+
| `wixMemberId` | TEXT | Wix Members app ID |
|
|
20
|
+
| `wixContactId` | TEXT | Wix CRM contact ID |
|
|
21
|
+
| `contactId` | TEXT | Legacy contact ID field |
|
|
22
|
+
| `_owner` | TEXT | System |
|
|
23
|
+
| `_createdDate` / `_updatedDate` | DATETIME | `{"$date":"...Z"}` shape |
|
|
24
|
+
| `pageNumber` | NUMBER | Which PAC API page last wrote this record — useful for tracing a sync run |
|
|
25
|
+
|
|
26
|
+
## Name and contact
|
|
27
|
+
|
|
28
|
+
| Field | Type | Notes |
|
|
29
|
+
| ----------------------------------- | -------------- | ----------------------------------------------------------------------------------- |
|
|
30
|
+
| `firstName` | TEXT | 🔒 **encrypted** — only `EQ`, `NE`, `HAS_SOME`, `EXISTS` |
|
|
31
|
+
| `lastName` | TEXT | 🔒 **encrypted** — same restriction |
|
|
32
|
+
| `fullName` | TEXT | Not encrypted → **use this for name searches** (`CONTAINS`, `STARTS_WITH` all work) |
|
|
33
|
+
| `businessName` / `showBusinessName` | TEXT / BOOLEAN | |
|
|
34
|
+
| `email` | TEXT | Login email |
|
|
35
|
+
| `contactFormEmail` | TEXT | Where contact-form mail goes; diverges from `email` by design |
|
|
36
|
+
| `phone` | TEXT | 🔒 **encrypted** |
|
|
37
|
+
| `toShowPhone` | TEXT | 🔒 **encrypted** — the phone actually rendered |
|
|
38
|
+
| `phones` | ARRAY | Full list from PAC |
|
|
39
|
+
|
|
40
|
+
## Membership and licensing
|
|
41
|
+
|
|
42
|
+
| Field | Type | Notes |
|
|
43
|
+
| --------------- | ------- | -------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `action` | TEXT | From the PAC API: `new` / `update` / `drop` / `none`. Drives `isVisible`. |
|
|
45
|
+
| `isVisible` | BOOLEAN | `action !== 'drop'`. Controls directory listing. |
|
|
46
|
+
| `optOut` | BOOLEAN | Member-chosen suppression, independent of `action` |
|
|
47
|
+
| `memberships` | ARRAY | `{association, membertype, expiration, membersince}` |
|
|
48
|
+
| `licenses` | ARRAY | `{association, state, license, exempt}` — filtered per-site by `filterLicensesByAssociation` |
|
|
49
|
+
| `showLicenseNo` | BOOLEAN | |
|
|
50
|
+
|
|
51
|
+
## Location
|
|
52
|
+
|
|
53
|
+
| Field | Type | Notes |
|
|
54
|
+
| ---------------------- | ------ | ---------------------------------------------------------------------------------- |
|
|
55
|
+
| `addresses` | ARRAY | `{key, line1, line2, city, state, postalcode, latitude, longitude, addressStatus}` |
|
|
56
|
+
| `addressDisplayOption` | ARRAY | `[{key, isMain}]` — which address is primary |
|
|
57
|
+
| `addressInfo` | OBJECT | Map of address `key` → display mode |
|
|
58
|
+
| `locHash` | ARRAY | Geohash (precision 3, see `GEO_HASH_PRECISION`) used for proximity search |
|
|
59
|
+
|
|
60
|
+
`addressStatus` values come from `ADDRESS_STATUS_TYPES` in `public/consts.js`:
|
|
61
|
+
`full_address`, `state_city_zip`, `dont_show`.
|
|
62
|
+
|
|
63
|
+
## Profile content
|
|
64
|
+
|
|
65
|
+
| Field | Type | Notes |
|
|
66
|
+
| ------------------ | ------------- | ----------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| `areasOfPractices` | ARRAY | **This is the "services" list** members complain about. There is no field literally named `services`. |
|
|
68
|
+
| `aboutService` | RICH_TEXT | HTML string |
|
|
69
|
+
| `testimonial` | ARRAY | Free-text testimonials |
|
|
70
|
+
| `gallery` | MEDIA_GALLERY | |
|
|
71
|
+
| `bannerImages` | ARRAY | |
|
|
72
|
+
| `profileImage` | IMAGE | `wix:image://` URI |
|
|
73
|
+
| `logoImage` | URL | |
|
|
74
|
+
| `title` | TEXT | Default CMS field, generally unused |
|
|
75
|
+
|
|
76
|
+
## Display flags — check these first on "X doesn't appear" tickets
|
|
77
|
+
|
|
78
|
+
| Field | Type |
|
|
79
|
+
| ----------------------------- | ------- | --------------------------------- |
|
|
80
|
+
| `showName` | BOOLEAN |
|
|
81
|
+
| `showPhone` | BOOLEAN |
|
|
82
|
+
| `showWebsite` | BOOLEAN |
|
|
83
|
+
| `showWixUrl` | BOOLEAN |
|
|
84
|
+
| `showContactForm` | BOOLEAN |
|
|
85
|
+
| `showBookingUrl` | BOOLEAN |
|
|
86
|
+
| `showBusinessName` | BOOLEAN |
|
|
87
|
+
| `showLicenseNo` | BOOLEAN |
|
|
88
|
+
| `showAbmp` **and** `showABMP` | BOOLEAN | ⚠️ two separate fields both exist |
|
|
89
|
+
|
|
90
|
+
## Links
|
|
91
|
+
|
|
92
|
+
| Field | Type | Notes |
|
|
93
|
+
| --------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
94
|
+
| `website` | URL | Member's own site |
|
|
95
|
+
| `bookingUrl` | URL | Member-entered booking link; rendered only when `showBookingUrl` is `true` |
|
|
96
|
+
| `apiBookingUrl` **and** `APIBookingUrl` | TEXT | ⚠️ two separate fields. Can contain a raw HTML embed blob (e.g. a Genbook `<script>` badge), not just a URL — don't assume it parses as a URL. |
|
|
97
|
+
|
|
98
|
+
## Known traps
|
|
99
|
+
|
|
100
|
+
1. **Encrypted fields reject substring operators.** `CONTAINS` on `firstName` errors out. Use
|
|
101
|
+
`fullName`.
|
|
102
|
+
2. **Duplicate-case field pairs** (`showAbmp`/`showABMP`, `apiBookingUrl`/`APIBookingUrl`) are
|
|
103
|
+
real and both queryable. A value "missing" from one may be present in the other.
|
|
104
|
+
3. **`memberId` is a NUMBER.** `{"memberId": "731898"}` matches nothing and returns an empty
|
|
105
|
+
list rather than an error — the most common false "member not found".
|
|
106
|
+
4. **Per-site collections.** Absence on one site is not absence everywhere.
|
|
107
|
+
5. **Eventually consistent.** A write may not be visible to the next immediate query.
|
|
108
|
+
6. **Two different date formats.** Only true `DATETIME` fields (`_createdDate`,
|
|
109
|
+
`_updatedDate`) use the `{"$date":"...Z"}` form. Dates _inside_ the `memberships` and
|
|
110
|
+
`licenses` arrays — notably `memberships.expiration` and `memberships.membersince` — are
|
|
111
|
+
**plain ISO strings without a `Z` or milliseconds** (`"2027-06-12T00:00:00"`) and must be
|
|
112
|
+
filtered as strings. Using `$date` against them silently returns zero rows rather than
|
|
113
|
+
erroring, which reads as "no affected members" when there may be thousands.
|
|
114
|
+
7. **`returnTotalCount` yields no `total`** on this collection — use
|
|
115
|
+
`POST /wix-data/v2/items/count` instead.
|
|
116
|
+
8. **`{"$ne": ""}` also matches rows where the field is absent.** Verified 2026-08-04: filtering
|
|
117
|
+
`{"website": {"$ne": ""}}` returned 300 rows whose projected `website` and `bookingUrl` came
|
|
118
|
+
back empty — the field simply wasn't set on them. So `$ne ""` is _not_ "has a value", and any
|
|
119
|
+
count built on it is inflated. To mean "actually has a value", pair it with
|
|
120
|
+
`{"$exists": true}` or filter/verify client-side after projecting the field.
|
|
121
|
+
|
|
122
|
+
## No regex filtering
|
|
123
|
+
|
|
124
|
+
Wix Data has no regex operator, so you cannot ask the API questions like "domains containing a
|
|
125
|
+
digit". Project the field, page through, and evaluate in JS.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Query cookbook
|
|
2
|
+
|
|
3
|
+
All bodies below are the JSON body for
|
|
4
|
+
`POST https://www.wixapis.com/wix-data/v2/items/query`
|
|
5
|
+
([docs](https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/query-data-items)).
|
|
6
|
+
|
|
7
|
+
Send them via `CallWixSiteAPI(siteId, url, method: "POST", body)` or via curl with
|
|
8
|
+
`Authorization: $WIX_API_KEY` + `wix-site-id: <siteId>`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Look up one member
|
|
13
|
+
|
|
14
|
+
By profile slug (from the URL in the ticket):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"dataCollectionId": "MembersDataLatest",
|
|
19
|
+
"query": { "filter": { "url": "karriknowles" }, "paging": { "limit": 1 } }
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
By PAC member ID — **number, not string**:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"dataCollectionId": "MembersDataLatest",
|
|
28
|
+
"query": { "filter": { "memberId": 731898 }, "paging": { "limit": 1 } }
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
By email:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"dataCollectionId": "MembersDataLatest",
|
|
37
|
+
"query": { "filter": { "email": "someone@example.com" }, "paging": { "limit": 1 } }
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
By name — `fullName` only, never the encrypted `firstName`/`lastName`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"dataCollectionId": "MembersDataLatest",
|
|
46
|
+
"query": { "filter": { "fullName": { "$contains": "Knowles" } }, "paging": { "limit": 20 } }
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Return only the fields you care about
|
|
51
|
+
|
|
52
|
+
Large records (galleries, testimonials, rich text) drown the useful bits. Project:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"dataCollectionId": "MembersDataLatest",
|
|
57
|
+
"query": {
|
|
58
|
+
"filter": { "memberId": 949741 },
|
|
59
|
+
"fields": [
|
|
60
|
+
"memberId",
|
|
61
|
+
"url",
|
|
62
|
+
"fullName",
|
|
63
|
+
"bookingUrl",
|
|
64
|
+
"showBookingUrl",
|
|
65
|
+
"apiBookingUrl",
|
|
66
|
+
"APIBookingUrl",
|
|
67
|
+
"isVisible",
|
|
68
|
+
"action"
|
|
69
|
+
],
|
|
70
|
+
"paging": { "limit": 1 }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Count / scope an issue
|
|
76
|
+
|
|
77
|
+
How many members are affected, without pulling them all. Use the **dedicated count
|
|
78
|
+
endpoint** — `POST https://www.wixapis.com/wix-data/v2/items/count`
|
|
79
|
+
([docs](https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/count-data-items)).
|
|
80
|
+
Note the different body shape: `filter` sits at the top level, there is no `query` wrapper.
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"dataCollectionId": "MembersDataLatest",
|
|
85
|
+
"filter": { "showBookingUrl": false, "bookingUrl": { "$ne": "" } }
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Returns `{"totalCount": 82449}`.
|
|
90
|
+
|
|
91
|
+
> ⚠️ **Do not rely on `returnTotalCount` in the query endpoint.** Verified 2026-08-04 against
|
|
92
|
+
> `MembersDataLatest`: passing `"returnTotalCount": true` — with or without an explicit
|
|
93
|
+
> `paging.offset` — returns `pagingMetadata` containing `count`, `offset`, `tooManyToCount`,
|
|
94
|
+
> `cursors` and `hasNext`, but **no `total` field**. Use `/items/count` for totals.
|
|
95
|
+
|
|
96
|
+
## Members who should have been dropped but are still visible
|
|
97
|
+
|
|
98
|
+
The shape behind the "multies not renewing / expired members still listed" class of ticket:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"dataCollectionId": "MembersDataLatest",
|
|
103
|
+
"query": {
|
|
104
|
+
"filter": { "action": "drop", "isVisible": true },
|
|
105
|
+
"fields": ["memberId", "url", "fullName", "action", "isVisible", "memberships"],
|
|
106
|
+
"paging": { "limit": 100 }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Inverse — visible members whose membership already expired:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"dataCollectionId": "MembersDataLatest",
|
|
116
|
+
"query": {
|
|
117
|
+
"filter": { "isVisible": true, "memberships.expiration": { "$lt": "2026-08-04T00:00:00" } },
|
|
118
|
+
"fields": ["memberId", "url", "memberships", "action"],
|
|
119
|
+
"paging": { "limit": 100 }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> ⚠️ **`memberships.expiration` is a plain ISO string, not a date.** Compare it as a string.
|
|
125
|
+
> Verified 2026-08-04 on ABMP prod: `{"$lt": {"$date": "2026-08-04T00:00:00.000Z"}}` counts
|
|
126
|
+
> **0**, while `{"$lt": "2026-08-04T00:00:00"}` counts **1008**. The `$date` wrapper is only
|
|
127
|
+
> correct for true DATETIME fields such as `_createdDate` / `_updatedDate`. Note the stored
|
|
128
|
+
> strings have no `Z` suffix and no milliseconds — match that format.
|
|
129
|
+
|
|
130
|
+
## Recently synced records
|
|
131
|
+
|
|
132
|
+
Useful for confirming whether a nightly run touched a member at all:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"dataCollectionId": "MembersDataLatest",
|
|
137
|
+
"query": {
|
|
138
|
+
"filter": { "_updatedDate": { "$gte": { "$date": "2026-08-01T00:00:00.000Z" } } },
|
|
139
|
+
"sort": [{ "fieldName": "_updatedDate", "order": "DESC" }],
|
|
140
|
+
"fields": ["memberId", "url", "_updatedDate", "action", "pageNumber"],
|
|
141
|
+
"paging": { "limit": 50 }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Combining conditions
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"dataCollectionId": "MembersDataLatest",
|
|
151
|
+
"query": {
|
|
152
|
+
"filter": {
|
|
153
|
+
"$and": [
|
|
154
|
+
{ "isVisible": true },
|
|
155
|
+
{ "$or": [{ "showBookingUrl": true }, { "showWebsite": true }] }
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
"paging": { "limit": 25 }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Paging past 100
|
|
164
|
+
|
|
165
|
+
Offset paging (each request may carry its own filter/sort):
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"dataCollectionId": "MembersDataLatest",
|
|
170
|
+
"query": { "filter": {}, "paging": { "limit": 100, "offset": 100 } }
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Cursor paging for a long scan — set `filter`/`sort` on the **first** request only, then pass
|
|
175
|
+
back `pagingMetadata.cursors.next` alone:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"dataCollectionId": "MembersDataLatest",
|
|
180
|
+
"query": { "cursorPaging": { "limit": 100, "cursor": "<cursors.next>" } }
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Other endpoints
|
|
187
|
+
|
|
188
|
+
**Collection schema** — field names, types, and which operators each field allows:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
GET https://www.wixapis.com/wix-data/v2/collections/MembersDataLatest
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**List all collections on the site:**
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
GET https://www.wixapis.com/wix-data/v2/collections
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Full-text search** across fields (`POST .../wix-data/v2/items/search`) — note it takes
|
|
201
|
+
`data_collection_id` (snake_case) and a `search` object rather than `query`. Only works on
|
|
202
|
+
CMS-native collections. See
|
|
203
|
+
[Search Data Items](https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/search-data-items).
|
|
204
|
+
|
|
205
|
+
**Aggregate** (`AGGREGATE` is supported on `MembersDataLatest`) for group-by counts, e.g.
|
|
206
|
+
distribution of `action` values across the directory.
|
|
207
|
+
|
|
208
|
+
Other collections worth knowing, from `COLLECTIONS` in `public/consts.js`:
|
|
209
|
+
`SiteConfigs`, `CompiledStateCityMap`, `State`, `City`, `interests`,
|
|
210
|
+
`contactUsSubmissions`, `updatedLoginEmails`, `QA_Users`, `ButtonClicks`.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const { isNotValidUrl } = require('../../public/Utils/personalDetailsUtils');
|
|
2
|
+
|
|
3
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const isValid = url => !isNotValidUrl(url);
|
|
6
|
+
|
|
7
|
+
// ─── Regression: digits in the hostname ──────────────────────────────
|
|
8
|
+
// Monday bug 12663709539 - a member could not save the booking link
|
|
9
|
+
// https://patty-10439.square.site because the host character class was
|
|
10
|
+
// written `[da-z.-]` instead of `[\da-z.-]`, rejecting every domain
|
|
11
|
+
// containing a digit.
|
|
12
|
+
|
|
13
|
+
describe('isNotValidUrl - digits in hostname', () => {
|
|
14
|
+
it('accepts the exact URL from the bug report', () => {
|
|
15
|
+
expect(isValid('https://patty-10439.square.site')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it.each([
|
|
19
|
+
'https://my-spa123.com',
|
|
20
|
+
'https://massage4u.net',
|
|
21
|
+
'https://booksy.com/en-us/698924_therapist_health-fitness_119607_city',
|
|
22
|
+
'https://www.genbook.com/bookings/slot/reservation/30241562?bookingSourceId=1000',
|
|
23
|
+
'www.spa2go.com',
|
|
24
|
+
'https://123.example.com',
|
|
25
|
+
])('accepts %s', url => {
|
|
26
|
+
expect(isValid(url)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('isNotValidUrl - case insensitivity', () => {
|
|
31
|
+
it.each(['https://Patty-10439.Square.Site', 'HTTPS://EXAMPLE.COM', 'WWW.Example.Com'])(
|
|
32
|
+
'accepts %s',
|
|
33
|
+
url => {
|
|
34
|
+
expect(isValid(url)).toBe(true);
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ─── Guard against regressions in the previously-working cases ───────
|
|
40
|
+
|
|
41
|
+
describe('isNotValidUrl - previously valid URLs stay valid', () => {
|
|
42
|
+
it.each([
|
|
43
|
+
'https://square.site',
|
|
44
|
+
'https://patty.square.site',
|
|
45
|
+
'http://healinghut.massagetherapy.com',
|
|
46
|
+
'www.example.com',
|
|
47
|
+
'https://example.co.uk',
|
|
48
|
+
'https://example.com/path/to/page',
|
|
49
|
+
'https://example.com?foo=bar',
|
|
50
|
+
'https://example.com#anchor',
|
|
51
|
+
])('accepts %s', url => {
|
|
52
|
+
expect(isValid(url)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('isNotValidUrl - invalid input is still rejected', () => {
|
|
57
|
+
it.each(['not a url', 'example', 'ftp://example.com', 'justtext.c', 'http://'])(
|
|
58
|
+
'rejects %s',
|
|
59
|
+
url => {
|
|
60
|
+
expect(isValid(url)).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it('treats an empty value as valid because the field is optional', () => {
|
|
65
|
+
expect(isValid('')).toBe(true);
|
|
66
|
+
expect(isValid(undefined)).toBe(true);
|
|
67
|
+
expect(isValid(null)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/package.json
CHANGED
package/pages/personalDetails.js
CHANGED
|
@@ -975,36 +975,10 @@ async function personalDetailsOnReady({
|
|
|
975
975
|
});
|
|
976
976
|
}
|
|
977
977
|
|
|
978
|
-
async function handleItemDelete(event, getTextSelector, arrayRef, matchField, renderFn) {
|
|
979
|
-
const result = await wixWindow.openLightbox(LIGHTBOX_NAMES.DELETE_CONFIRM);
|
|
980
|
-
|
|
981
|
-
if (result && result.toDelete) {
|
|
982
|
-
const $clickedItem = _$w.at(event.context);
|
|
983
|
-
const textToRemove = $clickedItem(getTextSelector).text;
|
|
984
|
-
|
|
985
|
-
arrayRef.splice(
|
|
986
|
-
0,
|
|
987
|
-
arrayRef.length,
|
|
988
|
-
...arrayRef.filter(item =>
|
|
989
|
-
typeof item === 'string' ? item !== textToRemove : item[matchField] !== textToRemove
|
|
990
|
-
)
|
|
991
|
-
);
|
|
992
|
-
|
|
993
|
-
renderFn();
|
|
994
|
-
checkFormChanges(FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES);
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
978
|
async function setInterestData() {
|
|
999
979
|
const interestsData = await getInterestAll();
|
|
1000
980
|
|
|
1001
981
|
_$w('#removeServiceButton').onClick(async event => {
|
|
1002
|
-
// Capture the stable item id BEFORE awaiting the confirm lightbox.
|
|
1003
|
-
// Reading the chip's text after the await is unsafe: the repeater
|
|
1004
|
-
// recycles its DOM items on re-render, so after a rapid sequence of
|
|
1005
|
-
// deletes event.context can resolve to a recycled node showing a
|
|
1006
|
-
// different service, removing the wrong item (or none). Matching on
|
|
1007
|
-
// the stable _id (as the gallery delete does) avoids that race.
|
|
1008
982
|
const itemId = event.context.itemId;
|
|
1009
983
|
const result = await wixWindow.openLightbox(LIGHTBOX_NAMES.DELETE_CONFIRM);
|
|
1010
984
|
|
|
@@ -1090,9 +1064,6 @@ async function personalDetailsOnReady({
|
|
|
1090
1064
|
}
|
|
1091
1065
|
|
|
1092
1066
|
function renderServices() {
|
|
1093
|
-
// Pass a fresh array copy so the Wix repeater detects the change and
|
|
1094
|
-
// re-renders. Assigning the same array reference (mutated in place by
|
|
1095
|
-
// add/delete) is treated as a no-op, leaving stale chips on screen.
|
|
1096
1067
|
setupRepeater('#servicesRepeater', [...selectedServices]);
|
|
1097
1068
|
}
|
|
1098
1069
|
|
|
@@ -1291,9 +1262,10 @@ async function personalDetailsOnReady({
|
|
|
1291
1262
|
console.groupEnd();
|
|
1292
1263
|
|
|
1293
1264
|
const result = await saveData(formData);
|
|
1294
|
-
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.PERSONAL.section] = false;
|
|
1295
1265
|
|
|
1296
1266
|
if (result.success) {
|
|
1267
|
+
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.PERSONAL.section] = false;
|
|
1268
|
+
|
|
1297
1269
|
if (personalChanges.url && personalChanges.url !== originalUrl) {
|
|
1298
1270
|
const newProfileLink = `${baseUrl}/profile/${personalChanges.url}`;
|
|
1299
1271
|
console.log('🔗 Updating profile link:', {
|
|
@@ -1346,7 +1318,10 @@ async function personalDetailsOnReady({
|
|
|
1346
1318
|
console.groupEnd();
|
|
1347
1319
|
|
|
1348
1320
|
const result = await saveData(formData);
|
|
1349
|
-
|
|
1321
|
+
if (result.success) {
|
|
1322
|
+
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES.section] = false;
|
|
1323
|
+
_$w('#saveBusinessButton').disable();
|
|
1324
|
+
}
|
|
1350
1325
|
handleSaveDataFeedback(_$w('#businessMessage'), result.message);
|
|
1351
1326
|
_$w('#businessNameText').text = formData.businessName || DEFAULT_BUSINESS_NAME_TEXT;
|
|
1352
1327
|
}
|
|
@@ -1355,14 +1330,21 @@ async function personalDetailsOnReady({
|
|
|
1355
1330
|
const addTestimonialButton = _$w('#addTestimonialButton');
|
|
1356
1331
|
|
|
1357
1332
|
addTestimonialButton.onClick(handleAddTestimonial);
|
|
1358
|
-
_$w('#deleteTestimonialButton').onClick(event => {
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1333
|
+
_$w('#deleteTestimonialButton').onClick(async event => {
|
|
1334
|
+
const data = _$w('#testimonialRepeater').data || [];
|
|
1335
|
+
const clickedIndex = data.findIndex(item => item._id === event.context.itemId);
|
|
1336
|
+
const result = await wixWindow.openLightbox(LIGHTBOX_NAMES.DELETE_CONFIRM);
|
|
1337
|
+
|
|
1338
|
+
if (
|
|
1339
|
+
result &&
|
|
1340
|
+
result.toDelete &&
|
|
1341
|
+
clickedIndex > 0 &&
|
|
1342
|
+
Array.isArray(itemMemberObj.testimonial)
|
|
1343
|
+
) {
|
|
1344
|
+
itemMemberObj.testimonial.splice(clickedIndex - 1, 1);
|
|
1345
|
+
renderTestimonials();
|
|
1346
|
+
checkFormChanges(FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES);
|
|
1347
|
+
}
|
|
1366
1348
|
});
|
|
1367
1349
|
|
|
1368
1350
|
renderTestimonials();
|
|
@@ -2402,8 +2384,9 @@ async function personalDetailsOnReady({
|
|
|
2402
2384
|
// Sync Personal Details opt-in from saved member.
|
|
2403
2385
|
_$w('#optWebsiteCheckbox').checked = itemMemberObj.showWixUrl;
|
|
2404
2386
|
toggleFreeWebsiteText(itemMemberObj.showWixUrl);
|
|
2387
|
+
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.CONTACT_BOOKING.section] = false;
|
|
2388
|
+
_$w('#saveContactBookingButton').disable();
|
|
2405
2389
|
}
|
|
2406
|
-
formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.CONTACT_BOOKING.section] = false;
|
|
2407
2390
|
handleSaveDataFeedback(_$w('#contactMessage'), result.message);
|
|
2408
2391
|
}
|
|
2409
2392
|
|
|
@@ -13,8 +13,12 @@ function isNotValidUrl(url) {
|
|
|
13
13
|
if (!url) return false;
|
|
14
14
|
|
|
15
15
|
// URL must start with protocol OR www - handles all TLDs including multi-level and query params
|
|
16
|
+
// NOTE: the host class is `[\da-z.-]` (digit, letter, dot, hyphen). It previously read
|
|
17
|
+
// `[da-z.-]`, which - missing the backslash - matched only a literal "d" plus a-z, so any
|
|
18
|
+
// domain containing a digit was rejected (e.g. https://patty-10439.square.site).
|
|
19
|
+
// The `i` flag keeps mixed-case hosts valid; domains are case-insensitive.
|
|
16
20
|
const urlRegex =
|
|
17
|
-
/^(https?:\/\/|www\.)([da-z.-]+)\.([a-z.]{2,})([/\w .-]*)*(\?[&\w=.-]*)?(#[&\w=.-]*)
|
|
21
|
+
/^(https?:\/\/|www\.)([\da-z.-]+)\.([a-z.]{2,})([/\w .-]*)*(\?[&\w=.-]*)?(#[&\w=.-]*)?\/?$/i;
|
|
18
22
|
|
|
19
23
|
return !urlRegex.test(url);
|
|
20
24
|
}
|