vairified 0.1.1 → 0.3.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
@@ -1,8 +1,8 @@
1
1
  // src/errors.ts
2
2
  var VairifiedError = class extends Error {
3
- /** HTTP status code */
3
+ /** HTTP status code (if the error came from an API response). */
4
4
  statusCode;
5
- /** Response body */
5
+ /** Raw response body parsed as JSON when available. */
6
6
  response;
7
7
  constructor(message, statusCode, response) {
8
8
  super(message);
@@ -12,7 +12,7 @@ var VairifiedError = class extends Error {
12
12
  }
13
13
  };
14
14
  var RateLimitError = class extends VairifiedError {
15
- /** Seconds to wait before retrying */
15
+ /** Seconds to wait before retrying, or `undefined` if the server didn't say. */
16
16
  retryAfter;
17
17
  constructor(message = "Rate limit exceeded", retryAfter, response) {
18
18
  super(message, 429, response);
@@ -39,7 +39,10 @@ var ValidationError = class extends VairifiedError {
39
39
  }
40
40
  };
41
41
  var OAuthError = class extends VairifiedError {
42
- /** OAuth error code (e.g., 'invalid_grant', 'expired_token') */
42
+ /**
43
+ * OAuth error code such as `'invalid_grant'`, `'invalid_scope'`,
44
+ * or `'expired_token'`. Check this to branch on the specific failure.
45
+ */
43
46
  errorCode;
44
47
  constructor(message = "OAuth error", errorCode, response) {
45
48
  super(message, void 0, response);
@@ -48,943 +51,1082 @@ var OAuthError = class extends VairifiedError {
48
51
  }
49
52
  };
50
53
 
51
- // src/models.ts
52
- var RatingSplit = class {
53
- /** The rating value */
54
- rating;
55
- /** Abbreviation (e.g., "VG", "50+") */
56
- abbr;
57
- /** Date of last match in this category */
58
- datePlayed;
59
- constructor(data) {
60
- if (typeof data === "number") {
61
- this.rating = data;
62
- this.abbr = "";
63
- } else {
64
- const ratingVal = data.rating;
65
- this.rating = typeof ratingVal === "string" ? Number.parseFloat(ratingVal) || 0 : ratingVal;
66
- this.abbr = data.abbr;
67
- this.datePlayed = data.date_played;
54
+ // src/http.ts
55
+ var HttpTransport = class {
56
+ #config;
57
+ constructor(config) {
58
+ this.#config = config;
59
+ }
60
+ async request(options) {
61
+ const url = buildUrl(this.#config.baseUrl, options.path, options.query);
62
+ const controller = new AbortController();
63
+ const timeoutId = setTimeout(
64
+ () => controller.abort(new Error(`Request timed out after ${this.#config.timeoutMs}ms`)),
65
+ this.#config.timeoutMs
66
+ );
67
+ const headers = {
68
+ "X-API-Key": this.#config.apiKey,
69
+ Accept: "application/json"
70
+ };
71
+ const init = {
72
+ method: options.method,
73
+ headers,
74
+ signal: controller.signal
75
+ };
76
+ if (options.body !== void 0) {
77
+ headers["Content-Type"] = "application/json";
78
+ init.body = JSON.stringify(options.body);
68
79
  }
69
- }
70
- };
71
- var RatingSplits = class {
72
- /** Map of category names to rating splits */
73
- splits;
74
- constructor(data) {
75
- this.splits = /* @__PURE__ */ new Map();
76
- if (data) {
77
- for (const [key, value] of Object.entries(data)) {
78
- this.splits.set(key, new RatingSplit(value));
79
- }
80
+ let response;
81
+ try {
82
+ response = await this.#config.fetch(url, init);
83
+ } finally {
84
+ clearTimeout(timeoutId);
80
85
  }
81
- }
82
- /** Get rating for a category */
83
- get(category) {
84
- return this.splits.get(category)?.rating;
85
- }
86
- /** Open division rating */
87
- get open() {
88
- return this.get("open") ?? this.get("VO");
89
- }
90
- /** Gender-specific rating (same gender doubles) */
91
- get gender() {
92
- return this.get("gender") ?? this.get("VG");
93
- }
94
- /** Mixed doubles rating */
95
- get mixed() {
96
- return this.get("mixed") ?? this.get("VM");
97
- }
98
- /** Recreational rating */
99
- get recreational() {
100
- return this.get("recreational") ?? this.get("R");
101
- }
102
- /** Singles rating */
103
- get singles() {
104
- return this.get("singles") ?? this.get("S");
105
- }
106
- /** Best available verified rating */
107
- get best() {
108
- const ratings = Array.from(this.splits.values()).map((s) => s.rating).filter((r) => r > 0);
109
- return ratings.length > 0 ? Math.max(...ratings) : void 0;
110
- }
111
- /** Convert to plain object */
112
- toJSON() {
113
- const result = {};
114
- for (const [key, split] of this.splits) {
115
- result[key] = { rating: split.rating, abbr: split.abbr };
86
+ if (!response.ok) {
87
+ await throwFromResponse(response);
116
88
  }
117
- return result;
118
- }
119
- };
120
- function isSearchData(data) {
121
- return "displayName" in data;
122
- }
123
- var Player = class {
124
- /** External player ID (vair_mem_xxx format) */
125
- id;
126
- /** Display name (First Name + Last Initial from search) */
127
- displayName;
128
- /** First name (only from connected member) */
129
- firstName;
130
- /** Last name (only from connected member) */
131
- lastName;
132
- /** Primary/overall rating (2.0-8.0) */
133
- rating;
134
- /** Whether player is verified */
135
- isVairified;
136
- /** Whether player has connected to your app */
137
- isConnected;
138
- /** Ratings by category (only from connected member) */
139
- ratingSplits;
140
- /** City */
141
- city;
142
- /** State code */
143
- state;
144
- /** Country code */
145
- country;
146
- _client;
147
- constructor(data, client) {
148
- if (isSearchData(data)) {
149
- this.id = data.id;
150
- this.displayName = data.displayName;
151
- this.rating = data.rating ?? 0;
152
- this.isVairified = data.isVairified ?? false;
153
- this.isConnected = data.isConnected ?? false;
154
- this.ratingSplits = new RatingSplits();
155
- } else {
156
- this.id = data.id;
157
- this.firstName = data.firstName ?? "";
158
- this.lastName = data.lastName ?? "";
159
- this.rating = data.rating ?? 0;
160
- this.isVairified = data.isVairified ?? false;
161
- this.isConnected = true;
162
- this.ratingSplits = new RatingSplits(data.ratingSplits);
89
+ if (response.status === 204 || response.headers.get("content-length") === "0") {
90
+ return void 0;
163
91
  }
164
- this.city = data.city;
165
- this.state = data.state;
166
- this.country = data.country;
167
- this._client = client;
168
- }
169
- /** Full name (or display name if full name not available) */
170
- get name() {
171
- if (this.firstName && this.lastName) {
172
- return `${this.firstName} ${this.lastName}`.trim();
92
+ const text = await response.text();
93
+ if (text.length === 0) {
94
+ return void 0;
95
+ }
96
+ try {
97
+ return JSON.parse(text);
98
+ } catch {
99
+ throw new VairifiedError(`Unable to parse response as JSON: ${text}`, response.status);
173
100
  }
174
- return this.displayName ?? "";
175
- }
176
- /** Best verified rating */
177
- get verifiedRating() {
178
- return this.ratingSplits.best;
179
- }
180
- toString() {
181
- const verified = this.isVairified ? " \u2713" : "";
182
- return `${this.name} (${this.rating.toFixed(2)})${verified}`;
183
101
  }
184
102
  };
185
- var Member = class extends Player {
186
- /** Email address (only if profile:email scope granted) */
187
- email;
188
- /** Scopes the player granted to your app */
189
- grantedScopes;
190
- constructor(data, client) {
191
- super(data, client);
192
- this.email = data.email;
193
- this.grantedScopes = data.grantedScopes ?? [];
194
- }
195
- /** Check if the player has granted a specific scope */
196
- hasScope(scope) {
197
- return this.grantedScopes.includes(scope);
198
- }
199
- /** Refresh member data from API */
200
- async refresh() {
201
- if (!this._client) {
202
- throw new Error("Member not connected to client");
103
+ function buildUrl(baseUrl, path, query) {
104
+ const cleanBase = baseUrl.replace(/\/+$/, "");
105
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
106
+ const url = new URL(cleanBase + cleanPath);
107
+ if (query) {
108
+ for (const [key, value] of Object.entries(query)) {
109
+ if (value === null || value === void 0) continue;
110
+ if (Array.isArray(value)) {
111
+ if (value.length === 0) continue;
112
+ url.searchParams.set(key, value.join(","));
113
+ } else {
114
+ url.searchParams.set(key, String(value));
115
+ }
203
116
  }
204
- const updated = await this._client.getMember(this.id);
205
- Object.assign(this, updated);
206
- return this;
207
117
  }
208
- };
209
- function generateId() {
210
- return `SDK-${Math.random().toString(36).substring(2, 14)}`;
118
+ return url.toString();
211
119
  }
212
- var Match = class {
213
- /** Event/tournament name */
214
- event;
215
- /** Bracket/division name */
216
- bracket;
217
- /** Match date */
218
- date;
219
- /** Team 1 player IDs */
220
- team1;
221
- /** Team 2 player IDs */
222
- team2;
223
- /** Game scores */
224
- scores;
225
- /** Match type */
226
- matchType;
227
- /** Match source */
228
- source;
229
- /** Location */
230
- location;
231
- /** Unique identifier */
232
- identifier;
233
- /** Match ID (set after submission) */
234
- id;
235
- constructor(data) {
236
- this.event = data.event;
237
- this.bracket = data.bracket;
238
- this.date = data.date instanceof Date ? data.date : new Date(data.date);
239
- this.team1 = data.team1;
240
- this.team2 = data.team2;
241
- this.scores = data.scores;
242
- this.matchType = data.matchType ?? "SIDEOUT";
243
- this.source = data.source ?? "PARTNER";
244
- this.location = data.location;
245
- this.identifier = data.identifier ?? generateId();
246
- }
247
- /** Match format: SINGLES or DOUBLES */
248
- get format() {
249
- return this.team1.length === 1 ? "SINGLES" : "DOUBLES";
250
- }
251
- /** Team that won (1 or 2). Returns 0 if tie. */
252
- get winner() {
253
- let t1Wins = 0;
254
- let t2Wins = 0;
255
- for (const [s1, s2] of this.scores) {
256
- if (s1 > s2) t1Wins++;
257
- else if (s2 > s1) t2Wins++;
258
- }
259
- if (t1Wins > t2Wins) return 1;
260
- if (t2Wins > t1Wins) return 2;
261
- return 0;
262
- }
263
- /** Score summary like "11-9, 11-7" */
264
- get scoreSummary() {
265
- return this.scores.map(([s1, s2]) => `${s1}-${s2}`).join(", ");
266
- }
267
- /** Convert to API request format */
268
- toJSON() {
269
- const player1A = this.team1[0];
270
- const player1B = this.team2[0];
271
- if (!player1A || !player1B) {
272
- throw new Error("Match must have at least one player per team");
120
+ async function throwFromResponse(response) {
121
+ const status = response.status;
122
+ const text = await response.text().catch(() => "");
123
+ let body = null;
124
+ if (text.length > 0) {
125
+ try {
126
+ body = JSON.parse(text);
127
+ } catch {
128
+ body = null;
273
129
  }
274
- const teamA = { player1: player1A };
275
- const teamB = { player1: player1B };
276
- if (this.team1[1]) teamA.player2 = this.team1[1];
277
- if (this.team2[1]) teamB.player2 = this.team2[1];
278
- const gameKeys = ["game1", "game2", "game3", "game4", "game5"];
279
- for (let i = 0; i < Math.min(this.scores.length, 5); i++) {
280
- const score = this.scores[i];
281
- const key = gameKeys[i];
282
- if (score && key) {
283
- teamA[key] = score[0];
284
- teamB[key] = score[1];
285
- }
130
+ }
131
+ let message;
132
+ if (body && typeof body === "object" && !Array.isArray(body)) {
133
+ const apiBody = body;
134
+ message = apiBody.message || apiBody.error || text || `HTTP ${status}`;
135
+ } else {
136
+ message = text || `HTTP ${status}`;
137
+ }
138
+ switch (status) {
139
+ case 400:
140
+ throw new ValidationError(message, body);
141
+ case 401:
142
+ throw new AuthenticationError(message, body);
143
+ case 404:
144
+ throw new NotFoundError(message, body);
145
+ case 429: {
146
+ const retryAfterHeader = response.headers.get("Retry-After");
147
+ const retryAfter = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : void 0;
148
+ throw new RateLimitError(message, Number.isFinite(retryAfter) ? retryAfter : void 0, body);
286
149
  }
287
- return {
288
- identifier: this.identifier,
289
- bracket: this.bracket,
290
- event: this.event,
291
- format: this.format,
292
- matchDate: this.date.toISOString(),
293
- matchSource: this.source,
294
- matchType: this.matchType,
295
- location: this.location,
296
- teamA,
297
- teamB
150
+ default:
151
+ throw new VairifiedError(message, status, body);
152
+ }
153
+ }
154
+
155
+ // src/resources/leaderboard.ts
156
+ var LeaderboardResource = class {
157
+ #http;
158
+ /** @internal */
159
+ constructor(http) {
160
+ this.#http = http;
161
+ }
162
+ /** Fetch a leaderboard page with optional filters. */
163
+ async list(options = {}) {
164
+ const query = {
165
+ limit: options.limit ?? 50,
166
+ offset: options.offset ?? 0,
167
+ category: options.category,
168
+ ageBracket: options.ageBracket,
169
+ scope: options.scope,
170
+ state: options.state,
171
+ city: options.city,
172
+ clubId: options.clubId,
173
+ gender: options.gender?.toUpperCase(),
174
+ minGames: options.minGames,
175
+ search: options.search,
176
+ verifiedOnly: options.verifiedOnly === true ? true : void 0
177
+ };
178
+ const data = await this.#http.request({
179
+ method: "GET",
180
+ path: "/leaderboard",
181
+ query
182
+ });
183
+ return data ?? {};
184
+ }
185
+ /** Fetch a specific player's rank plus nearby players. */
186
+ async rank(playerId, options = {}) {
187
+ const body = {
188
+ playerId,
189
+ category: options.category ?? "doubles",
190
+ ageBracket: options.ageBracket ?? "open",
191
+ scope: options.scope ?? "global",
192
+ contextSize: options.contextSize ?? 5
298
193
  };
194
+ if (options.state !== void 0) body.state = options.state;
195
+ if (options.city !== void 0) body.city = options.city;
196
+ if (options.clubId !== void 0) body.clubId = options.clubId;
197
+ const data = await this.#http.request({
198
+ method: "POST",
199
+ path: "/leaderboard/rank",
200
+ body
201
+ });
202
+ return data ?? {};
203
+ }
204
+ /** List available leaderboard categories, brackets, and scopes. */
205
+ async categories() {
206
+ const data = await this.#http.request({
207
+ method: "GET",
208
+ path: "/leaderboard/categories"
209
+ });
210
+ return data ?? {};
299
211
  }
300
212
  };
301
- var MatchResult = class {
302
- /** Whether submission succeeded */
213
+
214
+ // src/models/match-batch-result.ts
215
+ var MatchBatchResult = class {
303
216
  success;
304
- /** Number of matches processed */
305
217
  numMatches;
306
- /** Number of games recorded */
307
218
  numGames;
308
- /** Whether this was a dry-run (validation only) */
309
219
  dryRun;
310
- /** Human-readable result message */
311
220
  message;
312
- /** List of validation/processing errors */
313
221
  errors;
314
- constructor(data) {
315
- this.success = data.success;
316
- this.numMatches = data.numMatches;
317
- this.numGames = data.numGames;
318
- this.dryRun = data.dryRun ?? false;
319
- this.message = data.message;
320
- this.errors = data.errors ?? [];
321
- }
322
- /** Alias for dryRun */
222
+ constructor(wire) {
223
+ this.success = wire.success;
224
+ this.numMatches = wire.numMatches;
225
+ this.numGames = wire.numGames;
226
+ this.dryRun = wire.dryRun ?? null;
227
+ this.message = wire.message ?? null;
228
+ this.errors = wire.errors ? Object.freeze([...wire.errors]) : null;
229
+ Object.freeze(this);
230
+ }
231
+ /** Shorthand: successful submission with zero errors. */
232
+ get ok() {
233
+ return this.success && (this.errors === null || this.errors.length === 0);
234
+ }
235
+ /** Whether this was a dry-run (validation only, nothing persisted). */
323
236
  get isDryRun() {
324
- return this.dryRun;
237
+ return this.dryRun === true;
325
238
  }
326
- /** Returns true if submission succeeded without errors */
239
+ toString() {
240
+ const mode = this.isDryRun ? " [dry-run]" : "";
241
+ const errs = this.errors && this.errors.length > 0 ? ` errors=${this.errors.length}` : "";
242
+ const status = this.ok ? "ok" : "FAILED";
243
+ return `MatchBatchResult ${status}${mode} matches=${this.numMatches} games=${this.numGames}${errs}`;
244
+ }
245
+ };
246
+
247
+ // src/models/tournament-import-result.ts
248
+ var TournamentImportResult = class {
249
+ success;
250
+ matchesImported;
251
+ gamesRecorded;
252
+ ghostPlayersCreated;
253
+ existingPlayersMatched;
254
+ dryRun;
255
+ message;
256
+ errors;
257
+ /** @internal */
258
+ constructor(wire) {
259
+ this.success = wire.success;
260
+ this.matchesImported = wire.matchesImported;
261
+ this.gamesRecorded = wire.gamesRecorded;
262
+ this.ghostPlayersCreated = wire.ghostPlayersCreated;
263
+ this.existingPlayersMatched = wire.existingPlayersMatched;
264
+ this.dryRun = wire.dryRun ?? false;
265
+ this.message = wire.message;
266
+ this.errors = Object.freeze(wire.errors ?? []);
267
+ Object.freeze(this);
268
+ }
269
+ /** True when the import succeeded without errors. */
327
270
  get ok() {
328
271
  return this.success && this.errors.length === 0;
329
272
  }
330
273
  };
331
- var RatingUpdate = class {
332
- /** External player ID (vair_mem_xxx format) */
333
- id;
334
- /** Member name */
335
- memberName;
336
- /** Previous rating */
337
- previousRating;
338
- /** New rating */
339
- newRating;
340
- /** When the change occurred */
341
- changedAt;
342
- /** Updated rating splits */
343
- ratingSplits;
344
- _client;
345
- constructor(data, client) {
346
- this.id = data.id;
347
- this.memberName = data.memberName;
348
- this.previousRating = data.previousRating ?? 0;
349
- this.newRating = data.newRating ?? 0;
350
- this.changedAt = data.changedAt ? new Date(data.changedAt) : /* @__PURE__ */ new Date();
351
- this.ratingSplits = new RatingSplits(data.ratingSplits);
352
- this._client = client;
353
- }
354
- /** Amount of rating change */
355
- get change() {
356
- return this.newRating - this.previousRating;
274
+
275
+ // src/resources/matches.ts
276
+ var MatchesResource = class {
277
+ #http;
278
+ /** @internal */
279
+ constructor(http) {
280
+ this.#http = http;
357
281
  }
358
- /** Whether rating improved */
359
- get improved() {
360
- return this.change > 0;
282
+ /**
283
+ * Submit a {@link MatchBatch} for rating calculation.
284
+ *
285
+ * All players in every match must have granted the `user:match:submit`
286
+ * scope via OAuth (unless your API key has the
287
+ * `user:match:submit:trusted` scope, which skips per-player consent).
288
+ *
289
+ * Set `batch.dryRun = true` to validate without persisting.
290
+ *
291
+ * ```ts
292
+ * const result = await client.matches.submit({
293
+ * sport: 'pickleball',
294
+ * winScore: 11,
295
+ * winBy: 2,
296
+ * bracket: '4.0 Doubles',
297
+ * event: 'Weekly League',
298
+ * matchDate: '2026-04-11T14:00:00Z',
299
+ * matches: [
300
+ * {
301
+ * identifier: 'm1',
302
+ * teams: [['vair_mem_aaa', 'vair_mem_bbb'],
303
+ * ['vair_mem_ccc', 'vair_mem_ddd']],
304
+ * games: [{ scores: [11, 8] }, { scores: [11, 5] }],
305
+ * },
306
+ * ],
307
+ * });
308
+ * if (result.ok) {
309
+ * console.log(`Submitted ${result.numGames} games`);
310
+ * }
311
+ * ```
312
+ */
313
+ async submit(batch) {
314
+ const wire = await this.#http.request({
315
+ method: "POST",
316
+ path: "/partner/matches",
317
+ body: batch
318
+ });
319
+ return new MatchBatchResult(wire);
361
320
  }
362
- /** Fetch the member associated with this update */
363
- async getMember() {
364
- if (!this._client) {
365
- throw new Error("Update not connected to client");
366
- }
367
- return this._client.getMember(this.id);
321
+ /**
322
+ * Import tournament results.
323
+ *
324
+ * The request body is a free-form JSON object whose structure is
325
+ * defined by the Vairified tournament import schema. Set
326
+ * `body.dryRun = true` to validate without persisting.
327
+ *
328
+ * @param body - Tournament import payload.
329
+ * @returns {@link TournamentImportResult} with match/game counts.
330
+ * @category Matches
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * const result = await client.matches.tournamentImport({
335
+ * sport: 'pickleball',
336
+ * tournamentName: 'Spring Classic',
337
+ * matches: [...],
338
+ * });
339
+ * if (result.ok) {
340
+ * console.log(`Imported ${result.matchesImported} matches`);
341
+ * }
342
+ * ```
343
+ */
344
+ async tournamentImport(body) {
345
+ const wire = await this.#http.request({
346
+ method: "POST",
347
+ path: "/partner/tournament-import",
348
+ body
349
+ });
350
+ return new TournamentImportResult(wire);
368
351
  }
369
- toString() {
370
- const direction = this.improved ? "\u2191" : "\u2193";
371
- const name = this.memberName ? ` (${this.memberName})` : "";
372
- return `${this.id}${name}: ${this.previousRating.toFixed(2)} ${direction} ${this.newRating.toFixed(2)}`;
352
+ /** Send a test payload to a webhook URL. */
353
+ async testWebhook(webhookUrl) {
354
+ const data = await this.#http.request({
355
+ method: "POST",
356
+ path: "/partner/webhook-test",
357
+ body: { webhookUrl }
358
+ });
359
+ return data ?? {};
373
360
  }
374
361
  };
375
- var SearchResults = class {
376
- /** List of players */
377
- players;
378
- /** Total matching players */
379
- total;
380
- /** Current page */
381
- page;
382
- /** Results per page */
383
- limit;
384
- _client;
385
- _filters;
386
- constructor(data, client, filters = {}) {
387
- this.players = data.players.map((p) => new Player(p, client));
388
- this.total = data.total;
389
- this.page = data.page;
390
- this.limit = data.limit;
391
- this._client = client;
392
- this._filters = filters;
393
- }
394
- /** Whether more results are available */
395
- get hasMore() {
396
- return this.page * this.limit < this.total;
397
- }
398
- /** Total number of pages */
399
- get pages() {
400
- return this.limit > 0 ? Math.ceil(this.total / this.limit) : 0;
401
- }
402
- /** Number of players in current page */
403
- get length() {
404
- return this.players.length;
405
- }
406
- /** Get player by index */
407
- at(index) {
408
- return this.players[index];
409
- }
410
- /** Iterate over players */
411
- [Symbol.iterator]() {
412
- return this.players[Symbol.iterator]();
362
+
363
+ // src/models/sport-rating.ts
364
+ var SportRating = class {
365
+ /** Primary rating for this sport. */
366
+ rating;
367
+ /** Category abbreviation for the primary rating (e.g. `'VO'`). */
368
+ abbr;
369
+ #splits;
370
+ constructor(wire) {
371
+ this.rating = wire.rating;
372
+ this.abbr = wire.abbr;
373
+ this.#splits = new Map(Object.entries(wire.ratingSplits ?? {}));
374
+ Object.freeze(this);
413
375
  }
414
- /** Fetch next page of results */
415
- async nextPage() {
416
- if (!this._client) {
417
- throw new Error("Results not connected to client");
418
- }
419
- if (!this.hasMore) {
420
- throw new Error("No more pages");
421
- }
422
- return this._client.search({
423
- ...this._filters,
424
- page: this.page + 1
425
- });
376
+ /**
377
+ * Look up a rating split by key (e.g. `'overall-open'`,
378
+ * `'singles-12-13'`, `'gender-40+'`). Returns `undefined` if the
379
+ * player has no rating for that bracket.
380
+ */
381
+ get(key) {
382
+ return this.#splits.get(key);
383
+ }
384
+ /** Whether the player has a rating for the given split key. */
385
+ has(key) {
386
+ return this.#splits.has(key);
387
+ }
388
+ /** Number of rating splits. */
389
+ get size() {
390
+ return this.#splits.size;
391
+ }
392
+ /** All split keys the player has ratings for. */
393
+ keys() {
394
+ return this.#splits.keys();
395
+ }
396
+ /** All rating splits the player has. */
397
+ values() {
398
+ return this.#splits.values();
399
+ }
400
+ /** `[key, split]` pairs for every rating split. */
401
+ entries() {
402
+ return this.#splits.entries();
403
+ }
404
+ /**
405
+ * `for (const [key, split] of sportRating) { ... }` — iterate every
406
+ * rating split the player has in this sport.
407
+ */
408
+ [Symbol.iterator]() {
409
+ return this.#splits.entries();
426
410
  }
427
411
  };
428
412
 
429
- // src/oauth.ts
430
- var SCOPES = {
431
- "profile:read": "Access your name, location, and verification status",
432
- "profile:email": "Access your email address",
433
- "rating:read": "View your current rating and rating splits",
434
- "rating:history": "View your complete rating history",
435
- "match:submit": "Submit match results on your behalf",
436
- "webhook:subscribe": "Receive notifications when your rating changes"
437
- };
438
- var DEFAULT_SCOPES = ["profile:read", "rating:read"];
439
- function getAuthorizationUrl(config, scopes = DEFAULT_SCOPES, state) {
440
- const baseUrl = config.baseUrl || "https://api-next.vairified.com/api/v1";
441
- const scopeSet = new Set(scopes);
442
- scopeSet.add("profile:read");
443
- const scopeList = Array.from(scopeSet);
444
- const params = new URLSearchParams({
445
- redirect_uri: config.redirectUri,
446
- scope: scopeList.join(","),
447
- response_type: "code"
448
- });
449
- if (state) {
450
- params.set("state", state);
413
+ // src/models/member.ts
414
+ var MemberSportMap = class {
415
+ #sports;
416
+ constructor(wire) {
417
+ const entries = Object.entries(wire ?? {}).map(
418
+ ([code, w]) => [code, new SportRating(w)]
419
+ );
420
+ this.#sports = new Map(entries);
421
+ Object.freeze(this);
451
422
  }
452
- return `${baseUrl}/partner/oauth/authorize?${params.toString()}`;
453
- }
454
- function validateScope(scope) {
455
- return scope in SCOPES;
456
- }
457
- function describeScope(scope) {
458
- return SCOPES[scope] ?? `Unknown scope: ${scope}`;
459
- }
460
- function describeScopes(scopes) {
461
- return scopes.map((scope) => ({
462
- scope,
463
- description: describeScope(scope)
464
- }));
465
- }
466
- function generateState() {
467
- const array = new Uint8Array(16);
468
- if (typeof crypto !== "undefined" && crypto.getRandomValues) {
469
- crypto.getRandomValues(array);
470
- } else {
471
- for (let i = 0; i < array.length; i++) {
472
- array[i] = Math.floor(Math.random() * 256);
473
- }
423
+ get(sport) {
424
+ return this.#sports.get(sport);
474
425
  }
475
- return Array.from(array).map((b) => b.toString(16).padStart(2, "0")).join("");
476
- }
477
-
478
- // src/client.ts
479
- var ENVIRONMENTS = {
480
- production: "https://api-next.vairified.com/api/v1",
481
- staging: "https://api-staging.vairified.com/api/v1",
482
- local: "http://localhost:3001/api/v1"
483
- };
484
- var DEFAULT_BASE_URL = ENVIRONMENTS.production;
485
- var DEFAULT_TIMEOUT = 3e4;
486
- var Vairified = class {
487
- /** API key */
488
- apiKey;
489
- /** Base URL */
490
- baseUrl;
491
- /** Environment name */
492
- env;
493
- /** Request timeout in ms */
494
- timeout;
495
- constructor(options = {}) {
496
- this.apiKey = options.apiKey || this.getEnvApiKey();
497
- if (!this.apiKey) {
498
- throw new Error("API key required. Pass apiKey option or set VAIRIFIED_API_KEY env var.");
499
- }
500
- if (options.baseUrl) {
501
- this.baseUrl = options.baseUrl.replace(/\/$/, "");
502
- this.env = "production";
503
- } else if (options.env) {
504
- this.baseUrl = ENVIRONMENTS[options.env];
505
- this.env = options.env;
506
- } else {
507
- const envVar = this.getEnvVar("VAIRIFIED_ENV");
508
- const defaultEnv = envVar && envVar in ENVIRONMENTS ? envVar : "production";
509
- this.baseUrl = ENVIRONMENTS[defaultEnv] || DEFAULT_BASE_URL;
510
- this.env = defaultEnv;
511
- }
512
- this.timeout = options.timeout || DEFAULT_TIMEOUT;
426
+ has(sport) {
427
+ return this.#sports.has(sport);
513
428
  }
514
- getEnvVar(name) {
515
- if (typeof process !== "undefined" && process.env?.[name]) {
516
- return process.env[name];
517
- }
518
- return "";
429
+ get size() {
430
+ return this.#sports.size;
519
431
  }
520
- getEnvApiKey() {
521
- return this.getEnvVar("VAIRIFIED_API_KEY");
432
+ keys() {
433
+ return this.#sports.keys();
522
434
  }
523
- getHeaders() {
524
- return {
525
- "X-API-Key": this.apiKey,
526
- "Content-Type": "application/json",
527
- Accept: "application/json"
528
- };
435
+ values() {
436
+ return this.#sports.values();
529
437
  }
530
- async handleError(response) {
531
- let body;
532
- let message;
533
- try {
534
- body = await response.json();
535
- message = body.message || response.statusText;
536
- } catch {
537
- message = response.statusText;
538
- }
539
- const status = response.status;
540
- if (status === 401) throw new AuthenticationError(message, body);
541
- if (status === 404) throw new NotFoundError(message, body);
542
- if (status === 429) {
543
- const retryAfter = response.headers.get("Retry-After");
544
- throw new RateLimitError(
545
- message,
546
- retryAfter ? Number.parseInt(retryAfter, 10) : void 0,
547
- body
548
- );
549
- }
550
- if (status === 400) throw new ValidationError(message, body);
551
- throw new VairifiedError(message, status, body);
552
- }
553
- async request(method, path, options) {
554
- let url = `${this.baseUrl}${path}`;
555
- if (options?.params) {
556
- const searchParams = new URLSearchParams();
557
- for (const [key, value] of Object.entries(options.params)) {
558
- if (value !== void 0 && value !== null) {
559
- searchParams.append(key, String(value));
560
- }
561
- }
562
- const queryString = searchParams.toString();
563
- if (queryString) url += `?${queryString}`;
564
- }
565
- const controller = new AbortController();
566
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
567
- try {
568
- const response = await fetch(url, {
569
- method,
570
- headers: this.getHeaders(),
571
- body: options?.body ? JSON.stringify(options.body) : void 0,
572
- signal: controller.signal
573
- });
574
- if (!response.ok) await this.handleError(response);
575
- return await response.json();
576
- } finally {
577
- clearTimeout(timeoutId);
578
- }
438
+ entries() {
439
+ return this.#sports.entries();
440
+ }
441
+ [Symbol.iterator]() {
442
+ return this.#sports.entries();
443
+ }
444
+ };
445
+ var Member = class {
446
+ memberId;
447
+ id;
448
+ firstName;
449
+ lastName;
450
+ fullName;
451
+ displayName;
452
+ age;
453
+ city;
454
+ state;
455
+ zip;
456
+ country;
457
+ gender;
458
+ status;
459
+ sport;
460
+ activeLeagues;
461
+ email;
462
+ grantedScopes;
463
+ constructor(wire) {
464
+ this.memberId = wire.memberId;
465
+ this.id = wire.id ?? null;
466
+ this.firstName = wire.firstName;
467
+ this.lastName = wire.lastName;
468
+ this.fullName = wire.fullName;
469
+ this.displayName = wire.displayName;
470
+ this.age = wire.age ?? null;
471
+ this.city = wire.city ?? null;
472
+ this.state = wire.state ?? null;
473
+ this.zip = wire.zip ?? null;
474
+ this.country = wire.country ?? null;
475
+ this.gender = wire.gender ?? null;
476
+ this.status = Object.freeze({ ...wire.status });
477
+ this.sport = new MemberSportMap(wire.sport);
478
+ this.activeLeagues = wire.activeLeagues ? Object.freeze([...wire.activeLeagues]) : null;
479
+ this.email = wire.email ?? null;
480
+ this.grantedScopes = wire.grantedScopes ? Object.freeze([...wire.grantedScopes]) : null;
481
+ Object.freeze(this);
482
+ }
483
+ /** Full name — alias for {@link fullName}, matching common usage. */
484
+ get name() {
485
+ return this.fullName;
486
+ }
487
+ /** The list of sport codes this player has ratings in. */
488
+ get sports() {
489
+ return [...this.sport.keys()];
579
490
  }
580
- // ---------------------------------------------------------------------------
581
- // Member Operations
582
- // ---------------------------------------------------------------------------
583
491
  /**
584
- * Get a connected member by their external ID.
492
+ * Primary rating for a given sport.
585
493
  *
586
- * **Requires OAuth Connection**: The player must have connected their
587
- * account to your application via OAuth before you can access their data.
588
- *
589
- * @param playerId - External player ID (vair_mem_xxx format)
590
- * @returns Member object with profile and rating data
591
- * @throws NotFoundError if member is not found or invalid ID format
592
- * @throws ForbiddenError if player has not connected to your app
593
- *
594
- * @example
595
- * ```ts
596
- * const member = await client.getMember('vair_mem_0ABC123def456GHI789jk');
597
- * console.log(member.name, member.rating);
598
- * console.log(member.ratingSplits.open); // Open division rating
599
- * console.log(member.grantedScopes); // ['profile:read', 'rating:read']
600
- * ```
494
+ * @param sport Sport code — defaults to `'pickleball'`.
495
+ * @returns The primary rating value, or `null` if the player has no
496
+ * ratings for that sport.
601
497
  */
602
- async getMember(playerId) {
603
- const data = await this.request("GET", "/partner/member", {
604
- params: { id: playerId }
605
- });
606
- return new Member(data, this);
498
+ ratingFor(sport = "pickleball") {
499
+ return this.sport.get(sport)?.rating ?? null;
607
500
  }
608
- // ---------------------------------------------------------------------------
609
- // Search Operations
610
- // ---------------------------------------------------------------------------
611
501
  /**
612
- * Search for players.
502
+ * Get a specific rating split for a sport.
613
503
  *
614
- * @param filters - Search filters
615
- * @returns SearchResults with players and pagination
616
- *
617
- * @example
618
- * ```ts
619
- * const results = await client.search({
620
- * city: 'Austin',
621
- * ratingMin: 4.0,
622
- * vairifiedOnly: true,
623
- * });
624
- *
625
- * for (const player of results) {
626
- * console.log(player.name, player.rating);
627
- * }
628
- *
629
- * // Pagination
630
- * if (results.hasMore) {
631
- * const nextPage = await results.nextPage();
632
- * }
633
- * ```
504
+ * @param key Split key (e.g. `'overall-open'`).
505
+ * @param sport Sport code — defaults to `'pickleball'`.
634
506
  */
635
- async search(filters = {}) {
636
- const params = {
637
- limit: filters.limit ?? 20
638
- };
639
- if (filters.name) params.member = filters.name;
640
- if (filters.city) params.city = filters.city;
641
- if (filters.state) params.state = filters.state;
642
- if (filters.country) params.country = filters.country;
643
- if (filters.zipCode) params.zip = filters.zipCode;
644
- if (filters.ratingMin !== void 0) params.rating1 = filters.ratingMin;
645
- if (filters.ratingMax !== void 0) params.rating2 = filters.ratingMax;
646
- if (filters.gender) params.gender = filters.gender;
647
- if (filters.vairifiedOnly) params.vairified = true;
648
- if (filters.sortBy) {
649
- params.sortField = filters.sortBy;
650
- params.sortDirection = filters.sortOrder ?? "desc";
651
- }
652
- if (filters.age !== void 0) {
653
- params.ageFilterType = "exact";
654
- params.age1 = filters.age;
655
- } else if (filters.ageMin !== void 0 && filters.ageMax !== void 0) {
656
- params.ageFilterType = "range";
657
- params.age1 = filters.ageMin;
658
- params.age2 = filters.ageMax;
659
- } else if (filters.ageMin !== void 0) {
660
- params.ageFilterType = "above";
661
- params.age1 = filters.ageMin;
662
- } else if (filters.ageMax !== void 0) {
663
- params.ageFilterType = "below";
664
- params.age1 = filters.ageMax;
507
+ split(key, sport = "pickleball") {
508
+ return this.sport.get(sport)?.get(key) ?? null;
509
+ }
510
+ /** Compact summary for console output. */
511
+ toString() {
512
+ const primary = this.sport.values().next().value;
513
+ if (primary) {
514
+ return `Member #${this.memberId} '${this.displayName}' rating=${primary.rating.toFixed(
515
+ 3
516
+ )} ${primary.abbr}`;
665
517
  }
666
- const page = filters.page ?? 1;
667
- if (page > 1) {
668
- params.offset = (page - 1) * (filters.limit ?? 20);
518
+ return `Member #${this.memberId} '${this.displayName}'`;
519
+ }
520
+ };
521
+
522
+ // src/models/rating-update.ts
523
+ var RatingUpdate = class {
524
+ memberId;
525
+ id;
526
+ displayName;
527
+ sport;
528
+ previousRating;
529
+ newRating;
530
+ changedAt;
531
+ ratingSplits;
532
+ constructor(wire) {
533
+ this.memberId = wire.memberId;
534
+ this.id = wire.id ?? null;
535
+ this.displayName = wire.displayName ?? null;
536
+ this.sport = wire.sport ?? null;
537
+ this.previousRating = wire.previousRating ?? null;
538
+ this.newRating = wire.newRating ?? null;
539
+ this.changedAt = wire.changedAt ?? null;
540
+ this.ratingSplits = wire.ratingSplits ? Object.freeze({ ...wire.ratingSplits }) : null;
541
+ Object.freeze(this);
542
+ }
543
+ /**
544
+ * Rating change amount — `newRating - previousRating`. Returns `null`
545
+ * if either rating is missing from the update payload.
546
+ */
547
+ get delta() {
548
+ if (this.previousRating === null || this.newRating === null) {
549
+ return null;
669
550
  }
670
- const data = await this.request(
671
- "GET",
672
- "/partner/search",
673
- {
674
- params
675
- }
676
- );
677
- const normalized = Array.isArray(data) ? { players: data, total: data.length, page, limit: filters.limit ?? 20 } : data;
678
- return new SearchResults(normalized, this, filters);
551
+ return this.newRating - this.previousRating;
552
+ }
553
+ /** `true` when the new rating is strictly higher than the previous. */
554
+ get improved() {
555
+ const delta = this.delta;
556
+ return delta !== null && delta > 0;
557
+ }
558
+ toString() {
559
+ const arrow = this.improved ? "\u2191" : "\u2193";
560
+ const prev = this.previousRating !== null ? this.previousRating.toFixed(3) : "?";
561
+ const next = this.newRating !== null ? this.newRating.toFixed(3) : "?";
562
+ const name = this.displayName ? ` '${this.displayName}'` : "";
563
+ return `RatingUpdate #${this.memberId}${name} ${prev} ${arrow} ${next}`;
564
+ }
565
+ };
566
+
567
+ // src/resources/members.ts
568
+ var DEFAULT_PAGE_SIZE = 20;
569
+ var MAX_PAGE_SIZE = 100;
570
+ var MembersResource = class {
571
+ #http;
572
+ /** @internal */
573
+ constructor(http) {
574
+ this.#http = http;
679
575
  }
680
576
  /**
681
- * Find a single player by name.
577
+ * Get a connected member by external ID.
578
+ *
579
+ * **Requires an active OAuth connection** between your partner app
580
+ * and the player. Use the OAuth flow on `client.oauth` first.
682
581
  *
683
- * @param name - Player name to search for
684
- * @returns Player if found, undefined otherwise
582
+ * @param playerId External player ID in `vair_mem_xxx` format.
583
+ * @param options.sport Optional sport filter — single code or list.
584
+ * When omitted, the response contains every sport the player has
585
+ * ratings in.
586
+ * @throws {@link NotFoundError} if the external ID is unknown.
587
+ * @throws {@link VairifiedError} if the player has not connected to
588
+ * your app (403) or the request otherwise fails.
685
589
  *
686
590
  * @example
687
591
  * ```ts
688
- * const player = await client.findPlayer('John Smith');
689
- * if (player) {
690
- * console.log(player.rating);
691
- * }
592
+ * const member = await client.members.get('vair_mem_xxx');
593
+ * console.log(member.name, member.ratingFor('pickleball'));
594
+ *
595
+ * // Just pickleball
596
+ * const member2 = await client.members.get('vair_mem_xxx', { sport: 'pickleball' });
597
+ *
598
+ * // Multiple sports
599
+ * const member3 = await client.members.get('vair_mem_xxx', {
600
+ * sport: ['pickleball', 'padel'],
601
+ * });
692
602
  * ```
693
603
  */
694
- async findPlayer(name) {
695
- const results = await this.search({ name, limit: 1 });
696
- return results.at(0);
604
+ async get(playerId, options = {}) {
605
+ const query = { id: playerId };
606
+ if (options.sport !== void 0) {
607
+ query.sport = Array.isArray(options.sport) ? options.sport.join(",") : options.sport;
608
+ }
609
+ const wire = await this.#http.request({
610
+ method: "GET",
611
+ path: "/partner/member",
612
+ query
613
+ });
614
+ return new Member(wire);
697
615
  }
698
- // ---------------------------------------------------------------------------
699
- // Match Operations
700
- // ---------------------------------------------------------------------------
701
616
  /**
702
- * Submit a single match.
617
+ * Search for members, yielding each match as a {@link Member}.
703
618
  *
704
- * @param match - Match object with teams and scores
705
- * @returns MatchResult with submission status
619
+ * This is an **auto-paginating async iterator** — it fetches pages
620
+ * from the server lazily as you iterate, so you can stream through
621
+ * thousands of results without holding them all in memory:
706
622
  *
707
- * @example
708
623
  * ```ts
709
- * const match = new Match({
710
- * event: 'Weekly League',
711
- * bracket: '4.0 Doubles',
712
- * date: new Date(),
713
- * team1: ['p1', 'p2'],
714
- * team2: ['p3', 'p4'],
715
- * scores: [[11, 9], [11, 7]],
716
- * });
717
- *
718
- * const result = await client.submitMatch(match);
719
- * if (result.ok) {
720
- * console.log(`Submitted ${result.numGames} games`);
624
+ * for await (const m of client.members.search({ city: 'Austin' })) {
625
+ * console.log(m.name, m.ratingFor('pickleball'));
721
626
  * }
722
627
  * ```
628
+ *
629
+ * Stop early by `break`-ing out of the loop, or cap the total with
630
+ * `maxResults`.
723
631
  */
724
- async submitMatch(match) {
725
- return this.submitMatches([match]);
632
+ async *search(filters = {}) {
633
+ const pageSize = Math.min(filters.pageSize ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
634
+ const maxResults = filters.maxResults;
635
+ const baseQuery = buildSearchQuery(filters, pageSize);
636
+ let offset = 0;
637
+ let yielded = 0;
638
+ while (true) {
639
+ const query = { ...baseQuery, offset };
640
+ const data = await this.#http.request({ method: "GET", path: "/partner/search", query });
641
+ const batch = Array.isArray(data) ? data : data?.players ?? [];
642
+ if (batch.length === 0) {
643
+ return;
644
+ }
645
+ for (const wire of batch) {
646
+ yield new Member(wire);
647
+ yielded += 1;
648
+ if (maxResults !== void 0 && yielded >= maxResults) {
649
+ return;
650
+ }
651
+ }
652
+ if (batch.length < pageSize) {
653
+ return;
654
+ }
655
+ offset += pageSize;
656
+ }
726
657
  }
727
658
  /**
728
- * Submit multiple matches in a batch.
659
+ * Return the first search hit for a name, or `null`.
729
660
  *
730
- * @param matches - List of Match objects
731
- * @returns MatchResult with submission status
661
+ * Convenience for the common "look up by name" case:
732
662
  *
733
- * @example
734
663
  * ```ts
735
- * const result = await client.submitMatches([match1, match2, match3]);
736
- * console.log(`Submitted ${result.numGames} games from ${result.numMatches} matches`);
737
- *
738
- * if (result.dryRun) {
739
- * console.log('This was a dry run - no data persisted');
664
+ * const mike = await client.members.find('Mike Barker');
665
+ * if (mike) {
666
+ * console.log(mike.ratingFor('pickleball'));
740
667
  * }
741
668
  * ```
742
669
  */
743
- async submitMatches(matches) {
744
- const data = await this.request("POST", "/partner/matches", {
745
- body: { matches: matches.map((m) => m.toJSON()) }
746
- });
747
- return new MatchResult(data);
670
+ async find(name) {
671
+ for await (const member of this.search({ name, pageSize: 1, maxResults: 1 })) {
672
+ return member;
673
+ }
674
+ return null;
748
675
  }
749
- // ---------------------------------------------------------------------------
750
- // Rating Updates
751
- // ---------------------------------------------------------------------------
752
676
  /**
753
- * Get rating updates for subscribed members.
677
+ * Fetch up to 100 members by their member IDs in one call.
754
678
  *
755
- * Members are subscribed when you call getMember().
679
+ * Unknown IDs are silently omitted — the returned array may be
680
+ * shorter than the input. Results are returned in the same order
681
+ * as the input IDs.
756
682
  *
757
- * @returns List of RatingUpdate objects
683
+ * @param ids - Array of integer member IDs (max 100).
684
+ * @param options - Optional filters.
685
+ * @param options.sport - Sport code to scope ratings (e.g. `'pickleball'`).
686
+ * @returns Array of {@link Member} instances.
687
+ * @throws {@link ValidationError} If more than 100 IDs are provided.
688
+ * @category Members
758
689
  *
759
690
  * @example
760
691
  * ```ts
761
- * const updates = await client.getRatingUpdates();
762
- * for (const update of updates) {
763
- * console.log(`${update.memberId}: ${update.previousRating} → ${update.newRating}`);
764
- * if (update.improved) {
765
- * const member = await update.getMember();
766
- * console.log(`${member.name} improved!`);
767
- * }
692
+ * const members = await client.members.getBulk([4873327, 4873328]);
693
+ * for (const m of members) {
694
+ * console.log(m.name, m.ratingFor('pickleball'));
768
695
  * }
769
696
  * ```
770
697
  */
771
- async getRatingUpdates() {
772
- const data = await this.request(
773
- "GET",
774
- "/partner/rating-updates"
775
- );
776
- return (data.updates ?? []).map((u) => new RatingUpdate(u, this));
698
+ async getBulk(ids, options) {
699
+ if (ids.length > 100) {
700
+ throw new ValidationError("Maximum 100 member IDs per request");
701
+ }
702
+ const query = { ids: ids.join(",") };
703
+ if (options?.sport) query.sport = options.sport;
704
+ const rows = await this.#http.request({
705
+ method: "GET",
706
+ path: "/partner/members",
707
+ query
708
+ });
709
+ return rows.map((row) => new Member(row));
777
710
  }
778
711
  /**
779
- * Test webhook endpoint.
712
+ * Poll for rating change notifications.
780
713
  *
781
- * @param webhookUrl - URL to send test webhook to
782
- * @returns Test result
714
+ * Returns a list of {@link RatingUpdate} objects for every player
715
+ * whose rating has changed since the last poll. Members are
716
+ * considered subscribed when they have an active OAuth connection
717
+ * with the `user:webhook:subscribe` scope.
783
718
  */
784
- async testWebhook(webhookUrl) {
785
- return this.request("POST", "/partner/webhook-test", {
786
- body: { webhookUrl }
719
+ async ratingUpdates() {
720
+ const data = await this.#http.request({
721
+ method: "GET",
722
+ path: "/partner/rating-updates"
787
723
  });
724
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
725
+ return [];
726
+ }
727
+ const updates = data.updates ?? [];
728
+ return updates.map((wire) => new RatingUpdate(wire));
729
+ }
730
+ };
731
+ function buildSearchQuery(filters, pageSize) {
732
+ const query = {};
733
+ if (filters.sport !== void 0) {
734
+ query.sport = Array.isArray(filters.sport) ? filters.sport.join(",") : filters.sport;
735
+ }
736
+ if (filters.memberId !== void 0) {
737
+ query.member = String(filters.memberId);
738
+ } else if (filters.name !== void 0) {
739
+ query.member = filters.name;
740
+ }
741
+ if (filters.city !== void 0) query.city = filters.city;
742
+ if (filters.state !== void 0) query.state = filters.state;
743
+ if (filters.country !== void 0) query.country = filters.country;
744
+ if (filters.zip !== void 0) query.zip = filters.zip;
745
+ if (filters.location !== void 0) query.location = filters.location;
746
+ if (filters.gender !== void 0) {
747
+ query.gender = filters.gender.toUpperCase();
748
+ }
749
+ if (filters.vairifiedOnly !== void 0) query.vairified = filters.vairifiedOnly;
750
+ if (filters.wheelchair !== void 0) query.wheelchair = filters.wheelchair;
751
+ if (filters.ratingMin !== void 0) query.rating1 = filters.ratingMin;
752
+ if (filters.ratingMax !== void 0) query.rating2 = filters.ratingMax;
753
+ const { ageFilterType, age1, age2 } = resolveAgeFilter(filters);
754
+ if (ageFilterType !== void 0) query.ageFilterType = ageFilterType;
755
+ if (age1 !== void 0) query.age1 = age1;
756
+ if (age2 !== void 0) query.age2 = age2;
757
+ if (filters.sortBy !== void 0) query.sortField = filters.sortBy;
758
+ if (filters.sortOrder !== void 0) query.sortDirection = filters.sortOrder;
759
+ query.limit = pageSize;
760
+ return query;
761
+ }
762
+ function resolveAgeFilter(filters) {
763
+ if (filters.age !== void 0) {
764
+ return { ageFilterType: "exact", age1: filters.age };
765
+ }
766
+ if (filters.ageMin !== void 0 && filters.ageMax !== void 0) {
767
+ return { ageFilterType: "range", age1: filters.ageMin, age2: filters.ageMax };
768
+ }
769
+ if (filters.ageMin !== void 0) {
770
+ return { ageFilterType: "above", age1: filters.ageMin };
771
+ }
772
+ if (filters.ageMax !== void 0) {
773
+ return { ageFilterType: "below", age1: filters.ageMax };
774
+ }
775
+ return {};
776
+ }
777
+
778
+ // src/oauth.ts
779
+ var SCOPES = Object.freeze({
780
+ "user:profile:read": "Access your name, location, and verification status",
781
+ "user:profile:email": "Access your email address",
782
+ "user:rating:read": "View your current rating and rating splits",
783
+ "user:rating:history": "View your complete rating history",
784
+ "user:match:submit": "Submit match results on your behalf",
785
+ "user:webhook:subscribe": "Receive notifications when your rating changes"
786
+ });
787
+ var DEFAULT_SCOPES = Object.freeze([
788
+ "user:profile:read",
789
+ "user:rating:read"
790
+ ]);
791
+ function getAuthorizationUrl(config, options = {}) {
792
+ const baseUrl = (config.baseUrl ?? "https://api-next.vairified.com/api/v1").replace(/\/+$/, "");
793
+ const scopeList = ensureProfileRead(options.scopes ?? DEFAULT_SCOPES);
794
+ const params = new URLSearchParams({
795
+ redirect_uri: config.redirectUri,
796
+ scope: scopeList.join(","),
797
+ response_type: "code"
798
+ });
799
+ if (options.state) {
800
+ params.set("state", options.state);
801
+ }
802
+ return `${baseUrl}/partner/oauth/authorize?${params.toString()}`;
803
+ }
804
+ function validateScope(scope) {
805
+ return scope in SCOPES;
806
+ }
807
+ function describeScope(scope) {
808
+ if (validateScope(scope)) {
809
+ return SCOPES[scope];
810
+ }
811
+ return `Unknown scope: ${scope}`;
812
+ }
813
+ function describeScopes(scopes) {
814
+ return scopes.map((scope) => ({
815
+ scope,
816
+ description: describeScope(scope)
817
+ }));
818
+ }
819
+ function generateState() {
820
+ const bytes = new Uint8Array(32);
821
+ crypto.getRandomValues(bytes);
822
+ let binary = "";
823
+ for (const byte of bytes) {
824
+ binary += String.fromCharCode(byte);
825
+ }
826
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
827
+ }
828
+ function ensureProfileRead(scopes) {
829
+ if (scopes.includes("user:profile:read")) {
830
+ return scopes;
831
+ }
832
+ return ["user:profile:read", ...scopes];
833
+ }
834
+
835
+ // src/resources/oauth.ts
836
+ var OAuthResource = class {
837
+ #http;
838
+ /** @internal */
839
+ constructor(http) {
840
+ this.#http = http;
788
841
  }
789
- // ---------------------------------------------------------------------------
790
- // OAuth Operations
791
- // ---------------------------------------------------------------------------
792
842
  /**
793
843
  * Start an OAuth authorization flow.
794
844
  *
795
- * This creates a pending authorization and returns the URL where
796
- * users should be redirected to approve access.
797
- *
798
- * @param redirectUri - Your application's callback URL
799
- * @param scopes - Permission scopes to request (defaults to profile:read, rating:read)
800
- * @param state - CSRF protection state parameter (recommended)
801
- * @returns AuthorizationResponse with the URL to redirect users to
802
- * @throws OAuthError if the authorization fails to start
803
- *
804
- * @example
805
- * ```ts
806
- * const auth = await client.startOAuth(
807
- * 'https://myapp.com/callback',
808
- * ['profile:read', 'rating:read', 'match:submit'],
809
- * 'random_csrf_token',
810
- * );
811
- * // Redirect user to auth.authorizationUrl
812
- * window.location.href = auth.authorizationUrl;
813
- * ```
814
- *
815
- * @category OAuth
845
+ * @throws {@link OAuthError} with `errorCode: 'invalid_scope'` if a
846
+ * requested scope is not in the accepted list.
816
847
  */
817
- async startOAuth(redirectUri, scopes = [...DEFAULT_SCOPES], state) {
818
- const scopeSet = new Set(scopes);
819
- scopeSet.add("profile:read");
820
- const scopeList = Array.from(scopeSet);
848
+ async authorize(options) {
849
+ const scopeList = ensureProfileRead(options.scopes ?? DEFAULT_SCOPES);
821
850
  for (const scope of scopeList) {
822
851
  if (!(scope in SCOPES)) {
823
852
  throw new OAuthError(`Invalid scope: ${scope}`, "invalid_scope");
824
853
  }
825
854
  }
826
- const data = await this.request("POST", "/partner/oauth/authorize", {
855
+ const data = await this.#http.request({
856
+ method: "POST",
857
+ path: "/partner/oauth/authorize",
827
858
  body: {
828
- redirectUri,
859
+ redirectUri: options.redirectUri,
829
860
  scope: scopeList.join(","),
830
- state
861
+ state: options.state
831
862
  }
832
863
  });
833
864
  return {
834
- authorizationUrl: data.authorizationUrl,
835
- code: data.code,
836
- state
865
+ authorizationUrl: data?.authorizationUrl ?? "",
866
+ code: data?.code ?? "",
867
+ state: options.state
837
868
  };
838
869
  }
839
- /**
840
- * Exchange an authorization code for access and refresh tokens.
841
- *
842
- * Call this after the user approves access and is redirected back
843
- * to your application with a code parameter.
844
- *
845
- * @param code - Authorization code from the callback URL
846
- * @param redirectUri - Must match the redirectUri used in startOAuth
847
- * @returns TokenResponse with access_token, refresh_token, and player_id
848
- * @throws OAuthError if the code is invalid or expired
849
- *
850
- * @example
851
- * ```ts
852
- * // After user is redirected to: https://myapp.com/callback?code=xxx
853
- * const tokens = await client.exchangeToken(
854
- * new URL(window.location.href).searchParams.get('code')!,
855
- * 'https://myapp.com/callback',
856
- * );
857
- * // Store tokens.accessToken and tokens.refreshToken securely
858
- * // Use tokens.playerId to identify the connected player
859
- * ```
860
- *
861
- * @category OAuth
862
- */
863
- async exchangeToken(code, redirectUri) {
864
- const data = await this.request("POST", "/partner/oauth/token", {
865
- body: { code, redirectUri }
870
+ /** Exchange an authorization code for access and refresh tokens. */
871
+ async exchangeToken(options) {
872
+ const data = await this.#http.request({
873
+ method: "POST",
874
+ path: "/partner/oauth/token",
875
+ body: { code: options.code, redirectUri: options.redirectUri }
866
876
  });
867
- return {
868
- accessToken: data.accessToken,
869
- refreshToken: data.refreshToken,
870
- expiresIn: data.expiresIn,
871
- scope: data.scope ? data.scope.split(",") : [],
872
- playerId: data.playerId
873
- };
877
+ return tokenResponseFromWire(data);
878
+ }
879
+ /** Refresh an expired access token using a refresh token. */
880
+ async refresh(refreshToken) {
881
+ const data = await this.#http.request({
882
+ method: "POST",
883
+ path: "/partner/oauth/refresh",
884
+ body: { refreshToken }
885
+ });
886
+ return tokenResponseFromWire(data);
887
+ }
888
+ /** Revoke a player's OAuth connection to your app. */
889
+ async revoke(playerId) {
890
+ const data = await this.#http.request({
891
+ method: "POST",
892
+ path: "/partner/oauth/revoke",
893
+ body: { playerId }
894
+ });
895
+ return data ?? {};
896
+ }
897
+ /** Return the list of OAuth scopes the server currently supports. */
898
+ async availableScopes() {
899
+ const data = await this.#http.request({
900
+ method: "GET",
901
+ path: "/partner/oauth/scopes"
902
+ });
903
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
904
+ return [];
905
+ }
906
+ return data.scopes ?? [];
907
+ }
908
+ };
909
+ function tokenResponseFromWire(data) {
910
+ const scopeRaw = data?.scope ?? "";
911
+ const scopeList = scopeRaw.length > 0 ? scopeRaw.split(",") : [];
912
+ return {
913
+ accessToken: data?.accessToken ?? "",
914
+ refreshToken: data?.refreshToken ?? null,
915
+ expiresIn: data?.expiresIn ?? 3600,
916
+ scope: Object.freeze(scopeList),
917
+ playerId: data?.playerId ?? ""
918
+ };
919
+ }
920
+
921
+ // src/models/webhook-delivery.ts
922
+ var WebhookDelivery = class {
923
+ id;
924
+ event;
925
+ url;
926
+ statusCode;
927
+ responseBody;
928
+ errorMessage;
929
+ attempts;
930
+ maxAttempts;
931
+ lastAttemptAt;
932
+ nextRetryAt;
933
+ completedAt;
934
+ createdAt;
935
+ payload;
936
+ /** @internal */
937
+ constructor(wire) {
938
+ this.id = wire.id;
939
+ this.event = wire.event;
940
+ this.url = wire.url;
941
+ this.statusCode = wire.statusCode;
942
+ this.responseBody = wire.responseBody;
943
+ this.errorMessage = wire.errorMessage;
944
+ this.attempts = wire.attempts;
945
+ this.maxAttempts = wire.maxAttempts;
946
+ this.lastAttemptAt = wire.lastAttemptAt;
947
+ this.nextRetryAt = wire.nextRetryAt;
948
+ this.completedAt = wire.completedAt;
949
+ this.createdAt = wire.createdAt;
950
+ this.payload = Object.freeze({ ...wire.payload });
951
+ Object.freeze(this);
952
+ }
953
+ /** Whether delivery completed successfully (2xx status). */
954
+ get succeeded() {
955
+ return this.completedAt != null && this.statusCode != null && this.statusCode >= 200 && this.statusCode < 300;
956
+ }
957
+ /** Whether delivery failed definitively (completed with non-2xx). */
958
+ get failed() {
959
+ return this.completedAt != null && !this.succeeded;
960
+ }
961
+ };
962
+ var WebhookDeliveriesResult = class {
963
+ deliveries;
964
+ total;
965
+ /** @internal */
966
+ constructor(wire) {
967
+ this.deliveries = Object.freeze(wire.deliveries.map((d) => new WebhookDelivery(d)));
968
+ this.total = wire.total;
969
+ Object.freeze(this);
970
+ }
971
+ };
972
+
973
+ // src/resources/webhooks.ts
974
+ var WebhooksResource = class {
975
+ #http;
976
+ /** @internal */
977
+ constructor(http) {
978
+ this.#http = http;
874
979
  }
875
980
  /**
876
- * Refresh an expired access token.
877
- *
878
- * Use this when an access token expires to obtain a new one
879
- * without requiring the user to re-authorize.
981
+ * List recent webhook delivery attempts.
880
982
  *
881
- * @param refreshToken - The refresh token from a previous token exchange
882
- * @returns TokenResponse with new access_token and optionally a new refresh_token
883
- * @throws OAuthError if the refresh token is invalid or revoked
983
+ * @param options - Optional filters and pagination.
984
+ * @param options.event - Filter by event type (e.g. `'rating.updated'`).
985
+ * @param options.status - Filter: `'all'`, `'pending'`, `'success'`, or `'failed'`.
986
+ * @param options.limit - Results per page (1-100, default 20).
987
+ * @param options.offset - Pagination offset.
988
+ * @returns {@link WebhookDeliveriesResult} with entries and total.
989
+ * @category Webhooks
884
990
  *
885
991
  * @example
886
992
  * ```ts
887
- * try {
888
- * const newTokens = await client.refreshAccessToken(storedRefreshToken);
889
- * // Update stored tokens
890
- * } catch (e) {
891
- * if (e instanceof OAuthError && e.errorCode === 'invalid_grant') {
892
- * // Refresh token revoked, user needs to re-authorize
893
- * }
993
+ * const result = await client.webhooks.deliveries({ status: 'failed' });
994
+ * for (const d of result.deliveries) {
995
+ * console.log(d.event, d.statusCode, d.errorMessage);
894
996
  * }
895
997
  * ```
896
- *
897
- * @category OAuth
898
998
  */
899
- async refreshAccessToken(refreshToken) {
900
- const data = await this.request("POST", "/partner/oauth/refresh", {
901
- body: { refreshToken }
999
+ async deliveries(options) {
1000
+ const query = {};
1001
+ if (options?.event) query.event = options.event;
1002
+ if (options?.status) query.status = options.status;
1003
+ if (options?.limit != null) query.limit = options.limit;
1004
+ if (options?.offset != null) query.offset = options.offset;
1005
+ const data = await this.#http.request({
1006
+ method: "GET",
1007
+ path: "/partner/webhook-deliveries",
1008
+ query
902
1009
  });
903
- return {
904
- accessToken: data.accessToken,
905
- refreshToken: data.refreshToken,
906
- expiresIn: data.expiresIn,
907
- scope: data.scope ? data.scope.split(",") : [],
908
- playerId: data.playerId
909
- };
1010
+ return new WebhookDeliveriesResult(data);
1011
+ }
1012
+ };
1013
+
1014
+ // src/client.ts
1015
+ var ENVIRONMENTS = Object.freeze({
1016
+ production: "https://api-next.vairified.com/api/v1",
1017
+ staging: "https://api-staging.vairified.com/api/v1",
1018
+ local: "http://localhost:3001/api/v1"
1019
+ });
1020
+ var DEFAULT_TIMEOUT_MS = 3e4;
1021
+ var Vairified = class {
1022
+ /** The resolved API key this client is using. */
1023
+ apiKey;
1024
+ /** The resolved base URL (production, staging, local, or custom). */
1025
+ baseUrl;
1026
+ /** The resolved environment name. */
1027
+ env;
1028
+ /** Request timeout in milliseconds. */
1029
+ timeoutMs;
1030
+ /** Member operations — get, search, find, ratingUpdates. */
1031
+ members;
1032
+ /** Match submission — submit, testWebhook. */
1033
+ matches;
1034
+ /** OAuth flow — authorize, exchangeToken, refresh, revoke. */
1035
+ oauth;
1036
+ /** Leaderboard queries — list, rank, categories. */
1037
+ leaderboard;
1038
+ /** Webhook delivery inspection — deliveries. */
1039
+ webhooks;
1040
+ #transport;
1041
+ constructor(options = {}) {
1042
+ const apiKey = options.apiKey ?? process.env.VAIRIFIED_API_KEY ?? "";
1043
+ if (apiKey.length === 0) {
1044
+ throw new Error("API key required. Pass { apiKey } or set VAIRIFIED_API_KEY.");
1045
+ }
1046
+ this.apiKey = apiKey;
1047
+ if (options.baseUrl) {
1048
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
1049
+ this.env = options.env ?? "production";
1050
+ } else {
1051
+ const envName = options.env ?? process.env.VAIRIFIED_ENV ?? "production";
1052
+ if (options.env && !(envName in ENVIRONMENTS)) {
1053
+ throw new Error(
1054
+ `Unknown environment: ${envName}. Use one of: ${Object.keys(ENVIRONMENTS).join(", ")}`
1055
+ );
1056
+ }
1057
+ this.env = envName;
1058
+ this.baseUrl = ENVIRONMENTS[envName] ?? ENVIRONMENTS.production;
1059
+ }
1060
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1061
+ this.#transport = new HttpTransport({
1062
+ baseUrl: this.baseUrl,
1063
+ apiKey: this.apiKey,
1064
+ timeoutMs: this.timeoutMs,
1065
+ fetch: options.fetch ?? fetch
1066
+ });
1067
+ this.members = new MembersResource(this.#transport);
1068
+ this.matches = new MatchesResource(this.#transport);
1069
+ this.oauth = new OAuthResource(this.#transport);
1070
+ this.leaderboard = new LeaderboardResource(this.#transport);
1071
+ this.webhooks = new WebhooksResource(this.#transport);
910
1072
  }
911
1073
  /**
912
- * Revoke a player's OAuth connection.
913
- *
914
- * This disconnects the player from your application. You will no
915
- * longer be able to access their data or submit matches on their behalf.
1074
+ * API usage statistics for the current API key.
916
1075
  *
917
- * @param playerId - The player's external ID (vair_mem_xxx format)
918
- * @throws OAuthError if the revocation fails
919
- *
920
- * @example
921
- * ```ts
922
- * await client.revokeConnection('vair_mem_0ABC123def456GHI789jk');
923
- * // Player is now disconnected
924
- * ```
925
- *
926
- * @category OAuth
1076
+ * Returns rate-limit status, request counts, and quota usage.
927
1077
  */
928
- async revokeConnection(playerId) {
929
- await this.request("POST", "/partner/oauth/revoke", {
930
- body: { playerId }
1078
+ async usage() {
1079
+ const data = await this.#transport.request({
1080
+ method: "GET",
1081
+ path: "/partner/usage"
931
1082
  });
1083
+ return data ?? {};
932
1084
  }
933
1085
  /**
934
- * Get a list of available OAuth scopes.
1086
+ * Release any resources held by the client.
935
1087
  *
936
- * @returns List of scope objects with id, name, and description
937
- *
938
- * @example
939
- * ```ts
940
- * const scopes = await client.getAvailableScopes();
941
- * for (const scope of scopes) {
942
- * console.log(`${scope.id}: ${scope.description}`);
943
- * }
944
- * ```
945
- *
946
- * @category OAuth
1088
+ * The current transport is stateless, so this is a no-op today, but
1089
+ * partners should still call it (or use `await using`) so the SDK
1090
+ * can add connection pooling later without breaking them.
947
1091
  */
948
- async getAvailableScopes() {
949
- const data = await this.request("GET", "/partner/oauth/scopes");
950
- return data.scopes ?? [];
1092
+ async close() {
951
1093
  }
952
1094
  /**
953
- * Get API usage statistics for your partner account.
954
- *
955
- * @returns Usage statistics (requests, limits, etc.)
956
- *
957
- * @example
958
- * ```ts
959
- * const usage = await client.getUsage();
960
- * console.log(`Requests today: ${usage.requestsToday}`);
961
- * console.log(`Rate limit: ${usage.rateLimit}/hour`);
962
- * ```
963
- *
964
- * @category Client
1095
+ * Explicit resource management hook — enables
1096
+ * `await using client = new Vairified({ ... })` (TypeScript 5.2+).
965
1097
  */
966
- async getUsage() {
967
- return this.request("GET", "/partner/usage");
1098
+ async [Symbol.asyncDispose]() {
1099
+ await this.close();
1100
+ }
1101
+ /** Compact summary for console output. */
1102
+ toString() {
1103
+ return `Vairified { env: '${this.env}', baseUrl: '${this.baseUrl}' }`;
968
1104
  }
969
1105
  };
970
1106
  export {
971
1107
  AuthenticationError,
972
1108
  DEFAULT_SCOPES,
973
- Match,
974
- MatchResult,
1109
+ ENVIRONMENTS,
1110
+ LeaderboardResource,
1111
+ MatchBatchResult,
1112
+ MatchesResource,
975
1113
  Member,
1114
+ MemberSportMap,
1115
+ MembersResource,
976
1116
  NotFoundError,
977
1117
  OAuthError,
978
- Player,
1118
+ OAuthResource,
979
1119
  RateLimitError,
980
- RatingSplit,
981
- RatingSplits,
982
1120
  RatingUpdate,
983
1121
  SCOPES,
984
- SearchResults,
1122
+ SportRating,
1123
+ TournamentImportResult,
985
1124
  Vairified,
986
1125
  VairifiedError,
987
1126
  ValidationError,
1127
+ WebhookDeliveriesResult,
1128
+ WebhookDelivery,
1129
+ WebhooksResource,
988
1130
  describeScope,
989
1131
  describeScopes,
990
1132
  generateState,