@onesub/server 0.20.0 → 0.21.1

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
@@ -84,11 +84,17 @@ var InMemoryPurchaseStore = class {
84
84
  }
85
85
  this.byTransactionId.set(purchase.transactionId, purchase);
86
86
  const list = this.byUserId.get(purchase.userId) ?? [];
87
- list.push(purchase);
87
+ const at = Date.parse(purchase.purchasedAt);
88
+ const idx = list.findIndex((p) => Date.parse(p.purchasedAt) < at);
89
+ if (idx === -1) list.push(purchase);
90
+ else list.splice(idx, 0, purchase);
88
91
  this.byUserId.set(purchase.userId, list);
89
92
  }
90
93
  async getPurchasesByUserId(userId) {
91
- return this.byUserId.get(userId) ?? [];
94
+ return [...this.byUserId.get(userId) ?? []];
95
+ }
96
+ async getPurchasesForProduct(userId, productId) {
97
+ return (this.byUserId.get(userId) ?? []).filter((p) => p.productId === productId);
92
98
  }
93
99
  async getPurchaseByTransactionId(txId) {
94
100
  return this.byTransactionId.get(txId) ?? null;
@@ -1265,6 +1271,16 @@ function sendZodError(res, err2, extra = {}) {
1265
1271
  extra
1266
1272
  );
1267
1273
  }
1274
+ function parseOrSend(res, schema, input, opts = {}) {
1275
+ const result = schema.safeParse(input);
1276
+ if (result.success) return result.data;
1277
+ if (opts.message !== void 0) {
1278
+ sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, opts.message, opts.extra ?? {});
1279
+ } else {
1280
+ sendZodError(res, result.error, opts.extra ?? {});
1281
+ }
1282
+ return void 0;
1283
+ }
1268
1284
 
1269
1285
  // src/apps.ts
1270
1286
  function appName(app) {
@@ -1363,20 +1379,9 @@ function createValidateRouter(config, store) {
1363
1379
  const router = Router();
1364
1380
  const registry = getAppRegistry(config);
1365
1381
  router.post(ROUTES.VALIDATE, async (req, res) => {
1366
- let platform;
1367
- let receipt;
1368
- let userId;
1369
- let productId;
1370
- let appId;
1371
- try {
1372
- ({ platform, receipt, userId, productId, appId } = validateSchema.parse(req.body));
1373
- } catch (err2) {
1374
- if (err2 instanceof z.ZodError) {
1375
- sendZodError(res, err2, NO_SUB);
1376
- return;
1377
- }
1378
- throw err2;
1379
- }
1382
+ const body = parseOrSend(res, validateSchema, req.body, { extra: NO_SUB });
1383
+ if (!body) return;
1384
+ const { platform, receipt, userId, productId, appId } = body;
1380
1385
  try {
1381
1386
  let sub = null;
1382
1387
  const appConfig = registry.configFor({
@@ -1974,20 +1979,19 @@ var purchaseStatusQuerySchema = z.object({
1974
1979
  userId: z.string().min(1).max(256),
1975
1980
  productId: z.string().min(1).max(256).optional()
1976
1981
  });
1982
+ async function purchasesForProduct(purchaseStore, userId, productId) {
1983
+ if (purchaseStore.getPurchasesForProduct) {
1984
+ return purchaseStore.getPurchasesForProduct(userId, productId);
1985
+ }
1986
+ const all = await purchaseStore.getPurchasesByUserId(userId);
1987
+ return all.filter((p) => p.productId === productId);
1988
+ }
1977
1989
  function createPurchaseRouter(config, purchaseStore) {
1978
1990
  const router = Router();
1979
1991
  const registry = getAppRegistry(config);
1980
1992
  router.post(ROUTES.VALIDATE_PURCHASE, async (req, res) => {
1981
- let body;
1982
- try {
1983
- body = validatePurchaseSchema.parse(req.body);
1984
- } catch (err2) {
1985
- if (err2 instanceof z.ZodError) {
1986
- sendZodError(res, err2, NO_PURCHASE);
1987
- return;
1988
- }
1989
- throw err2;
1990
- }
1993
+ const body = parseOrSend(res, validatePurchaseSchema, req.body, { extra: NO_PURCHASE });
1994
+ if (!body) return;
1991
1995
  const { platform, receipt, userId, productId, type, appId } = body;
1992
1996
  const appConfig = registry.configFor({
1993
1997
  appId,
@@ -1995,9 +1999,7 @@ function createPurchaseRouter(config, purchaseStore) {
1995
1999
  });
1996
2000
  try {
1997
2001
  if (type === PURCHASE_TYPE.NON_CONSUMABLE) {
1998
- const owned = (await purchaseStore.getPurchasesByUserId(userId)).find(
1999
- (p) => p.productId === productId
2000
- );
2002
+ const owned = (await purchasesForProduct(purchaseStore, userId, productId))[0];
2001
2003
  if (owned) {
2002
2004
  const response2 = {
2003
2005
  valid: true,
@@ -2115,22 +2117,13 @@ function createPurchaseRouter(config, purchaseStore) {
2115
2117
  }
2116
2118
  });
2117
2119
  router.get(ROUTES.PURCHASE_STATUS, async (req, res) => {
2118
- let query;
2119
- try {
2120
- query = purchaseStatusQuerySchema.parse(req.query);
2121
- } catch (err2) {
2122
- if (err2 instanceof z.ZodError) {
2123
- sendZodError(res, err2, { purchases: [] });
2124
- return;
2125
- }
2126
- throw err2;
2127
- }
2120
+ const query = parseOrSend(res, purchaseStatusQuerySchema, req.query, {
2121
+ extra: { purchases: [] }
2122
+ });
2123
+ if (!query) return;
2128
2124
  const { userId, productId } = query;
2129
2125
  try {
2130
- let purchases = await purchaseStore.getPurchasesByUserId(userId);
2131
- if (productId !== void 0) {
2132
- purchases = purchases.filter((p) => p.productId === productId);
2133
- }
2126
+ const purchases = productId !== void 0 ? await purchasesForProduct(purchaseStore, userId, productId) : await purchaseStore.getPurchasesByUserId(userId);
2134
2127
  const response = { purchases };
2135
2128
  res.status(200).json(response);
2136
2129
  } catch (err2) {
@@ -2175,6 +2168,8 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
2175
2168
  }
2176
2169
  var userIdSchema = z.string().min(1).max(256);
2177
2170
  var entitlementIdSchema = z.string().min(1).max(128);
2171
+ var entitlementQuerySchema = z.object({ userId: userIdSchema, id: entitlementIdSchema });
2172
+ var entitlementsQuerySchema = z.object({ userId: userIdSchema });
2178
2173
  function createEntitlementRouter(config, store, purchaseStore) {
2179
2174
  if (!config.entitlements || Object.keys(config.entitlements).length === 0) {
2180
2175
  return null;
@@ -2182,15 +2177,11 @@ function createEntitlementRouter(config, store, purchaseStore) {
2182
2177
  const entitlements = config.entitlements;
2183
2178
  const router = Router();
2184
2179
  router.get(ROUTES.ENTITLEMENT, async (req, res) => {
2185
- let userId;
2186
- let id;
2187
- try {
2188
- userId = userIdSchema.parse(req.query["userId"]);
2189
- id = entitlementIdSchema.parse(req.query["id"]);
2190
- } catch {
2191
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId and id are required");
2192
- return;
2193
- }
2180
+ const query = parseOrSend(res, entitlementQuerySchema, req.query, {
2181
+ message: "userId and id are required"
2182
+ });
2183
+ if (!query) return;
2184
+ const { userId, id } = query;
2194
2185
  const entitlement = entitlements[id];
2195
2186
  if (!entitlement) {
2196
2187
  sendError(res, 404, ONESUB_ERROR_CODE.ENTITLEMENT_NOT_FOUND, `Unknown entitlement: ${id}`);
@@ -2206,13 +2197,11 @@ function createEntitlementRouter(config, store, purchaseStore) {
2206
2197
  }
2207
2198
  });
2208
2199
  router.get(ROUTES.ENTITLEMENTS, async (req, res) => {
2209
- let userId;
2210
- try {
2211
- userId = userIdSchema.parse(req.query["userId"]);
2212
- } catch {
2213
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId is required");
2214
- return;
2215
- }
2200
+ const query = parseOrSend(res, entitlementsQuerySchema, req.query, {
2201
+ message: "userId is required"
2202
+ });
2203
+ if (!query) return;
2204
+ const { userId } = query;
2216
2205
  try {
2217
2206
  const [subs, purchases] = await Promise.all([
2218
2207
  store.getAllByUserId(userId),
@@ -2261,13 +2250,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2261
2250
  productId: z.string().min(1).max(256)
2262
2251
  });
2263
2252
  router.delete("/onesub/purchase/admin/:userId/:productId", async (req, res) => {
2264
- let params;
2265
- try {
2266
- params = resetParamsSchema.parse(req.params);
2267
- } catch {
2268
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId and productId required");
2269
- return;
2270
- }
2253
+ const params = parseOrSend(res, resetParamsSchema, req.params, {
2254
+ message: "userId and productId required"
2255
+ });
2256
+ if (!params) return;
2271
2257
  const deleted = await purchaseStore.deletePurchases(params.userId, params.productId);
2272
2258
  res.json({ ok: true, deleted });
2273
2259
  });
@@ -2276,16 +2262,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2276
2262
  newUserId: z.string().min(1).max(256)
2277
2263
  });
2278
2264
  router.post("/onesub/purchase/admin/transfer", async (req, res) => {
2279
- let body;
2280
- try {
2281
- body = transferSchema.parse(req.body);
2282
- } catch (err2) {
2283
- if (err2 instanceof z.ZodError) {
2284
- sendZodError(res, err2);
2285
- return;
2286
- }
2287
- throw err2;
2288
- }
2265
+ const body = parseOrSend(res, transferSchema, req.body);
2266
+ if (!body) return;
2289
2267
  const existing = await purchaseStore.getPurchaseByTransactionId(body.transactionId);
2290
2268
  if (!existing) {
2291
2269
  sendError(res, 404, ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
@@ -2307,16 +2285,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2307
2285
  transactionId: z.string().min(1).max(256).optional()
2308
2286
  });
2309
2287
  router.post("/onesub/purchase/admin/grant", async (req, res) => {
2310
- let body;
2311
- try {
2312
- body = grantSchema.parse(req.body);
2313
- } catch (err2) {
2314
- if (err2 instanceof z.ZodError) {
2315
- sendZodError(res, err2);
2316
- return;
2317
- }
2318
- throw err2;
2319
- }
2288
+ const body = parseOrSend(res, grantSchema, req.body);
2289
+ if (!body) return;
2320
2290
  const transactionId = body.transactionId ?? `admin_grant_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
2321
2291
  const purchase = {
2322
2292
  transactionId,
@@ -2348,16 +2318,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2348
2318
  offset: z.coerce.number().int().nonnegative().optional()
2349
2319
  });
2350
2320
  router.get(ROUTES.ADMIN_SUBSCRIPTIONS, async (req, res) => {
2351
- let query;
2352
- try {
2353
- query = listQuerySchema.parse(req.query);
2354
- } catch (err2) {
2355
- if (err2 instanceof z.ZodError) {
2356
- sendZodError(res, err2);
2357
- return;
2358
- }
2359
- throw err2;
2360
- }
2321
+ const query = parseOrSend(res, listQuerySchema, req.query);
2322
+ if (!query) return;
2361
2323
  try {
2362
2324
  const result = await store.listFiltered(query);
2363
2325
  const response = {
@@ -2368,20 +2330,18 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2368
2330
  };
2369
2331
  res.status(200).json(response);
2370
2332
  } catch (err2) {
2371
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
2333
+ log.error("[onesub/admin/subscriptions] list error:", err2);
2334
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2372
2335
  }
2373
2336
  });
2374
2337
  const detailParamsSchema = z.object({
2375
2338
  transactionId: z.string().min(1).max(256)
2376
2339
  });
2377
2340
  router.get("/onesub/admin/subscriptions/:transactionId", async (req, res) => {
2378
- let params;
2379
- try {
2380
- params = detailParamsSchema.parse(req.params);
2381
- } catch {
2382
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "transactionId required");
2383
- return;
2384
- }
2341
+ const params = parseOrSend(res, detailParamsSchema, req.params, {
2342
+ message: "transactionId required"
2343
+ });
2344
+ if (!params) return;
2385
2345
  try {
2386
2346
  const sub = await store.getByTransactionId(params.transactionId);
2387
2347
  if (!sub) {
@@ -2390,20 +2350,18 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2390
2350
  }
2391
2351
  res.status(200).json(sub);
2392
2352
  } catch (err2) {
2393
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "detail error");
2353
+ log.error("[onesub/admin/subscriptions/:transactionId] detail error:", err2);
2354
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2394
2355
  }
2395
2356
  });
2396
2357
  const customerParamsSchema = z.object({
2397
2358
  userId: z.string().min(1).max(256)
2398
2359
  });
2399
2360
  router.get("/onesub/admin/customers/:userId", async (req, res) => {
2400
- let params;
2401
- try {
2402
- params = customerParamsSchema.parse(req.params);
2403
- } catch {
2404
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
2405
- return;
2406
- }
2361
+ const params = parseOrSend(res, customerParamsSchema, req.params, {
2362
+ message: "userId required"
2363
+ });
2364
+ if (!params) return;
2407
2365
  try {
2408
2366
  const [subscriptions, purchases] = await Promise.all([
2409
2367
  store.getAllByUserId(params.userId),
@@ -2425,20 +2383,18 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2425
2383
  };
2426
2384
  res.status(200).json(response);
2427
2385
  } catch (err2) {
2428
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "customer error");
2386
+ log.error("[onesub/admin/customers/:userId] error:", err2);
2387
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2429
2388
  }
2430
2389
  });
2431
2390
  const syncAppleParamsSchema = z.object({
2432
2391
  originalTransactionId: z.string().min(1).max(256)
2433
2392
  });
2434
2393
  router.post(ROUTES.ADMIN_SYNC_APPLE, async (req, res) => {
2435
- let params;
2436
- try {
2437
- params = syncAppleParamsSchema.parse(req.params);
2438
- } catch {
2439
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "originalTransactionId required");
2440
- return;
2441
- }
2394
+ const params = parseOrSend(res, syncAppleParamsSchema, req.params, {
2395
+ message: "originalTransactionId required"
2396
+ });
2397
+ if (!params) return;
2442
2398
  if (!config.apple?.issuerId || !config.apple?.keyId || !config.apple?.privateKey) {
2443
2399
  sendError(res, 400, ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple API credentials not configured");
2444
2400
  return;
@@ -2457,7 +2413,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2457
2413
  await store.save(fresh);
2458
2414
  res.status(200).json({ ok: true, subscription: fresh });
2459
2415
  } catch (err2) {
2460
- sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
2416
+ log.error("[onesub/admin/sync-apple] error:", err2);
2417
+ sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error");
2461
2418
  }
2462
2419
  });
2463
2420
  const overrideParamsSchema = z.object({ userId: z.string().min(1).max(256) });
@@ -2466,19 +2423,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2466
2423
  res.json({ overrides: listTestOverrides() });
2467
2424
  });
2468
2425
  router.put(ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
2469
- let params;
2470
- let body;
2471
- try {
2472
- params = overrideParamsSchema.parse(req.params);
2473
- body = overrideBodySchema.parse(req.body);
2474
- } catch (err2) {
2475
- if (err2 instanceof z.ZodError) {
2476
- sendZodError(res, err2);
2477
- } else {
2478
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId and { entitled: boolean } required");
2479
- }
2480
- return;
2481
- }
2426
+ const params = parseOrSend(res, overrideParamsSchema, req.params);
2427
+ if (!params) return;
2428
+ const body = parseOrSend(res, overrideBodySchema, req.body);
2429
+ if (!body) return;
2482
2430
  setTestOverride(params.userId, body.entitled);
2483
2431
  log.warn(
2484
2432
  `[onesub/admin] sandbox test override set for ${params.userId}: entitled=${body.entitled}`
@@ -2486,13 +2434,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2486
2434
  res.json({ ok: true, userId: params.userId, entitled: body.entitled, sandboxOnly: true });
2487
2435
  });
2488
2436
  router.delete(ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
2489
- let params;
2490
- try {
2491
- params = overrideParamsSchema.parse(req.params);
2492
- } catch {
2493
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
2494
- return;
2495
- }
2437
+ const params = parseOrSend(res, overrideParamsSchema, req.params, {
2438
+ message: "userId required"
2439
+ });
2440
+ if (!params) return;
2496
2441
  const cleared = clearTestOverride(params.userId);
2497
2442
  res.json({ ok: true, userId: params.userId, cleared });
2498
2443
  });
@@ -2502,25 +2447,22 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2502
2447
  const items = await webhookQueue.listDeadLetters();
2503
2448
  res.status(200).json({ items });
2504
2449
  } catch (err2) {
2505
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
2450
+ log.error("[onesub/admin/webhook-deadletters] error:", err2);
2451
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2506
2452
  }
2507
2453
  });
2508
2454
  }
2509
2455
  if (webhookQueue?.replayDeadLetter) {
2510
2456
  const replayParamsSchema = z.object({ id: z.string().min(1).max(256) });
2511
2457
  router.post("/onesub/admin/webhook-replay/:id", async (req, res) => {
2512
- let params;
2513
- try {
2514
- params = replayParamsSchema.parse(req.params);
2515
- } catch {
2516
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_INPUT, "id required");
2517
- return;
2518
- }
2458
+ const params = parseOrSend(res, replayParamsSchema, req.params, { message: "id required" });
2459
+ if (!params) return;
2519
2460
  try {
2520
2461
  await webhookQueue.replayDeadLetter(params.id);
2521
2462
  res.status(200).json({ ok: true });
2522
2463
  } catch (err2) {
2523
- sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "replay error");
2464
+ log.error("[onesub/admin/webhook-replay] error:", err2);
2465
+ sendError(res, 500, ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2524
2466
  }
2525
2467
  });
2526
2468
  }
@@ -2779,16 +2721,8 @@ function createAppleOfferRouter(config) {
2779
2721
  return;
2780
2722
  }
2781
2723
  }
2782
- let body;
2783
- try {
2784
- body = offerBodySchema.parse(req.body);
2785
- } catch (err2) {
2786
- if (err2 instanceof z.ZodError) {
2787
- sendZodError(res, err2);
2788
- return;
2789
- }
2790
- throw err2;
2791
- }
2724
+ const body = parseOrSend(res, offerBodySchema, req.body);
2725
+ if (!body) return;
2792
2726
  if (!apple.bundleId) {
2793
2727
  sendError(res, 400, ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "config.apple.bundleId is required for offer signing");
2794
2728
  return;
@@ -2805,7 +2739,8 @@ function createAppleOfferRouter(config) {
2805
2739
  );
2806
2740
  res.status(200).json(result);
2807
2741
  } catch (err2) {
2808
- sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "Offer signing failed");
2742
+ log.error("[onesub/apple/offer] signing error:", err2);
2743
+ sendError(res, 500, ONESUB_ERROR_CODE.INTERNAL_ERROR, "Offer signing failed");
2809
2744
  }
2810
2745
  });
2811
2746
  return router;
@@ -3106,6 +3041,26 @@ var PostgresPurchaseStore = class {
3106
3041
  );
3107
3042
  return result.rows.map(rowToPurchaseInfo);
3108
3043
  }
3044
+ /**
3045
+ * Purchases for one user + product, most-recent-first.
3046
+ *
3047
+ * Same shape and ordering as `getPurchasesByUserId`, with `product_id` pushed
3048
+ * into the WHERE clause instead of filtered in process. Non-consumables also
3049
+ * have a partial unique index on `(user_id, product_id)`, so the ownership
3050
+ * check this backs is a single index lookup rather than a full read of the
3051
+ * user's purchase history.
3052
+ */
3053
+ async getPurchasesForProduct(userId, productId) {
3054
+ const pool = await this.getPool();
3055
+ const result = await pool.query(
3056
+ `SELECT *
3057
+ FROM onesub_purchases
3058
+ WHERE user_id = $1 AND product_id = $2
3059
+ ORDER BY purchased_at DESC`,
3060
+ [userId, productId]
3061
+ );
3062
+ return result.rows.map(rowToPurchaseInfo);
3063
+ }
3109
3064
  async getPurchaseByTransactionId(txId) {
3110
3065
  const pool = await this.getPool();
3111
3066
  const result = await pool.query(
@@ -3316,6 +3271,23 @@ var RedisPurchaseStore = class {
3316
3271
  const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
3317
3272
  return raws.filter((r) => r != null).map((r) => JSON.parse(r));
3318
3273
  }
3274
+ /**
3275
+ * Purchases for one user + product, most-recent-first.
3276
+ *
3277
+ * Served from the `user_product` set that `savePurchase` already maintains for
3278
+ * `hasPurchased`, so this reads only the rows for this product rather than the
3279
+ * user's whole purchase history.
3280
+ *
3281
+ * That set is unordered — unlike the per-user sorted set — so the
3282
+ * most-recent-first contract is restored by an explicit sort here. The row
3283
+ * count is per-product, so this is small.
3284
+ */
3285
+ async getPurchasesForProduct(userId, productId) {
3286
+ const ids = await this.redis.smembers(PUR_USER_PRODUCT_PREFIX + userId + ":" + productId);
3287
+ if (ids.length === 0) return [];
3288
+ const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
3289
+ return raws.filter((r) => r != null).map((r) => JSON.parse(r)).sort((a, b) => Date.parse(b.purchasedAt) - Date.parse(a.purchasedAt));
3290
+ }
3319
3291
  async getPurchaseByTransactionId(txId) {
3320
3292
  const raw = await this.redis.get(PUR_TX_PREFIX + txId);
3321
3293
  return raw ? JSON.parse(raw) : null;