@odla-ai/chapter 0.41.0 → 0.42.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.
@@ -44,7 +44,7 @@ interface ChapterCopy {
44
44
  payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete" | "consent">;
45
45
  done: TextFields<"label" | "calendarInvite" | "memberArea">;
46
46
  accepted: TextFields<"label" | "body">;
47
- gift: TextFields<"chooseSelf" | "optionsLabel" | "label" | "body" | "save" | "rotate" | "rotateFailed">;
47
+ gift: TextFields<"chooseSelf" | "optionsLabel" | "label" | "body" | "save" | "rotate" | "rotateFailed" | "claimTitle" | "claimBody" | "code" | "redeem" | "redeemed" | "redeemFailed">;
48
48
  };
49
49
  members: {
50
50
  loadFailed: string;
@@ -44,7 +44,7 @@ interface ChapterCopy {
44
44
  payment: TextFields<"setupFailed" | "preparing" | "pending" | "processing" | "payAndContinue" | "incomplete" | "consent">;
45
45
  done: TextFields<"label" | "calendarInvite" | "memberArea">;
46
46
  accepted: TextFields<"label" | "body">;
47
- gift: TextFields<"chooseSelf" | "optionsLabel" | "label" | "body" | "save" | "rotate" | "rotateFailed">;
47
+ gift: TextFields<"chooseSelf" | "optionsLabel" | "label" | "body" | "save" | "rotate" | "rotateFailed" | "claimTitle" | "claimBody" | "code" | "redeem" | "redeemed" | "redeemFailed">;
48
48
  };
49
49
  members: {
50
50
  loadFailed: string;
@@ -1981,7 +1981,12 @@ var handleMember = async (req, url, env, ctx) => {
1981
1981
  const group = Array.isArray(groups) ? groups[0] : void 0;
1982
1982
  if (!group) return json({ error: "not found" }, 404);
1983
1983
  const state = await continueJoin(db, ctx, env, url.origin, app, group);
1984
- if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
1984
+ if (state) return json({
1985
+ state,
1986
+ applicationId,
1987
+ ...state === "gift" ? { claimId: app.giftClaimId } : {},
1988
+ ...state === "payment" && typeof app.giftOfferId === "string" ? { giftPurchase: true } : {}
1989
+ });
1985
1990
  return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
1986
1991
  }
1987
1992
  if (req.method === "POST" && url.pathname === "/api/applications") {
@@ -2030,6 +2035,7 @@ var handleMember = async (req, url, env, ctx) => {
2030
2035
  duplicate: result.duplicate,
2031
2036
  status: result.status,
2032
2037
  ...state ? { state } : {},
2038
+ ...state === "payment" && requestedGiftId ? { giftPurchase: true } : {},
2033
2039
  // Echoed so a site can see (and assert in an integration test) whether its
2034
2040
  // join page actually posted the ack. Absent consent is otherwise invisible.
2035
2041
  disclaimerAckAt: result.disclaimerAckAt
@@ -3728,9 +3734,15 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
3728
3734
  const target = claim.target;
3729
3735
  const ops = [];
3730
3736
  let result;
3737
+ let applicationGuard;
3731
3738
  if (target.type === "tier") {
3732
- const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
3733
- const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3739
+ const apps = beneficiary.applicationId ? (await db.query({ applications: { $: { where: { id: beneficiary.applicationId }, limit: 1 } } })).applications : (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
3740
+ const existing = beneficiary.applicationId ? apps?.[0] : apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3741
+ if (beneficiary.applicationId && (!existing || String(existing.groupId ?? "") !== chapterId || String(existing.emailNormalized ?? "") !== emailNormalized || String(existing.tierId ?? "") !== target.tierId || String(existing.status ?? "") !== "submitted" || typeof existing.stripeSubscriptionId === "string" || typeof existing.giftClaimId === "string" || typeof existing.clerkUserId === "string" && existing.clerkUserId !== beneficiary.userId)) return null;
3742
+ if (beneficiary.applicationId) applicationGuard = {
3743
+ tierId: target.tierId,
3744
+ ...typeof existing?.clerkUserId === "string" ? { clerkUserId: existing.clerkUserId } : {}
3745
+ };
3734
3746
  const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
3735
3747
  const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
3736
3748
  ops.push({ t: "update", ns: "applications", id, attrs: {
@@ -3747,6 +3759,7 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
3747
3759
  } });
3748
3760
  result = { kind: "application", id };
3749
3761
  } else {
3762
+ if (beneficiary.applicationId) return null;
3750
3763
  const id = `gift-entitlement:${String(claim.id)}`;
3751
3764
  ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
3752
3765
  id,
@@ -3770,13 +3783,27 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
3770
3783
  attemptCount: Number(claim.attemptCount ?? 0) + 1
3771
3784
  } });
3772
3785
  try {
3786
+ const guards = [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3787
+ status: "pending",
3788
+ codeVersion: Number(claim.codeVersion),
3789
+ codeHash: String(claim.codeHash)
3790
+ } }];
3791
+ if (beneficiary.applicationId) guards.push({
3792
+ ns: "applications",
3793
+ id: beneficiary.applicationId,
3794
+ exists: true,
3795
+ equals: {
3796
+ status: "submitted",
3797
+ groupId: chapterId,
3798
+ emailNormalized,
3799
+ tierId: applicationGuard.tierId,
3800
+ ...applicationGuard?.clerkUserId ? { clerkUserId: applicationGuard.clerkUserId } : {}
3801
+ },
3802
+ absent: ["stripeSubscriptionId", "giftClaimId"]
3803
+ });
3773
3804
  await db.transact(ops, {
3774
3805
  mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
3775
- guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3776
- status: "pending",
3777
- codeVersion: Number(claim.codeVersion),
3778
- codeHash: String(claim.codeHash)
3779
- } }]
3806
+ guards
3780
3807
  });
3781
3808
  return result;
3782
3809
  } catch {
@@ -4291,15 +4318,35 @@ var handleGifts = async (req, url, env, ctx) => {
4291
4318
  return json({ error: "too many attempts" }, 429);
4292
4319
  }
4293
4320
  let code = "";
4321
+ let applicationId = "";
4294
4322
  try {
4295
- code = String(JSON.parse(await req.text()).code ?? "").trim();
4323
+ const body = JSON.parse(await req.text());
4324
+ code = String(body.code ?? "").trim();
4325
+ applicationId = typeof body.applicationId === "string" ? body.applicationId : "";
4296
4326
  } catch {
4297
4327
  return invalid();
4298
4328
  }
4299
- if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
4329
+ if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code) || !applicationId) return invalid();
4330
+ const app = first3((await db.query({ applications: { $: { where: { id: applicationId }, limit: 1 } } })).applications);
4331
+ const emailNormalized = normalizeChapterEmail(user.email);
4332
+ if (!app || String(app.groupId ?? "") !== ctx.chapter.id || String(app.emailNormalized ?? "") !== emailNormalized || typeof app.clerkUserId === "string" && app.clerkUserId !== user.userId) return invalid();
4300
4333
  const codeHash = await hashGiftCode(ctx.chapter.id, code);
4301
4334
  const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
4302
4335
  if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
4336
+ const sameRedemption = (candidate) => candidate.status === "redeemed" && candidate.applicationId === applicationId && candidate.beneficiaryEmailNormalized === emailNormalized && candidate.beneficiaryUserId === user.userId;
4337
+ const success = async () => {
4338
+ const current = first3((await db.query({ applications: { $: { where: { id: applicationId }, limit: 1 } } })).applications);
4339
+ const group2 = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4340
+ const state = current && group2 ? await continueJoin(db, ctx, env, url.origin, current, group2) : null;
4341
+ return json({
4342
+ ok: true,
4343
+ kind: "application",
4344
+ id: applicationId,
4345
+ applicationId,
4346
+ ...state ? { state } : {}
4347
+ });
4348
+ };
4349
+ if (sameRedemption(claim)) return success();
4303
4350
  if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
4304
4351
  await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
4305
4352
  attemptCount: Number(claim.attemptCount ?? 0) + 1,
@@ -4307,8 +4354,18 @@ var handleGifts = async (req, url, env, ctx) => {
4307
4354
  } }]).catch(() => void 0);
4308
4355
  return invalid();
4309
4356
  }
4310
- const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
4311
- return redeemed ? json({ ok: true, ...redeemed }) : invalid();
4357
+ const target = claim.target;
4358
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4359
+ const tier = group ? findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null) : void 0;
4360
+ if (String(app.status ?? "") !== "submitted" || typeof app.stripeSubscriptionId === "string" || typeof app.giftClaimId === "string" || !tier || tierIsFree(tier) || target?.type !== "tier" || target.tierId !== app.tierId) return invalid();
4361
+ const redeemed = await redeemGiftClaim(db, claim, {
4362
+ email: user.email,
4363
+ userId: user.userId,
4364
+ applicationId
4365
+ }, now);
4366
+ if (redeemed) return success();
4367
+ const raced = await loadClaim(db, String(claim.id));
4368
+ return raced && sameRedemption(raced) ? success() : invalid();
4312
4369
  }
4313
4370
  return null;
4314
4371
  };