vairified 0.4.0 → 0.7.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/dist/index.js CHANGED
@@ -254,6 +254,19 @@ var TournamentImportResult = class {
254
254
  dryRun;
255
255
  message;
256
256
  errors;
257
+ /**
258
+ * Public member ids for the ghost players THIS import created, keyed by the
259
+ * `ref` supplied in `ghostMembers[]`.
260
+ *
261
+ * Empty on a dry-run, and empty for entries the import matched to a player who
262
+ * already existed — resolving an existing email to a member requires the
263
+ * `key:player:lookup` scope and `members.getByEmail()`.
264
+ *
265
+ * Use these ids directly in a follow-up `matches.submit()`; without them an
266
+ * import reports only how many accounts it caused and you cannot address any
267
+ * of them.
268
+ */
269
+ createdGhostMembers;
257
270
  /** @internal */
258
271
  constructor(wire) {
259
272
  this.success = wire.success;
@@ -264,6 +277,11 @@ var TournamentImportResult = class {
264
277
  this.dryRun = wire.dryRun ?? false;
265
278
  this.message = wire.message;
266
279
  this.errors = Object.freeze(wire.errors ?? []);
280
+ this.createdGhostMembers = Object.freeze(
281
+ (wire.createdGhostMembers ?? []).map(
282
+ (entry) => Object.freeze({ ref: entry.ref, memberId: entry.memberId })
283
+ )
284
+ );
267
285
  Object.freeze(this);
268
286
  }
269
287
  /** True when the import succeeded without errors. */
@@ -466,6 +484,20 @@ var Member = class {
466
484
  state;
467
485
  zip;
468
486
  country;
487
+ /**
488
+ * The DATE this member's VAIR account was created (`YYYY-MM-DD`, UTC), or
489
+ * `null` when the endpoint does not supply it.
490
+ *
491
+ * Present on `members.get()`, `members.getBulk()` and `members.getByEmail()` —
492
+ * the calls where you already know which member you asked about. **Never on
493
+ * `members.search()`**, which is discovery: account age is not something you
494
+ * can browse strangers by.
495
+ *
496
+ * Deliberately a date, not a timestamp. It exists so you can apply a
497
+ * new-accounts-only referral rule — crediting an ambassador only for accounts
498
+ * created because of their event.
499
+ */
500
+ memberSince;
469
501
  gender;
470
502
  status;
471
503
  sport;
@@ -484,6 +516,7 @@ var Member = class {
484
516
  this.state = wire.state ?? null;
485
517
  this.zip = wire.zip ?? null;
486
518
  this.country = wire.country ?? null;
519
+ this.memberSince = wire.memberSince ?? null;
487
520
  this.gender = wire.gender ?? null;
488
521
  this.status = Object.freeze({ ...wire.status });
489
522
  this.sport = new MemberSportMap(wire.sport);
@@ -531,6 +564,70 @@ var Member = class {
531
564
  }
532
565
  };
533
566
 
567
+ // src/models/members-by-email-result.ts
568
+ var MemberEmailMatch = class {
569
+ /** The address exactly as you supplied it, not as stored. */
570
+ email;
571
+ /** Every member holding this address. Never empty. */
572
+ members;
573
+ /** @internal */
574
+ constructor(wire) {
575
+ this.email = wire.email;
576
+ this.members = Object.freeze(wire.members.map((m) => new Member(m)));
577
+ Object.freeze(this);
578
+ }
579
+ /**
580
+ * The single member for this address, or `null` when the address is
581
+ * ambiguous (more than one match).
582
+ *
583
+ * Use this rather than `members[0]` when a wrong link is worse than no
584
+ * link — it refuses to guess instead of silently picking one.
585
+ */
586
+ get sole() {
587
+ return this.members.length === 1 ? this.members[0] ?? null : null;
588
+ }
589
+ /** Whether this address resolved to more than one member. */
590
+ get isAmbiguous() {
591
+ return this.members.length > 1;
592
+ }
593
+ toString() {
594
+ return `MemberEmailMatch ${this.email} -> ${this.members.length} member(s)`;
595
+ }
596
+ };
597
+ var MembersByEmailResult = class {
598
+ matched;
599
+ /** Addresses that resolved to nothing, echoed as you supplied them. */
600
+ notFound;
601
+ /** @internal */
602
+ constructor(wire) {
603
+ this.matched = Object.freeze(wire.matched.map((m) => new MemberEmailMatch(m)));
604
+ this.notFound = Object.freeze([...wire.notFound]);
605
+ Object.freeze(this);
606
+ }
607
+ /**
608
+ * Look up one address's match, case-insensitively.
609
+ *
610
+ * Saves callers a linear scan and, more importantly, saves them from
611
+ * matching case-sensitively against an address the server echoed back
612
+ * in whatever case they originally sent.
613
+ */
614
+ get(email) {
615
+ const needle = email.trim().toLowerCase();
616
+ return this.matched.find((m) => m.email.toLowerCase() === needle) ?? null;
617
+ }
618
+ /** Whether every requested address resolved to at least one member. */
619
+ get allResolved() {
620
+ return this.notFound.length === 0;
621
+ }
622
+ /** Total number of members across every matched address. */
623
+ get memberCount() {
624
+ return this.matched.reduce((n, m) => n + m.members.length, 0);
625
+ }
626
+ toString() {
627
+ return `MembersByEmailResult matched=${this.matched.length} notFound=${this.notFound.length}`;
628
+ }
629
+ };
630
+
534
631
  // src/models/rating-update.ts
535
632
  var RatingUpdate = class {
536
633
  memberId;
@@ -579,6 +676,7 @@ var RatingUpdate = class {
579
676
  // src/resources/members.ts
580
677
  var DEFAULT_PAGE_SIZE = 20;
581
678
  var MAX_PAGE_SIZE = 100;
679
+ var MAX_EMAILS_PER_LOOKUP = 100;
582
680
  var MembersResource = class {
583
681
  #http;
584
682
  /** @internal */
@@ -720,6 +818,70 @@ var MembersResource = class {
720
818
  });
721
819
  return rows.map((row) => new Member(row));
722
820
  }
821
+ /**
822
+ * Resolve up to 100 members by their **exact** email address.
823
+ *
824
+ * Use this to link your users to their VAIR identity when you hold
825
+ * their email but not their member ID — e.g. resolving a tournament
826
+ * roster at registration instead of waiting for each player to
827
+ * complete SSO.
828
+ *
829
+ * **Requires the `key:player:lookup` scope**, which is granted per
830
+ * partner on approval. Holding `key:player:search` does not imply it.
831
+ *
832
+ * Matching is exact and case-insensitive; there is deliberately no
833
+ * partial, prefix or fuzzy matching. Every address you supply comes
834
+ * back in either `matched` or `notFound`, so read `notFound` directly
835
+ * instead of diffing your input against the results.
836
+ *
837
+ * A `notFound` address is **not** proof the person has no VAIR
838
+ * account — unclaimed imported records are excluded from this lookup.
839
+ *
840
+ * @param emails - Email addresses to resolve (max 100).
841
+ * @param options - Optional filters.
842
+ * @param options.sport - Sport code to scope ratings (e.g. `'pickleball'`).
843
+ * @throws {@link ValidationError} If more than 100 addresses are
844
+ * provided, or the list is empty.
845
+ * @category Members
846
+ *
847
+ * @example
848
+ * ```ts
849
+ * const result = await client.members.getByEmail([
850
+ * 'ada@example.com',
851
+ * 'nobody@example.com',
852
+ * ]);
853
+ *
854
+ * for (const match of result.matched) {
855
+ * const member = match.sole; // null when the address is ambiguous
856
+ * if (member) console.log(match.email, '->', member.memberId);
857
+ * }
858
+ *
859
+ * console.log('no VAIR account found for:', result.notFound);
860
+ * ```
861
+ */
862
+ async getByEmail(emails, options) {
863
+ if (emails.length === 0) {
864
+ throw new ValidationError("At least one email address is required");
865
+ }
866
+ if (emails.length > MAX_EMAILS_PER_LOOKUP) {
867
+ throw new ValidationError(`Maximum ${MAX_EMAILS_PER_LOOKUP} email addresses per request`);
868
+ }
869
+ const offender = emails.find((e) => e.includes(","));
870
+ if (offender !== void 0) {
871
+ throw new ValidationError(`Email address must not contain a comma: ${offender}`);
872
+ }
873
+ const query = { emails: emails.join(",") };
874
+ if (options?.sport) query.sport = options.sport;
875
+ const wire = await this.#http.request({
876
+ method: "GET",
877
+ path: "/partner/members/by-email",
878
+ query
879
+ });
880
+ return new MembersByEmailResult({
881
+ matched: wire?.matched ?? [],
882
+ notFound: wire?.notFound ?? []
883
+ });
884
+ }
723
885
  /**
724
886
  * Poll for rating change notifications.
725
887
  *
@@ -958,6 +1120,207 @@ function tokenResponseFromWire(data) {
958
1120
  };
959
1121
  }
960
1122
 
1123
+ // src/models/attribution.ts
1124
+ var MemberAttribution = class {
1125
+ memberId;
1126
+ /** True when some ambassador already holds credit for this member. */
1127
+ attributed;
1128
+ /**
1129
+ * The member id of the ambassador holding the credit.
1130
+ *
1131
+ * Compare it against your own event host's member id to tell "already credited
1132
+ * to my host — nothing to do" from "credited to somebody else — a person needs
1133
+ * to look, because claiming it takes credit from them".
1134
+ *
1135
+ * `null` both when nobody holds credit and when credit is held by a record that
1136
+ * has no member id of its own, so check {@link attributed} to tell those apart.
1137
+ */
1138
+ ambassadorMemberId;
1139
+ /** The date the credit was established (`YYYY-MM-DD`), or `null`. */
1140
+ attributedAt;
1141
+ /** @internal */
1142
+ constructor(wire) {
1143
+ this.memberId = wire.memberId;
1144
+ this.attributed = wire.attributed;
1145
+ this.ambassadorMemberId = wire.ambassadorMemberId ?? null;
1146
+ this.attributedAt = wire.attributedAt ?? null;
1147
+ Object.freeze(this);
1148
+ }
1149
+ /** True when nobody holds credit yet, so this member can be claimed. */
1150
+ get isClaimable() {
1151
+ return !this.attributed;
1152
+ }
1153
+ /** True when credit is held by an ambassador OTHER than the one given. */
1154
+ heldBySomeoneOtherThan(ambassadorMemberId) {
1155
+ return this.attributed && this.ambassadorMemberId !== ambassadorMemberId;
1156
+ }
1157
+ };
1158
+ var MembersAttributionResult = class {
1159
+ attributions;
1160
+ /**
1161
+ * Member ids that matched no member. Read this rather than diffing your input
1162
+ * against the results — every id you sent lands in one bucket or the other.
1163
+ */
1164
+ notFound;
1165
+ /** @internal */
1166
+ constructor(wire) {
1167
+ this.attributions = Object.freeze(
1168
+ (wire.attributions ?? []).map((a) => new MemberAttribution(a))
1169
+ );
1170
+ this.notFound = Object.freeze([...wire.notFound ?? []]);
1171
+ Object.freeze(this);
1172
+ }
1173
+ /** Attribution for one member id, or `undefined` if it was not returned. */
1174
+ get(memberId) {
1175
+ return this.attributions.find((a) => a.memberId === memberId);
1176
+ }
1177
+ /** Members nobody holds credit for yet. */
1178
+ get claimable() {
1179
+ return this.attributions.filter((a) => a.isClaimable);
1180
+ }
1181
+ };
1182
+ var AttributionResult = class {
1183
+ /** How many members were newly attributed by this request. */
1184
+ attributed;
1185
+ /** One entry per member id supplied, in the order supplied. */
1186
+ results;
1187
+ /** @internal */
1188
+ constructor(wire) {
1189
+ this.attributed = wire.attributed;
1190
+ this.results = Object.freeze(
1191
+ (wire.results ?? []).map((r) => Object.freeze({ memberId: r.memberId, outcome: r.outcome }))
1192
+ );
1193
+ Object.freeze(this);
1194
+ }
1195
+ /** Member ids with the given outcome. */
1196
+ withOutcome(outcome) {
1197
+ return this.results.filter((r) => r.outcome === outcome).map((r) => r.memberId);
1198
+ }
1199
+ /**
1200
+ * Members already credited to somebody. These are the ones worth a human
1201
+ * look — it may be your own host, or it may be another ambassador.
1202
+ */
1203
+ get alreadyAttributed() {
1204
+ return this.withOutcome("already_attributed");
1205
+ }
1206
+ /**
1207
+ * Members rejected because their account pre-dates the event's registration
1208
+ * page. The event did not recruit them, so no credit is due.
1209
+ */
1210
+ get predatedEvent() {
1211
+ return this.withOutcome("account_predates_event");
1212
+ }
1213
+ };
1214
+
1215
+ // src/resources/referrals.ts
1216
+ var MAX_IDS_PER_READ = 100;
1217
+ var MAX_IDS_PER_WRITE = 500;
1218
+ var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
1219
+ var ReferralsResource = class {
1220
+ #http;
1221
+ /** @internal */
1222
+ constructor(http) {
1223
+ this.#http = http;
1224
+ }
1225
+ /**
1226
+ * Who currently earns referral credit for these members.
1227
+ *
1228
+ * Use this before {@link attribute} to tell the two cases apart that matter:
1229
+ * a member already credited to your own event host (nothing to do) and one
1230
+ * credited to a different ambassador (a person should look, because claiming
1231
+ * it takes credit from them).
1232
+ *
1233
+ * @example
1234
+ * ```ts
1235
+ * const result = await client.referrals.get([4873327, 4873328]);
1236
+ *
1237
+ * for (const a of result.claimable) {
1238
+ * console.log(a.memberId, 'has no credit yet');
1239
+ * }
1240
+ * console.log('no such member:', result.notFound);
1241
+ * ```
1242
+ *
1243
+ * @throws {@link ValidationError} If the list is empty or exceeds 100 ids.
1244
+ */
1245
+ async get(memberIds) {
1246
+ if (memberIds.length === 0) {
1247
+ throw new ValidationError("At least one member id is required");
1248
+ }
1249
+ if (memberIds.length > MAX_IDS_PER_READ) {
1250
+ throw new ValidationError(`Maximum ${MAX_IDS_PER_READ} member ids per request`);
1251
+ }
1252
+ const wire = await this.#http.request({
1253
+ method: "GET",
1254
+ path: "/partner/members/attribution",
1255
+ query: { memberIds: memberIds.join(",") }
1256
+ });
1257
+ return new MembersAttributionResult({
1258
+ attributions: wire?.attributions ?? [],
1259
+ notFound: wire?.notFound ?? []
1260
+ });
1261
+ }
1262
+ /**
1263
+ * Credit an ambassador for players their event recruited.
1264
+ *
1265
+ * `registrationPublishedAt` is the date the event's registration page was
1266
+ * **first published**. Accounts created before it did not come from the event
1267
+ * and are rejected with `account_predates_event`. VAIR applies that rule
1268
+ * itself, so every partner is held to the same one.
1269
+ *
1270
+ * VAIR cannot verify the date — it holds no record of your registration pages —
1271
+ * so the value you send is recorded for audit. Send the real one.
1272
+ *
1273
+ * Safe to retry: attribution is one-per-player forever, enforced by the
1274
+ * database, so a resubmitted player returns `already_attributed` and nothing
1275
+ * changes.
1276
+ *
1277
+ * @example
1278
+ * ```ts
1279
+ * const result = await client.referrals.attribute({
1280
+ * referralCode: 'hillhurst-open',
1281
+ * registrationPublishedAt: '2026-08-01',
1282
+ * memberIds: [4873327, 4873328],
1283
+ * });
1284
+ *
1285
+ * console.log(result.attributed, 'newly credited');
1286
+ * console.log('need a human:', result.alreadyAttributed);
1287
+ * console.log('too old to credit:', result.predatedEvent);
1288
+ * ```
1289
+ *
1290
+ * @throws {@link ValidationError} If the list is empty or exceeds 500 ids, or
1291
+ * the date is not `YYYY-MM-DD`.
1292
+ */
1293
+ async attribute(input) {
1294
+ if (!input.referralCode.trim()) {
1295
+ throw new ValidationError("A referral code is required");
1296
+ }
1297
+ if (!ISO_DATE.test(input.registrationPublishedAt)) {
1298
+ throw new ValidationError(
1299
+ "registrationPublishedAt must be a calendar date formatted YYYY-MM-DD"
1300
+ );
1301
+ }
1302
+ if (input.memberIds.length === 0) {
1303
+ throw new ValidationError("At least one member id is required");
1304
+ }
1305
+ if (input.memberIds.length > MAX_IDS_PER_WRITE) {
1306
+ throw new ValidationError(`Maximum ${MAX_IDS_PER_WRITE} member ids per request`);
1307
+ }
1308
+ const wire = await this.#http.request({
1309
+ method: "POST",
1310
+ path: "/partner/ambassador/attribution",
1311
+ body: {
1312
+ referralCode: input.referralCode.trim(),
1313
+ registrationPublishedAt: input.registrationPublishedAt,
1314
+ memberIds: [...input.memberIds]
1315
+ }
1316
+ });
1317
+ return new AttributionResult({
1318
+ attributed: wire?.attributed ?? 0,
1319
+ results: wire?.results ?? []
1320
+ });
1321
+ }
1322
+ };
1323
+
961
1324
  // src/models/webhook-delivery.ts
962
1325
  var WebhookDelivery = class {
963
1326
  id;
@@ -1085,6 +1448,11 @@ var Vairified = class {
1085
1448
  leaderboard;
1086
1449
  /** Webhook delivery inspection — deliveries. */
1087
1450
  webhooks;
1451
+ /**
1452
+ * Read and record ambassador referral credit. Each method needs its own
1453
+ * per-partner permission — see {@link ReferralsResource}.
1454
+ */
1455
+ referrals;
1088
1456
  #transport;
1089
1457
  constructor(options = {}) {
1090
1458
  const apiKey = options.apiKey ?? readEnv("VAIRIFIED_API_KEY") ?? "";
@@ -1117,6 +1485,7 @@ var Vairified = class {
1117
1485
  this.oauth = new OAuthResource(this.#transport);
1118
1486
  this.leaderboard = new LeaderboardResource(this.#transport);
1119
1487
  this.webhooks = new WebhooksResource(this.#transport);
1488
+ this.referrals = new ReferralsResource(this.#transport);
1120
1489
  }
1121
1490
  /**
1122
1491
  * API usage statistics for the current API key.
@@ -1159,7 +1528,9 @@ export {
1159
1528
  MatchBatchResult,
1160
1529
  MatchesResource,
1161
1530
  Member,
1531
+ MemberEmailMatch,
1162
1532
  MemberSportMap,
1533
+ MembersByEmailResult,
1163
1534
  MembersResource,
1164
1535
  NotFoundError,
1165
1536
  OAuthError,