vairified 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,7 +52,7 @@ Every operation lives on a sub-resource that mirrors the REST path:
52
52
 
53
53
  | Sub-resource | Operations |
54
54
  |-------------------------|------------------------------------------------------------------|
55
- | `client.members` | `get`, `getBulk`, `search`, `find`, `ratingUpdates` |
55
+ | `client.members` | `get`, `getBulk`, `getByEmail`, `search`, `find`, `ratingUpdates` |
56
56
  | `client.matches` | `submit`, `tournamentImport`, `testWebhook` |
57
57
  | `client.oauth` | `authorize`, `exchangeToken`, `refresh`, `revoke` |
58
58
  | `client.webhooks` | `deliveries` |
@@ -100,6 +100,34 @@ const pb = await client.members.getBulk([4873327], { sport: 'pickleball' });
100
100
 
101
101
  Unknown IDs are silently omitted — the returned array may be shorter than the input.
102
102
 
103
+ ### Look members up by email
104
+
105
+ Resolve up to 100 members by their **exact** email address — useful for linking
106
+ your users to their VAIR identity when you hold their email but not their member
107
+ ID, instead of waiting for each player to complete SSO:
108
+
109
+ ```ts
110
+ const result = await client.members.getByEmail(['ada@example.com', 'nobody@example.com']);
111
+
112
+ for (const match of result.matched) {
113
+ const member = match.sole; // null when the address is ambiguous
114
+ if (member) console.log(match.email, '->', member.memberId);
115
+ }
116
+
117
+ // Read notFound directly — don't diff your input against the results
118
+ console.log('no VAIR account found for:', result.notFound);
119
+ ```
120
+
121
+ Requires the **`key:player:lookup`** scope, granted per partner on approval —
122
+ holding `key:player:search` does not imply it. Ask VAIR to enable it for your app.
123
+
124
+ Unlike `getBulk`, **nothing is silently dropped**: every address you supply comes
125
+ back in either `matched` or `notFound`. Matching is exact and case-insensitive
126
+ (no partial or fuzzy matching), and `match.members` is an array because an email
127
+ is not a unique key in VAIR — use `.sole` or check `.isAmbiguous` rather than
128
+ assuming a single result. A `notFound` address is not proof the person has no VAIR
129
+ account: unclaimed imported records are excluded from this lookup.
130
+
103
131
  ### Filter ratings to specific sports
104
132
 
105
133
  ```ts
package/dist/index.cjs CHANGED
@@ -27,7 +27,9 @@ __export(index_exports, {
27
27
  MatchBatchResult: () => MatchBatchResult,
28
28
  MatchesResource: () => MatchesResource,
29
29
  Member: () => Member,
30
+ MemberEmailMatch: () => MemberEmailMatch,
30
31
  MemberSportMap: () => MemberSportMap,
32
+ MembersByEmailResult: () => MembersByEmailResult,
31
33
  MembersResource: () => MembersResource,
32
34
  NotFoundError: () => NotFoundError,
33
35
  OAuthError: () => OAuthError,
@@ -307,6 +309,19 @@ var TournamentImportResult = class {
307
309
  dryRun;
308
310
  message;
309
311
  errors;
312
+ /**
313
+ * Public member ids for the ghost players THIS import created, keyed by the
314
+ * `ref` supplied in `ghostMembers[]`.
315
+ *
316
+ * Empty on a dry-run, and empty for entries the import matched to a player who
317
+ * already existed — resolving an existing email to a member requires the
318
+ * `key:player:lookup` scope and `members.getByEmail()`.
319
+ *
320
+ * Use these ids directly in a follow-up `matches.submit()`; without them an
321
+ * import reports only how many accounts it caused and you cannot address any
322
+ * of them.
323
+ */
324
+ createdGhostMembers;
310
325
  /** @internal */
311
326
  constructor(wire) {
312
327
  this.success = wire.success;
@@ -317,6 +332,11 @@ var TournamentImportResult = class {
317
332
  this.dryRun = wire.dryRun ?? false;
318
333
  this.message = wire.message;
319
334
  this.errors = Object.freeze(wire.errors ?? []);
335
+ this.createdGhostMembers = Object.freeze(
336
+ (wire.createdGhostMembers ?? []).map(
337
+ (entry) => Object.freeze({ ref: entry.ref, memberId: entry.memberId })
338
+ )
339
+ );
320
340
  Object.freeze(this);
321
341
  }
322
342
  /** True when the import succeeded without errors. */
@@ -519,6 +539,20 @@ var Member = class {
519
539
  state;
520
540
  zip;
521
541
  country;
542
+ /**
543
+ * The DATE this member's VAIR account was created (`YYYY-MM-DD`, UTC), or
544
+ * `null` when the endpoint does not supply it.
545
+ *
546
+ * Present on `members.get()`, `members.getBulk()` and `members.getByEmail()` —
547
+ * the calls where you already know which member you asked about. **Never on
548
+ * `members.search()`**, which is discovery: account age is not something you
549
+ * can browse strangers by.
550
+ *
551
+ * Deliberately a date, not a timestamp. It exists so you can apply a
552
+ * new-accounts-only referral rule — crediting an ambassador only for accounts
553
+ * created because of their event.
554
+ */
555
+ memberSince;
522
556
  gender;
523
557
  status;
524
558
  sport;
@@ -537,6 +571,7 @@ var Member = class {
537
571
  this.state = wire.state ?? null;
538
572
  this.zip = wire.zip ?? null;
539
573
  this.country = wire.country ?? null;
574
+ this.memberSince = wire.memberSince ?? null;
540
575
  this.gender = wire.gender ?? null;
541
576
  this.status = Object.freeze({ ...wire.status });
542
577
  this.sport = new MemberSportMap(wire.sport);
@@ -584,6 +619,70 @@ var Member = class {
584
619
  }
585
620
  };
586
621
 
622
+ // src/models/members-by-email-result.ts
623
+ var MemberEmailMatch = class {
624
+ /** The address exactly as you supplied it, not as stored. */
625
+ email;
626
+ /** Every member holding this address. Never empty. */
627
+ members;
628
+ /** @internal */
629
+ constructor(wire) {
630
+ this.email = wire.email;
631
+ this.members = Object.freeze(wire.members.map((m) => new Member(m)));
632
+ Object.freeze(this);
633
+ }
634
+ /**
635
+ * The single member for this address, or `null` when the address is
636
+ * ambiguous (more than one match).
637
+ *
638
+ * Use this rather than `members[0]` when a wrong link is worse than no
639
+ * link — it refuses to guess instead of silently picking one.
640
+ */
641
+ get sole() {
642
+ return this.members.length === 1 ? this.members[0] ?? null : null;
643
+ }
644
+ /** Whether this address resolved to more than one member. */
645
+ get isAmbiguous() {
646
+ return this.members.length > 1;
647
+ }
648
+ toString() {
649
+ return `MemberEmailMatch ${this.email} -> ${this.members.length} member(s)`;
650
+ }
651
+ };
652
+ var MembersByEmailResult = class {
653
+ matched;
654
+ /** Addresses that resolved to nothing, echoed as you supplied them. */
655
+ notFound;
656
+ /** @internal */
657
+ constructor(wire) {
658
+ this.matched = Object.freeze(wire.matched.map((m) => new MemberEmailMatch(m)));
659
+ this.notFound = Object.freeze([...wire.notFound]);
660
+ Object.freeze(this);
661
+ }
662
+ /**
663
+ * Look up one address's match, case-insensitively.
664
+ *
665
+ * Saves callers a linear scan and, more importantly, saves them from
666
+ * matching case-sensitively against an address the server echoed back
667
+ * in whatever case they originally sent.
668
+ */
669
+ get(email) {
670
+ const needle = email.trim().toLowerCase();
671
+ return this.matched.find((m) => m.email.toLowerCase() === needle) ?? null;
672
+ }
673
+ /** Whether every requested address resolved to at least one member. */
674
+ get allResolved() {
675
+ return this.notFound.length === 0;
676
+ }
677
+ /** Total number of members across every matched address. */
678
+ get memberCount() {
679
+ return this.matched.reduce((n, m) => n + m.members.length, 0);
680
+ }
681
+ toString() {
682
+ return `MembersByEmailResult matched=${this.matched.length} notFound=${this.notFound.length}`;
683
+ }
684
+ };
685
+
587
686
  // src/models/rating-update.ts
588
687
  var RatingUpdate = class {
589
688
  memberId;
@@ -632,6 +731,7 @@ var RatingUpdate = class {
632
731
  // src/resources/members.ts
633
732
  var DEFAULT_PAGE_SIZE = 20;
634
733
  var MAX_PAGE_SIZE = 100;
734
+ var MAX_EMAILS_PER_LOOKUP = 100;
635
735
  var MembersResource = class {
636
736
  #http;
637
737
  /** @internal */
@@ -773,6 +873,70 @@ var MembersResource = class {
773
873
  });
774
874
  return rows.map((row) => new Member(row));
775
875
  }
876
+ /**
877
+ * Resolve up to 100 members by their **exact** email address.
878
+ *
879
+ * Use this to link your users to their VAIR identity when you hold
880
+ * their email but not their member ID — e.g. resolving a tournament
881
+ * roster at registration instead of waiting for each player to
882
+ * complete SSO.
883
+ *
884
+ * **Requires the `key:player:lookup` scope**, which is granted per
885
+ * partner on approval. Holding `key:player:search` does not imply it.
886
+ *
887
+ * Matching is exact and case-insensitive; there is deliberately no
888
+ * partial, prefix or fuzzy matching. Every address you supply comes
889
+ * back in either `matched` or `notFound`, so read `notFound` directly
890
+ * instead of diffing your input against the results.
891
+ *
892
+ * A `notFound` address is **not** proof the person has no VAIR
893
+ * account — unclaimed imported records are excluded from this lookup.
894
+ *
895
+ * @param emails - Email addresses to resolve (max 100).
896
+ * @param options - Optional filters.
897
+ * @param options.sport - Sport code to scope ratings (e.g. `'pickleball'`).
898
+ * @throws {@link ValidationError} If more than 100 addresses are
899
+ * provided, or the list is empty.
900
+ * @category Members
901
+ *
902
+ * @example
903
+ * ```ts
904
+ * const result = await client.members.getByEmail([
905
+ * 'ada@example.com',
906
+ * 'nobody@example.com',
907
+ * ]);
908
+ *
909
+ * for (const match of result.matched) {
910
+ * const member = match.sole; // null when the address is ambiguous
911
+ * if (member) console.log(match.email, '->', member.memberId);
912
+ * }
913
+ *
914
+ * console.log('no VAIR account found for:', result.notFound);
915
+ * ```
916
+ */
917
+ async getByEmail(emails, options) {
918
+ if (emails.length === 0) {
919
+ throw new ValidationError("At least one email address is required");
920
+ }
921
+ if (emails.length > MAX_EMAILS_PER_LOOKUP) {
922
+ throw new ValidationError(`Maximum ${MAX_EMAILS_PER_LOOKUP} email addresses per request`);
923
+ }
924
+ const offender = emails.find((e) => e.includes(","));
925
+ if (offender !== void 0) {
926
+ throw new ValidationError(`Email address must not contain a comma: ${offender}`);
927
+ }
928
+ const query = { emails: emails.join(",") };
929
+ if (options?.sport) query.sport = options.sport;
930
+ const wire = await this.#http.request({
931
+ method: "GET",
932
+ path: "/partner/members/by-email",
933
+ query
934
+ });
935
+ return new MembersByEmailResult({
936
+ matched: wire?.matched ?? [],
937
+ notFound: wire?.notFound ?? []
938
+ });
939
+ }
776
940
  /**
777
941
  * Poll for rating change notifications.
778
942
  *
@@ -1011,6 +1175,207 @@ function tokenResponseFromWire(data) {
1011
1175
  };
1012
1176
  }
1013
1177
 
1178
+ // src/models/attribution.ts
1179
+ var MemberAttribution = class {
1180
+ memberId;
1181
+ /** True when some ambassador already holds credit for this member. */
1182
+ attributed;
1183
+ /**
1184
+ * The member id of the ambassador holding the credit.
1185
+ *
1186
+ * Compare it against your own event host's member id to tell "already credited
1187
+ * to my host — nothing to do" from "credited to somebody else — a person needs
1188
+ * to look, because claiming it takes credit from them".
1189
+ *
1190
+ * `null` both when nobody holds credit and when credit is held by a record that
1191
+ * has no member id of its own, so check {@link attributed} to tell those apart.
1192
+ */
1193
+ ambassadorMemberId;
1194
+ /** The date the credit was established (`YYYY-MM-DD`), or `null`. */
1195
+ attributedAt;
1196
+ /** @internal */
1197
+ constructor(wire) {
1198
+ this.memberId = wire.memberId;
1199
+ this.attributed = wire.attributed;
1200
+ this.ambassadorMemberId = wire.ambassadorMemberId ?? null;
1201
+ this.attributedAt = wire.attributedAt ?? null;
1202
+ Object.freeze(this);
1203
+ }
1204
+ /** True when nobody holds credit yet, so this member can be claimed. */
1205
+ get isClaimable() {
1206
+ return !this.attributed;
1207
+ }
1208
+ /** True when credit is held by an ambassador OTHER than the one given. */
1209
+ heldBySomeoneOtherThan(ambassadorMemberId) {
1210
+ return this.attributed && this.ambassadorMemberId !== ambassadorMemberId;
1211
+ }
1212
+ };
1213
+ var MembersAttributionResult = class {
1214
+ attributions;
1215
+ /**
1216
+ * Member ids that matched no member. Read this rather than diffing your input
1217
+ * against the results — every id you sent lands in one bucket or the other.
1218
+ */
1219
+ notFound;
1220
+ /** @internal */
1221
+ constructor(wire) {
1222
+ this.attributions = Object.freeze(
1223
+ (wire.attributions ?? []).map((a) => new MemberAttribution(a))
1224
+ );
1225
+ this.notFound = Object.freeze([...wire.notFound ?? []]);
1226
+ Object.freeze(this);
1227
+ }
1228
+ /** Attribution for one member id, or `undefined` if it was not returned. */
1229
+ get(memberId) {
1230
+ return this.attributions.find((a) => a.memberId === memberId);
1231
+ }
1232
+ /** Members nobody holds credit for yet. */
1233
+ get claimable() {
1234
+ return this.attributions.filter((a) => a.isClaimable);
1235
+ }
1236
+ };
1237
+ var AttributionResult = class {
1238
+ /** How many members were newly attributed by this request. */
1239
+ attributed;
1240
+ /** One entry per member id supplied, in the order supplied. */
1241
+ results;
1242
+ /** @internal */
1243
+ constructor(wire) {
1244
+ this.attributed = wire.attributed;
1245
+ this.results = Object.freeze(
1246
+ (wire.results ?? []).map((r) => Object.freeze({ memberId: r.memberId, outcome: r.outcome }))
1247
+ );
1248
+ Object.freeze(this);
1249
+ }
1250
+ /** Member ids with the given outcome. */
1251
+ withOutcome(outcome) {
1252
+ return this.results.filter((r) => r.outcome === outcome).map((r) => r.memberId);
1253
+ }
1254
+ /**
1255
+ * Members already credited to somebody. These are the ones worth a human
1256
+ * look — it may be your own host, or it may be another ambassador.
1257
+ */
1258
+ get alreadyAttributed() {
1259
+ return this.withOutcome("already_attributed");
1260
+ }
1261
+ /**
1262
+ * Members rejected because their account pre-dates the event's registration
1263
+ * page. The event did not recruit them, so no credit is due.
1264
+ */
1265
+ get predatedEvent() {
1266
+ return this.withOutcome("account_predates_event");
1267
+ }
1268
+ };
1269
+
1270
+ // src/resources/referrals.ts
1271
+ var MAX_IDS_PER_READ = 100;
1272
+ var MAX_IDS_PER_WRITE = 500;
1273
+ var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
1274
+ var ReferralsResource = class {
1275
+ #http;
1276
+ /** @internal */
1277
+ constructor(http) {
1278
+ this.#http = http;
1279
+ }
1280
+ /**
1281
+ * Who currently earns referral credit for these members.
1282
+ *
1283
+ * Use this before {@link attribute} to tell the two cases apart that matter:
1284
+ * a member already credited to your own event host (nothing to do) and one
1285
+ * credited to a different ambassador (a person should look, because claiming
1286
+ * it takes credit from them).
1287
+ *
1288
+ * @example
1289
+ * ```ts
1290
+ * const result = await client.referrals.get([4873327, 4873328]);
1291
+ *
1292
+ * for (const a of result.claimable) {
1293
+ * console.log(a.memberId, 'has no credit yet');
1294
+ * }
1295
+ * console.log('no such member:', result.notFound);
1296
+ * ```
1297
+ *
1298
+ * @throws {@link ValidationError} If the list is empty or exceeds 100 ids.
1299
+ */
1300
+ async get(memberIds) {
1301
+ if (memberIds.length === 0) {
1302
+ throw new ValidationError("At least one member id is required");
1303
+ }
1304
+ if (memberIds.length > MAX_IDS_PER_READ) {
1305
+ throw new ValidationError(`Maximum ${MAX_IDS_PER_READ} member ids per request`);
1306
+ }
1307
+ const wire = await this.#http.request({
1308
+ method: "GET",
1309
+ path: "/partner/members/attribution",
1310
+ query: { memberIds: memberIds.join(",") }
1311
+ });
1312
+ return new MembersAttributionResult({
1313
+ attributions: wire?.attributions ?? [],
1314
+ notFound: wire?.notFound ?? []
1315
+ });
1316
+ }
1317
+ /**
1318
+ * Credit an ambassador for players their event recruited.
1319
+ *
1320
+ * `registrationPublishedAt` is the date the event's registration page was
1321
+ * **first published**. Accounts created before it did not come from the event
1322
+ * and are rejected with `account_predates_event`. VAIR applies that rule
1323
+ * itself, so every partner is held to the same one.
1324
+ *
1325
+ * VAIR cannot verify the date — it holds no record of your registration pages —
1326
+ * so the value you send is recorded for audit. Send the real one.
1327
+ *
1328
+ * Safe to retry: attribution is one-per-player forever, enforced by the
1329
+ * database, so a resubmitted player returns `already_attributed` and nothing
1330
+ * changes.
1331
+ *
1332
+ * @example
1333
+ * ```ts
1334
+ * const result = await client.referrals.attribute({
1335
+ * referralCode: 'hillhurst-open',
1336
+ * registrationPublishedAt: '2026-08-01',
1337
+ * memberIds: [4873327, 4873328],
1338
+ * });
1339
+ *
1340
+ * console.log(result.attributed, 'newly credited');
1341
+ * console.log('need a human:', result.alreadyAttributed);
1342
+ * console.log('too old to credit:', result.predatedEvent);
1343
+ * ```
1344
+ *
1345
+ * @throws {@link ValidationError} If the list is empty or exceeds 500 ids, or
1346
+ * the date is not `YYYY-MM-DD`.
1347
+ */
1348
+ async attribute(input) {
1349
+ if (!input.referralCode.trim()) {
1350
+ throw new ValidationError("A referral code is required");
1351
+ }
1352
+ if (!ISO_DATE.test(input.registrationPublishedAt)) {
1353
+ throw new ValidationError(
1354
+ "registrationPublishedAt must be a calendar date formatted YYYY-MM-DD"
1355
+ );
1356
+ }
1357
+ if (input.memberIds.length === 0) {
1358
+ throw new ValidationError("At least one member id is required");
1359
+ }
1360
+ if (input.memberIds.length > MAX_IDS_PER_WRITE) {
1361
+ throw new ValidationError(`Maximum ${MAX_IDS_PER_WRITE} member ids per request`);
1362
+ }
1363
+ const wire = await this.#http.request({
1364
+ method: "POST",
1365
+ path: "/partner/ambassador/attribution",
1366
+ body: {
1367
+ referralCode: input.referralCode.trim(),
1368
+ registrationPublishedAt: input.registrationPublishedAt,
1369
+ memberIds: [...input.memberIds]
1370
+ }
1371
+ });
1372
+ return new AttributionResult({
1373
+ attributed: wire?.attributed ?? 0,
1374
+ results: wire?.results ?? []
1375
+ });
1376
+ }
1377
+ };
1378
+
1014
1379
  // src/models/webhook-delivery.ts
1015
1380
  var WebhookDelivery = class {
1016
1381
  id;
@@ -1138,6 +1503,11 @@ var Vairified = class {
1138
1503
  leaderboard;
1139
1504
  /** Webhook delivery inspection — deliveries. */
1140
1505
  webhooks;
1506
+ /**
1507
+ * Read and record ambassador referral credit. Each method needs its own
1508
+ * per-partner permission — see {@link ReferralsResource}.
1509
+ */
1510
+ referrals;
1141
1511
  #transport;
1142
1512
  constructor(options = {}) {
1143
1513
  const apiKey = options.apiKey ?? readEnv("VAIRIFIED_API_KEY") ?? "";
@@ -1170,6 +1540,7 @@ var Vairified = class {
1170
1540
  this.oauth = new OAuthResource(this.#transport);
1171
1541
  this.leaderboard = new LeaderboardResource(this.#transport);
1172
1542
  this.webhooks = new WebhooksResource(this.#transport);
1543
+ this.referrals = new ReferralsResource(this.#transport);
1173
1544
  }
1174
1545
  /**
1175
1546
  * API usage statistics for the current API key.
@@ -1213,7 +1584,9 @@ var Vairified = class {
1213
1584
  MatchBatchResult,
1214
1585
  MatchesResource,
1215
1586
  Member,
1587
+ MemberEmailMatch,
1216
1588
  MemberSportMap,
1589
+ MembersByEmailResult,
1217
1590
  MembersResource,
1218
1591
  NotFoundError,
1219
1592
  OAuthError,