@odla-ai/chapter 0.51.0 → 0.52.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/README.md +110 -8
- package/dist/{chunk-JXX4BFDP.js → chunk-4M2PN5K3.js} +8 -2
- package/dist/chunk-4M2PN5K3.js.map +1 -0
- package/dist/{chunk-UAXWA5BL.js → chunk-TEHCORNC.js} +31 -12
- package/dist/chunk-TEHCORNC.js.map +1 -0
- package/dist/{chunk-4EA5V4JE.js → chunk-WWF2TO3H.js} +239 -136
- package/dist/chunk-WWF2TO3H.js.map +1 -0
- package/dist/{copy-context-Ddat__H5.d.ts → copy-context-qvFo6eA7.d.ts} +12 -6
- package/dist/index.cjs +91 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -13
- package/dist/index.d.ts +71 -13
- package/dist/index.js +91 -8
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +14 -3
- package/dist/ui/admin/index.js +4 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +5 -3
- package/dist/ui/member/index.d.ts +20 -14
- package/dist/ui/member/index.js +2 -2
- package/dist/worker/index.cjs +912 -354
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +48 -10
- package/dist/worker/index.d.ts +48 -10
- package/dist/worker/index.js +912 -354
- package/dist/worker/index.js.map +1 -1
- package/package.json +2 -1
- package/dist/chunk-4EA5V4JE.js.map +0 -1
- package/dist/chunk-JXX4BFDP.js.map +0 -1
- package/dist/chunk-UAXWA5BL.js.map +0 -1
package/dist/worker/index.js
CHANGED
|
@@ -143,11 +143,11 @@ async function clerkVerifiedPrimaryEmail(db, userId) {
|
|
|
143
143
|
|
|
144
144
|
// src/crm-person-projection.ts
|
|
145
145
|
import { createRecord } from "@odla-ai/crm";
|
|
146
|
-
async function ensureProjectedPerson(
|
|
146
|
+
async function ensureProjectedPerson(deps, input, options = {}) {
|
|
147
147
|
const email = String(input.email ?? "").trim().toLowerCase();
|
|
148
148
|
if (!email) throw new Error("crm_projection_email_required");
|
|
149
149
|
const find = async () => {
|
|
150
|
-
const records = (await
|
|
150
|
+
const records = (await deps.db.query({ crm_record: { $: {
|
|
151
151
|
where: { type: "person", primaryEmail: email },
|
|
152
152
|
limit: 2
|
|
153
153
|
} } })).crm_record ?? [];
|
|
@@ -160,7 +160,7 @@ async function ensureProjectedPerson(deps2, input, options = {}) {
|
|
|
160
160
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`chapter:person:v1:${email}`)));
|
|
161
161
|
const id6 = `person-${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
162
162
|
try {
|
|
163
|
-
const created = await createRecord({ ...
|
|
163
|
+
const created = await createRecord({ ...deps, db: deps.db }, {
|
|
164
164
|
id: id6,
|
|
165
165
|
type: "person",
|
|
166
166
|
input: { ...input, email },
|
|
@@ -176,14 +176,14 @@ async function ensureProjectedPerson(deps2, input, options = {}) {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// src/crm-account-projection.ts
|
|
179
|
-
async function syncAccountToCrm(
|
|
180
|
-
const user = (await
|
|
179
|
+
async function syncAccountToCrm(deps, userId) {
|
|
180
|
+
const user = (await deps.db.query({ $users: { $: { where: { id: userId }, limit: 1 } } })).$users?.[0];
|
|
181
181
|
if (!user || user.deleted === true || typeof user.email !== "string" || !user.email.trim()) return null;
|
|
182
|
-
const linked = (await
|
|
182
|
+
const linked = (await deps.db.query({ crm_record: { $: { where: { type: "person", clerkUserId: userId }, limit: 2 } } })).crm_record ?? [];
|
|
183
183
|
if (linked.length > 1) throw new Error("crm_projection_identity_ambiguous");
|
|
184
184
|
if (linked.length === 1) return String(linked[0].id);
|
|
185
185
|
const email = user.email.trim().toLowerCase();
|
|
186
|
-
const peers = (await
|
|
186
|
+
const peers = (await deps.db.query({ $users: { $: { where: { email }, limit: 2 } } })).$users ?? [];
|
|
187
187
|
if (peers.length !== 1 || peers[0]?.id !== userId) throw new Error("crm_projection_identity_ambiguous");
|
|
188
188
|
const userGuard = {
|
|
189
189
|
ns: "$users",
|
|
@@ -192,14 +192,14 @@ async function syncAccountToCrm(deps2, userId) {
|
|
|
192
192
|
equals: { email: user.email, ...user.deleted !== void 0 ? { deleted: user.deleted } : {} },
|
|
193
193
|
...user.deleted === void 0 ? { absent: ["deleted"] } : {}
|
|
194
194
|
};
|
|
195
|
-
const { record: record4 } = await ensureProjectedPerson(
|
|
195
|
+
const { record: record4 } = await ensureProjectedPerson(deps, {
|
|
196
196
|
name: typeof user.name === "string" && user.name.trim() ? user.name : email,
|
|
197
197
|
email
|
|
198
198
|
}, { stage: null, guards: [userGuard], emailConsent: { state: "unsubscribed", source: "account-no-marketing-consent" } });
|
|
199
199
|
if (record4.clerkUserId === userId) return String(record4.id);
|
|
200
200
|
if (record4.clerkUserId != null) throw new Error("crm_projection_identity_conflict");
|
|
201
201
|
try {
|
|
202
|
-
await
|
|
202
|
+
await deps.db.transact([{ t: "update", ns: "crm_record", id: String(record4.id), attrs: { clerkUserId: userId } }], {
|
|
203
203
|
guards: [userGuard, {
|
|
204
204
|
ns: "crm_record",
|
|
205
205
|
id: String(record4.id),
|
|
@@ -215,12 +215,37 @@ async function syncAccountToCrm(deps2, userId) {
|
|
|
215
215
|
});
|
|
216
216
|
} catch (error) {
|
|
217
217
|
if (error.code !== "transact_guard_failed") throw error;
|
|
218
|
-
const current = (await
|
|
218
|
+
const current = (await deps.db.query({ crm_record: { $: { where: { id: record4.id }, limit: 1 } } })).crm_record?.[0];
|
|
219
219
|
if (current?.clerkUserId !== userId) throw error;
|
|
220
220
|
}
|
|
221
221
|
return String(record4.id);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// src/worker-alerts.ts
|
|
225
|
+
var consoleErrorRecorder = (error, report) => {
|
|
226
|
+
console.error("chapter_alert", JSON.stringify({ message: error.message, ...report }));
|
|
227
|
+
};
|
|
228
|
+
function reportChapterAlert(ctx, error, report) {
|
|
229
|
+
(ctx?.recordError ?? consoleErrorRecorder)(error, report);
|
|
230
|
+
}
|
|
231
|
+
var ACTIVATION_LIMIT = 50;
|
|
232
|
+
function networkCommercialAlerts(ctx, route) {
|
|
233
|
+
return {
|
|
234
|
+
exceptionsActivated(activated, activeCount) {
|
|
235
|
+
if (activated.length === 0) return;
|
|
236
|
+
reportChapterAlert(ctx, new Error("network commercial exception activated"), {
|
|
237
|
+
code: "network_commercial_exception_activated",
|
|
238
|
+
route,
|
|
239
|
+
attributes: {
|
|
240
|
+
"odla.commercial.active_exceptions": activeCount,
|
|
241
|
+
"odla.commercial.activated_exceptions": activated.length
|
|
242
|
+
},
|
|
243
|
+
artifacts: { exceptions: activated.slice(0, ACTIVATION_LIMIT).map(({ eventId, kind }) => ({ eventId, kind })) }
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
224
249
|
// src/worker-context.ts
|
|
225
250
|
var json = (body, status2 = 200) => new Response(JSON.stringify(body), { status: status2, headers: { "content-type": "application/json" } });
|
|
226
251
|
function createWorkerContext(options) {
|
|
@@ -330,6 +355,7 @@ function createWorkerContext(options) {
|
|
|
330
355
|
requirePaymentQuote: options.requirePaymentQuote === true,
|
|
331
356
|
membershipAuthority: options.membershipAuthority,
|
|
332
357
|
membershipAuthorityOperator: options.membershipAuthorityOperator,
|
|
358
|
+
recordError: options.recordError ?? consoleErrorRecorder,
|
|
333
359
|
getPublicConfig,
|
|
334
360
|
verifyUser,
|
|
335
361
|
makeDb,
|
|
@@ -823,6 +849,17 @@ function projectNetworkCommercialEvents(input, sources) {
|
|
|
823
849
|
};
|
|
824
850
|
}
|
|
825
851
|
|
|
852
|
+
// src/network-commercial-fact-identity.ts
|
|
853
|
+
var stable = (value) => JSON.stringify(value, function(_key, item) {
|
|
854
|
+
return item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).filter(([key3]) => key3 !== "digest").sort(([a], [b]) => a.localeCompare(b))) : item;
|
|
855
|
+
});
|
|
856
|
+
function sameNetworkCommercialFact(stored, incoming) {
|
|
857
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored)) return false;
|
|
858
|
+
const comparable = { ...incoming };
|
|
859
|
+
if (!stored.seatSubscriptionItems) delete comparable.seatSubscriptionItems;
|
|
860
|
+
return stable(comparable) === stable(stored);
|
|
861
|
+
}
|
|
862
|
+
|
|
826
863
|
// src/network-commercial-store.ts
|
|
827
864
|
var eventRowId = (eventId) => `network-commercial-event:${eventId}`;
|
|
828
865
|
var projectionId = (kind, sourceKey, providerId3) => `network-commercial:${kind}:${sourceKey}:${providerId3}`;
|
|
@@ -834,6 +871,7 @@ var materializedNamespaces = [
|
|
|
834
871
|
"networkCommercialRefunds",
|
|
835
872
|
"networkCommercialSeats"
|
|
836
873
|
];
|
|
874
|
+
var RESOLUTION_ATTRS = ["resolvedDigest", "reviewedDigest", "resolvedBy", "reviewId"];
|
|
837
875
|
async function rows(db, ns) {
|
|
838
876
|
const all = [];
|
|
839
877
|
const pageSize = 500;
|
|
@@ -883,13 +921,14 @@ function projectionOps(projection, now) {
|
|
|
883
921
|
}
|
|
884
922
|
})));
|
|
885
923
|
}
|
|
886
|
-
async function rebuildNetworkCommercialProjection(db, sources, mutationId, now = Date.now()) {
|
|
924
|
+
async function rebuildNetworkCommercialProjection(db, sources, mutationId, now = Date.now(), alerts) {
|
|
887
925
|
const projection = await loadNetworkCommercialProjection(db, sources);
|
|
888
926
|
const [existing, ...materialized] = await Promise.all([
|
|
889
927
|
rows(db, "networkCommercialExceptions"),
|
|
890
928
|
...materializedNamespaces.map((ns) => rows(db, ns))
|
|
891
929
|
]);
|
|
892
930
|
const activeIds = new Set(projection.exceptions.map((item) => exceptionId(item.eventId, item.kind)));
|
|
931
|
+
const wasActive = new Set(existing.filter((row2) => row2.status === "active").map((row2) => String(row2.id)));
|
|
893
932
|
const projectedIds = new Set(projectionOps(projection, now).map((op) => String(op.id)));
|
|
894
933
|
const ops = [
|
|
895
934
|
...projectionOps(projection, now),
|
|
@@ -915,27 +954,80 @@ async function rebuildNetworkCommercialProjection(db, sources, mutationId, now =
|
|
|
915
954
|
}
|
|
916
955
|
}))
|
|
917
956
|
];
|
|
918
|
-
if (ops.length
|
|
957
|
+
if (ops.length === 0) return projection;
|
|
958
|
+
const write = await db.transact(ops, { mutationId });
|
|
959
|
+
if (!write.duplicate) {
|
|
960
|
+
alerts?.exceptionsActivated(
|
|
961
|
+
projection.exceptions.filter((item) => !wasActive.has(exceptionId(item.eventId, item.kind))),
|
|
962
|
+
projection.exceptions.length
|
|
963
|
+
);
|
|
964
|
+
}
|
|
919
965
|
return projection;
|
|
920
966
|
}
|
|
921
|
-
async function
|
|
922
|
-
const
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
967
|
+
async function collide(db, sources, stored, fact, now, legacyDigest, alerts) {
|
|
968
|
+
const id6 = exceptionId(fact.eventId, "collision");
|
|
969
|
+
const exception = (await db.query({ networkCommercialExceptions: { $: { where: { id: id6 }, limit: 1 } } })).networkCommercialExceptions?.[0];
|
|
970
|
+
const exact = stored.digest === fact.digest || stored.digest === legacyDigest;
|
|
971
|
+
const canonical3 = !exact && sameNetworkCommercialFact(stored.fact, fact);
|
|
972
|
+
if (exact || canonical3) {
|
|
973
|
+
const recorded = exception?.incomingFact;
|
|
974
|
+
const resolvable = canonical3 && exception?.status === "active" && (recorded === void 0 || sameNetworkCommercialFact(stored.fact, recorded));
|
|
975
|
+
if (!resolvable) return { status: "replayed", projection: await loadNetworkCommercialProjection(db, sources) };
|
|
976
|
+
const reviewId = crypto.randomUUID();
|
|
977
|
+
await db.transact([
|
|
978
|
+
{ t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
|
|
979
|
+
id: reviewId,
|
|
980
|
+
eventId: fact.eventId,
|
|
981
|
+
actorUserId: "system",
|
|
982
|
+
reviewedAt: now,
|
|
983
|
+
previousDigest: String(stored.digest),
|
|
984
|
+
providerDigest: fact.digest,
|
|
985
|
+
scope: "network",
|
|
986
|
+
previousState: exception,
|
|
987
|
+
action: "canonical_replay",
|
|
988
|
+
reason: "a resend matched the retained canonical fact"
|
|
989
|
+
} },
|
|
990
|
+
{ t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
|
|
991
|
+
status: "resolved",
|
|
992
|
+
resolvedDigest: stored.digest,
|
|
993
|
+
reviewedDigest: fact.digest,
|
|
994
|
+
resolvedBy: "system",
|
|
995
|
+
reviewId,
|
|
996
|
+
updatedAt: now
|
|
997
|
+
} }
|
|
998
|
+
], { mutationId: `network-commercial-resolve:${fact.eventId}:${fact.digest}`, guards: [
|
|
999
|
+
{ ns: "networkCommercialExceptions", id: id6, exists: true, equals: { status: "active", updatedAt: exception.updatedAt } }
|
|
1000
|
+
] });
|
|
1001
|
+
const projection2 = await rebuildNetworkCommercialProjection(db, sources, `network-commercial-resolve-rebuild:${fact.eventId}:${fact.digest}`, now, alerts);
|
|
1002
|
+
return { status: "replayed", resolvedCollision: true, projection: projection2 };
|
|
1003
|
+
}
|
|
1004
|
+
if (exception?.status === "resolved" && exception.reviewedDigest === fact.digest) {
|
|
1005
|
+
return { status: "replayed", projection: await loadNetworkCommercialProjection(db, sources) };
|
|
1006
|
+
}
|
|
1007
|
+
const stale = RESOLUTION_ATTRS.filter((attr3) => exception !== void 0 && Object.hasOwn(exception, attr3));
|
|
1008
|
+
await db.transact([
|
|
1009
|
+
...stale.length ? [{ t: "retract", ns: "networkCommercialExceptions", id: id6, attrs: stale }] : [],
|
|
1010
|
+
{ t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
|
|
1011
|
+
id: id6,
|
|
930
1012
|
eventId: fact.eventId,
|
|
931
1013
|
kind: "collision",
|
|
932
1014
|
status: "active",
|
|
933
1015
|
incomingDigest: fact.digest,
|
|
1016
|
+
incomingFact: fact,
|
|
934
1017
|
detail: { eventType: fact.eventType, objectId: fact.objectId },
|
|
935
1018
|
updatedAt: now
|
|
936
|
-
} }
|
|
937
|
-
|
|
1019
|
+
} }
|
|
1020
|
+
]);
|
|
1021
|
+
const projection = await loadNetworkCommercialProjection(db, sources);
|
|
1022
|
+
if (exception?.status !== "active") {
|
|
1023
|
+
alerts?.exceptionsActivated([{ eventId: fact.eventId, kind: "collision", detail: { eventType: fact.eventType, objectId: fact.objectId } }], projection.exceptions.length);
|
|
938
1024
|
}
|
|
1025
|
+
return { status: "collision", projection };
|
|
1026
|
+
}
|
|
1027
|
+
async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(), legacyDigest, alerts) {
|
|
1028
|
+
const found = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
|
|
1029
|
+
const existing = found.networkCommercialEvents?.[0];
|
|
1030
|
+
if (existing) return collide(db, sources, existing, fact, now, legacyDigest, alerts);
|
|
939
1031
|
const id6 = eventRowId(fact.eventId);
|
|
940
1032
|
let lostRace = false;
|
|
941
1033
|
let duplicate = false;
|
|
@@ -961,25 +1053,15 @@ async function ingestNetworkCommercialFact(db, sources, fact, now = Date.now(),
|
|
|
961
1053
|
if (duplicate || lostRace) {
|
|
962
1054
|
const raced = await db.query({ networkCommercialEvents: { $: { where: { eventId: fact.eventId }, limit: 1 } } });
|
|
963
1055
|
const row2 = raced.networkCommercialEvents?.[0];
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const id7 = exceptionId(fact.eventId, "collision");
|
|
967
|
-
await db.transact([{ t: "update", ns: "networkCommercialExceptions", id: id7, attrs: {
|
|
968
|
-
id: id7,
|
|
969
|
-
eventId: fact.eventId,
|
|
970
|
-
kind: "collision",
|
|
971
|
-
status: "active",
|
|
972
|
-
incomingDigest: fact.digest,
|
|
973
|
-
detail: { eventType: fact.eventType, objectId: fact.objectId },
|
|
974
|
-
updatedAt: now
|
|
975
|
-
} }]);
|
|
976
|
-
return { status: "collision", projection: await loadNetworkCommercialProjection(db, sources) };
|
|
1056
|
+
if (!row2) throw new Error("network commercial event vanished after a lost race");
|
|
1057
|
+
return collide(db, sources, row2, fact, now, legacyDigest, alerts);
|
|
977
1058
|
}
|
|
978
1059
|
const projection = await rebuildNetworkCommercialProjection(
|
|
979
1060
|
db,
|
|
980
1061
|
sources,
|
|
981
1062
|
`network-commercial-project:${fact.eventId}:${fact.digest}`,
|
|
982
|
-
now
|
|
1063
|
+
now,
|
|
1064
|
+
alerts
|
|
983
1065
|
);
|
|
984
1066
|
return { status: "stored", projection };
|
|
985
1067
|
}
|
|
@@ -994,10 +1076,7 @@ async function readStripeRecoveryEvent(db, eventId) {
|
|
|
994
1076
|
const rawBody = JSON.stringify(response.body);
|
|
995
1077
|
return { event: response.body, rawBody, digest: await stripeEventDigest(rawBody) };
|
|
996
1078
|
}
|
|
997
|
-
|
|
998
|
-
return item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).filter(([key3]) => key3 !== "digest").sort(([a], [b]) => a.localeCompare(b))) : item;
|
|
999
|
-
});
|
|
1000
|
-
async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now()) {
|
|
1079
|
+
async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now(), alerts) {
|
|
1001
1080
|
const eventRowId2 = `network-commercial-event:${eventId}`, exceptionId2 = `network-commercial-exception:${eventId}:collision`;
|
|
1002
1081
|
const rows5 = await db.query({
|
|
1003
1082
|
networkCommercialEvents: { $: { where: { id: eventRowId2 }, limit: 1 } },
|
|
@@ -1007,9 +1086,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
|
|
|
1007
1086
|
if (!stored) throw new Error("stored event not found");
|
|
1008
1087
|
const provider = await readStripeRecoveryEvent(db, eventId);
|
|
1009
1088
|
const fact = normalizeNetworkCommercialStripeEvent(provider.event, provider.digest);
|
|
1010
|
-
|
|
1011
|
-
if (!stored.fact?.seatSubscriptionItems) delete comparable.seatSubscriptionItems;
|
|
1012
|
-
if (stable(comparable) !== stable(stored.fact)) throw new Error("Stripe facts differ; collision requires investigation");
|
|
1089
|
+
if (!sameNetworkCommercialFact(stored.fact, fact)) throw new Error("Stripe facts differ; collision requires investigation");
|
|
1013
1090
|
const id6 = crypto.randomUUID();
|
|
1014
1091
|
await db.transact([
|
|
1015
1092
|
{ t: "update", ns: "paymentRecoveryReviews", id: id6, attrs: {
|
|
@@ -1017,6 +1094,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
|
|
|
1017
1094
|
eventId,
|
|
1018
1095
|
actorUserId,
|
|
1019
1096
|
reviewedAt: now,
|
|
1097
|
+
action: "recover",
|
|
1020
1098
|
previousDigest: stored.digest,
|
|
1021
1099
|
providerDigest: provider.digest,
|
|
1022
1100
|
scope: "network",
|
|
@@ -1027,7 +1105,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
|
|
|
1027
1105
|
t: "update",
|
|
1028
1106
|
ns: "networkCommercialExceptions",
|
|
1029
1107
|
id: exceptionId2,
|
|
1030
|
-
attrs: { status: "resolved", resolvedDigest: provider.digest, updatedAt: now }
|
|
1108
|
+
attrs: { status: "resolved", resolvedDigest: provider.digest, resolvedBy: actorUserId, reviewId: id6, updatedAt: now }
|
|
1031
1109
|
}] : []
|
|
1032
1110
|
], { mutationId: `payment-recovery:${id6}`, guards: [
|
|
1033
1111
|
{ ns: "networkCommercialEvents", id: eventRowId2, exists: true, equals: { digest: stored.digest } },
|
|
@@ -1038,7 +1116,7 @@ async function recoverNetworkCommercialEvent(db, sources, eventId, actorUserId,
|
|
|
1038
1116
|
...exception ? { equals: { status: exception.status, updatedAt: exception.updatedAt } } : {}
|
|
1039
1117
|
}
|
|
1040
1118
|
] });
|
|
1041
|
-
await rebuildNetworkCommercialProjection(db, sources, `payment-recovery-rebuild:${id6}`, now);
|
|
1119
|
+
await rebuildNetworkCommercialProjection(db, sources, `payment-recovery-rebuild:${id6}`, now, alerts);
|
|
1042
1120
|
return { recovered: true, eventId, reviewId: id6 };
|
|
1043
1121
|
}
|
|
1044
1122
|
|
|
@@ -1200,12 +1278,12 @@ function applicationCrmFields(crm, app) {
|
|
|
1200
1278
|
}
|
|
1201
1279
|
|
|
1202
1280
|
// src/applicant-projection.ts
|
|
1203
|
-
async function upsertPerson(
|
|
1281
|
+
async function upsertPerson(deps, opts) {
|
|
1204
1282
|
const email = opts.email.toLowerCase();
|
|
1205
|
-
const crmDeps3 = { crm:
|
|
1206
|
-
const { record: record4, created } = await ensureProjectedPerson(
|
|
1283
|
+
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1284
|
+
const { record: record4, created } = await ensureProjectedPerson(deps, { ...opts.input, email });
|
|
1207
1285
|
if (!created) await updateRecord(crmDeps3, { id: String(record4.id), input: opts.input, mutationId: opts.mutationId });
|
|
1208
|
-
const initial =
|
|
1286
|
+
const initial = deps.crm.initialStage("person");
|
|
1209
1287
|
if (!created && record4.stage == null && initial) {
|
|
1210
1288
|
await setStage(crmDeps3, {
|
|
1211
1289
|
id: String(record4.id),
|
|
@@ -1217,7 +1295,7 @@ async function upsertPerson(deps2, opts) {
|
|
|
1217
1295
|
}
|
|
1218
1296
|
return { recordId: String(record4.id) };
|
|
1219
1297
|
}
|
|
1220
|
-
async function projectApplicant(
|
|
1298
|
+
async function projectApplicant(deps, applicant) {
|
|
1221
1299
|
const base = sharedPersonInput({
|
|
1222
1300
|
email: applicant.email,
|
|
1223
1301
|
firstName: applicant.firstName,
|
|
@@ -1226,18 +1304,18 @@ async function projectApplicant(deps2, applicant) {
|
|
|
1226
1304
|
linkedin: applicant.linkedin,
|
|
1227
1305
|
hubRecordId: applicant.applicationId
|
|
1228
1306
|
});
|
|
1229
|
-
Object.assign(base, applicationCrmFields(
|
|
1307
|
+
Object.assign(base, applicationCrmFields(deps.crm, applicant));
|
|
1230
1308
|
const mutationId = `apply:${applicant.applicationId}`;
|
|
1231
1309
|
const extra = applicant.extra ?? {};
|
|
1232
|
-
if (Object.keys(extra).length === 0) return upsertPerson(
|
|
1310
|
+
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
1233
1311
|
try {
|
|
1234
|
-
return await upsertPerson(
|
|
1312
|
+
return await upsertPerson(deps, { email: applicant.email, input: {
|
|
1235
1313
|
...base,
|
|
1236
1314
|
...extra,
|
|
1237
|
-
...applicationCrmFields(
|
|
1315
|
+
...applicationCrmFields(deps.crm, applicant)
|
|
1238
1316
|
}, mutationId });
|
|
1239
1317
|
} catch {
|
|
1240
|
-
return upsertPerson(
|
|
1318
|
+
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
1241
1319
|
}
|
|
1242
1320
|
}
|
|
1243
1321
|
|
|
@@ -1335,24 +1413,24 @@ function sharedRecordFromCrm(crm, record4, target, sourceId) {
|
|
|
1335
1413
|
if (input[nameField] === void 0) input[nameField] = record4.name;
|
|
1336
1414
|
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record4.id }, type: record4.type, input } : { version: 1, type: record4.type, hubRecordId: record4.id, input };
|
|
1337
1415
|
}
|
|
1338
|
-
async function findSharedRecord(
|
|
1416
|
+
async function findSharedRecord(deps, record4, tag) {
|
|
1339
1417
|
const structured = await getRecordByOrigin(
|
|
1340
|
-
{ crm:
|
|
1418
|
+
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
1341
1419
|
record4.sourceId,
|
|
1342
1420
|
record4.sourceRecordId
|
|
1343
1421
|
);
|
|
1344
1422
|
if (structured) return structured;
|
|
1345
|
-
const mapped = await
|
|
1423
|
+
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
1346
1424
|
const mappedId = mapped.crm_tag?.[0]?.recordId;
|
|
1347
1425
|
if (typeof mappedId === "string") {
|
|
1348
|
-
const found = await
|
|
1426
|
+
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
1349
1427
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1350
1428
|
}
|
|
1351
|
-
const def =
|
|
1429
|
+
const def = deps.crm.type(record4.type);
|
|
1352
1430
|
const emailField = def.emailField;
|
|
1353
1431
|
if (emailField && typeof record4.input[emailField] === "string") {
|
|
1354
1432
|
const primaryEmail = record4.input[emailField].toLowerCase();
|
|
1355
|
-
const found = await
|
|
1433
|
+
const found = await deps.db.query({
|
|
1356
1434
|
crm_record: { $: { where: { type: record4.type, primaryEmail }, limit: 1 } }
|
|
1357
1435
|
});
|
|
1358
1436
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
@@ -1360,7 +1438,7 @@ async function findSharedRecord(deps2, record4, tag) {
|
|
|
1360
1438
|
const domain = record4.input.domain;
|
|
1361
1439
|
const domainSlot = def.fields.domain?.slot;
|
|
1362
1440
|
if (typeof domain === "string" && domainSlot) {
|
|
1363
|
-
const found = await
|
|
1441
|
+
const found = await deps.db.query({
|
|
1364
1442
|
crm_record: { $: { where: { type: record4.type, [domainSlot]: domain }, limit: 1 } }
|
|
1365
1443
|
});
|
|
1366
1444
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
@@ -1368,14 +1446,14 @@ async function findSharedRecord(deps2, record4, tag) {
|
|
|
1368
1446
|
const nameField = def.nameField ?? "name";
|
|
1369
1447
|
const name = record4.input[nameField];
|
|
1370
1448
|
if (record4.type === "company" && typeof name === "string" && name.trim()) {
|
|
1371
|
-
const found = await
|
|
1449
|
+
const found = await deps.db.query({
|
|
1372
1450
|
crm_record: { $: { where: { type: record4.type, name: name.trim() }, limit: 1 } }
|
|
1373
1451
|
});
|
|
1374
1452
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1375
1453
|
}
|
|
1376
1454
|
return void 0;
|
|
1377
1455
|
}
|
|
1378
|
-
async function projectSharedRecord(
|
|
1456
|
+
async function projectSharedRecord(deps, shared, options = {}) {
|
|
1379
1457
|
const record4 = normalizeSharedRecord(shared, options.sourceId);
|
|
1380
1458
|
if (!record4.type.trim() || !record4.sourceId.trim() || !record4.sourceRecordId.trim()) {
|
|
1381
1459
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
@@ -1384,14 +1462,14 @@ async function projectSharedRecord(deps2, shared, options = {}) {
|
|
|
1384
1462
|
throw new Error("signed sender does not match payload source");
|
|
1385
1463
|
}
|
|
1386
1464
|
const tag = networkSourceTag(record4.type, record4.sourceRecordId, record4.version === 2 ? record4.sourceId : void 0);
|
|
1387
|
-
const crmDeps3 = { crm:
|
|
1388
|
-
const existing = await findSharedRecord(
|
|
1465
|
+
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1466
|
+
const existing = await findSharedRecord(deps, record4, tag);
|
|
1389
1467
|
let recordId;
|
|
1390
1468
|
if (existing && typeof existing.id === "string") {
|
|
1391
1469
|
await updateRecord2(crmDeps3, { id: existing.id, input: record4.input });
|
|
1392
1470
|
recordId = existing.id;
|
|
1393
1471
|
} else if (record4.type === "person" && typeof record4.input.email === "string") {
|
|
1394
|
-
const projected = await ensureProjectedPerson(
|
|
1472
|
+
const projected = await ensureProjectedPerson(deps, record4.input);
|
|
1395
1473
|
recordId = String(projected.record.id);
|
|
1396
1474
|
if (!projected.created) await updateRecord2(crmDeps3, { id: recordId, input: record4.input });
|
|
1397
1475
|
} else {
|
|
@@ -1402,8 +1480,8 @@ async function projectSharedRecord(deps2, shared, options = {}) {
|
|
|
1402
1480
|
mutationId: `share-create:${tag}`
|
|
1403
1481
|
});
|
|
1404
1482
|
}
|
|
1405
|
-
await
|
|
1406
|
-
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt:
|
|
1483
|
+
await deps.db.transact(
|
|
1484
|
+
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
|
|
1407
1485
|
{ mutationId: `share-map:${tag}:${recordId}` }
|
|
1408
1486
|
);
|
|
1409
1487
|
await upsertRecordOrigin(crmDeps3, {
|
|
@@ -1451,21 +1529,21 @@ function billingColumns(app) {
|
|
|
1451
1529
|
if (billingStatus !== "canceled" && billingStatus !== "refunded" && Number.isFinite(renewal) && renewal > 0) cols.renewalAt = renewal;
|
|
1452
1530
|
return cols;
|
|
1453
1531
|
}
|
|
1454
|
-
async function syncApplicationToCrm(
|
|
1532
|
+
async function syncApplicationToCrm(deps, opts) {
|
|
1455
1533
|
if (!opts.app.id) throw new Error("crm_application_id_required");
|
|
1456
1534
|
const emailKey = str(opts.app.email).toLowerCase();
|
|
1457
1535
|
if (!emailKey) return null;
|
|
1458
|
-
const recordDeps = { crm:
|
|
1459
|
-
const input = personInputFromApp(
|
|
1536
|
+
const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1537
|
+
const input = personInputFromApp(deps.chapter, opts.app);
|
|
1460
1538
|
const stage = opts.stage || void 0;
|
|
1461
|
-
const { record: existing, created } = await ensureProjectedPerson(
|
|
1539
|
+
const { record: existing, created } = await ensureProjectedPerson(deps, input, { stage });
|
|
1462
1540
|
const recordId = String(existing.id);
|
|
1463
1541
|
if (!created) await updateRecord3(recordDeps, { id: recordId, input });
|
|
1464
1542
|
if (!created && stage && existing.stage !== stage) {
|
|
1465
1543
|
await setStage2(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${existing.updatedAt}:${stage}` });
|
|
1466
1544
|
}
|
|
1467
1545
|
const billing = billingColumns(opts.app);
|
|
1468
|
-
await
|
|
1546
|
+
await deps.db.transact([
|
|
1469
1547
|
{ t: "update", ns: "crm_record", id: recordId, attrs: billing },
|
|
1470
1548
|
// Optional typed dates are removed with a retract, never an invalid null.
|
|
1471
1549
|
...!("renewalAt" in billing) ? [{ t: "retract", ns: "crm_record", id: recordId, attrs: ["renewalAt"] }] : []
|
|
@@ -1473,8 +1551,8 @@ async function syncApplicationToCrm(deps2, opts) {
|
|
|
1473
1551
|
await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
|
|
1474
1552
|
return recordId;
|
|
1475
1553
|
}
|
|
1476
|
-
async function backfillCrm(
|
|
1477
|
-
const data = await
|
|
1554
|
+
async function backfillCrm(deps) {
|
|
1555
|
+
const data = await deps.db.query({
|
|
1478
1556
|
applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } }
|
|
1479
1557
|
});
|
|
1480
1558
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -1485,7 +1563,7 @@ async function backfillCrm(deps2) {
|
|
|
1485
1563
|
if (!key3 || seen.has(key3)) return;
|
|
1486
1564
|
seen.add(key3);
|
|
1487
1565
|
try {
|
|
1488
|
-
await syncApplicationToCrm(
|
|
1566
|
+
await syncApplicationToCrm(deps, { app, stage });
|
|
1489
1567
|
synced += 1;
|
|
1490
1568
|
} catch (err) {
|
|
1491
1569
|
errors.push({ email: key3, error: err instanceof Error ? err.message : String(err) });
|
|
@@ -1493,10 +1571,10 @@ async function backfillCrm(deps2) {
|
|
|
1493
1571
|
};
|
|
1494
1572
|
for (const a of data.applications ?? []) await run(a, str(a.status));
|
|
1495
1573
|
for (let offset = 0; ; offset += 200) {
|
|
1496
|
-
const users = (await
|
|
1574
|
+
const users = (await deps.db.query({ $users: { $: { order: { id: "asc" }, limit: 200, offset } } })).$users ?? [];
|
|
1497
1575
|
for (const u of users) {
|
|
1498
1576
|
try {
|
|
1499
|
-
if (await syncAccountToCrm(
|
|
1577
|
+
if (await syncAccountToCrm(deps, String(u.id))) synced += 1;
|
|
1500
1578
|
} catch (err) {
|
|
1501
1579
|
errors.push({ email: str(u.email), error: err instanceof Error ? err.message : String(err) });
|
|
1502
1580
|
}
|
|
@@ -1536,26 +1614,32 @@ function planDelivery(input) {
|
|
|
1536
1614
|
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
1537
1615
|
const to = redirect ? input.group.debugEmail : input.to;
|
|
1538
1616
|
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
1539
|
-
const
|
|
1617
|
+
const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
1540
1618
|
|
|
1541
1619
|
` + render(tpl.text, vars) : render(tpl.text, vars);
|
|
1542
|
-
return { deliver: true, transport, to, subject, text:
|
|
1620
|
+
return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
|
|
1543
1621
|
}
|
|
1544
1622
|
|
|
1545
1623
|
// src/notify.ts
|
|
1546
|
-
var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
|
|
1547
|
-
|
|
1548
|
-
const
|
|
1624
|
+
var EMAIL_TEMPLATE_NAMES = ["adminNotification", "submitConfirmation", "paymentConfirmation", "prepEmail", "onboardingInvite"];
|
|
1625
|
+
function fromAddress(configured, name) {
|
|
1626
|
+
const display = name?.trim();
|
|
1627
|
+
if (!display) return configured;
|
|
1628
|
+
const angled = configured.match(/<([^>]+)>/);
|
|
1629
|
+
return { name: display, email: (angled ? angled[1] : configured).trim() };
|
|
1630
|
+
}
|
|
1631
|
+
async function sendTemplated(deps, input) {
|
|
1632
|
+
const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
|
|
1549
1633
|
const prior = Array.isArray(emailLog) ? emailLog : [];
|
|
1550
1634
|
if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
|
|
1551
1635
|
if (prior.length > 1 || prior.length === 1 && prior[0].deliveryState !== "retry_ready") {
|
|
1552
1636
|
return { sent: false, reason: "delivery-uncertain" };
|
|
1553
1637
|
}
|
|
1554
|
-
const cloudflareReady = Boolean(
|
|
1555
|
-
const override =
|
|
1556
|
-
const group =
|
|
1638
|
+
const cloudflareReady = Boolean(deps.sender && deps.from);
|
|
1639
|
+
const override = deps.debugEmail?.trim();
|
|
1640
|
+
const group = deps.envName !== "prod" && deps.debugEmail !== void 0 ? { ...input.group, debugEmail: override && /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/.test(override) ? override : void 0 } : input.group;
|
|
1557
1641
|
const decision = planDelivery({
|
|
1558
|
-
envName:
|
|
1642
|
+
envName: deps.envName,
|
|
1559
1643
|
group,
|
|
1560
1644
|
template: input.template,
|
|
1561
1645
|
to: input.to,
|
|
@@ -1580,14 +1664,14 @@ async function sendTemplated(deps2, input) {
|
|
|
1580
1664
|
transport: decision.transport,
|
|
1581
1665
|
redirected: decision.redirected,
|
|
1582
1666
|
dedupeKey: input.dedupeKey,
|
|
1583
|
-
sentAt:
|
|
1667
|
+
sentAt: deps.now(),
|
|
1584
1668
|
deliveryState: decision.transport === "log-only" ? "sent" : "sending",
|
|
1585
1669
|
deliveryAttempt: attempt,
|
|
1586
1670
|
error: decision.transport === "log-only" ? "" : "delivery-pending",
|
|
1587
1671
|
...input.applicationId ? { applicationId: input.applicationId } : {}
|
|
1588
1672
|
};
|
|
1589
1673
|
try {
|
|
1590
|
-
const claim = await
|
|
1674
|
+
const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: row2 }], {
|
|
1591
1675
|
mutationId: `email-claim:${id6}:${attempt}`,
|
|
1592
1676
|
guards: [previous ? {
|
|
1593
1677
|
ns: "emailLog",
|
|
@@ -1605,8 +1689,8 @@ async function sendTemplated(deps2, input) {
|
|
|
1605
1689
|
if (decision.transport === "log-only") return { sent: true };
|
|
1606
1690
|
let messageId;
|
|
1607
1691
|
try {
|
|
1608
|
-
const response = await
|
|
1609
|
-
from:
|
|
1692
|
+
const response = await deps.sender.send({
|
|
1693
|
+
from: fromAddress(deps.from, input.group.fromName),
|
|
1610
1694
|
to: [decision.to],
|
|
1611
1695
|
subject: String(row2.subject),
|
|
1612
1696
|
text: String(row2.body),
|
|
@@ -1615,12 +1699,12 @@ async function sendTemplated(deps2, input) {
|
|
|
1615
1699
|
if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
|
|
1616
1700
|
messageId = response.messageId;
|
|
1617
1701
|
} catch {
|
|
1618
|
-
await
|
|
1702
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "uncertain", error: "delivery-uncertain" } }], {
|
|
1619
1703
|
guards: [{ ns: "emailLog", id: id6, exists: true, equals: { deliveryState: "sending", deliveryAttempt: attempt } }]
|
|
1620
1704
|
});
|
|
1621
1705
|
return { sent: false, reason: "delivery-uncertain" };
|
|
1622
1706
|
}
|
|
1623
|
-
await
|
|
1707
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id: id6, attrs: { deliveryState: "sent", error: "", messageId, sentAt: deps.now() } }], {
|
|
1624
1708
|
mutationId: `email-confirmed:${id6}:${attempt}`,
|
|
1625
1709
|
guards: [{
|
|
1626
1710
|
ns: "emailLog",
|
|
@@ -1639,6 +1723,7 @@ function emailGroupFrom(row2) {
|
|
|
1639
1723
|
name: String(row2.name ?? ""),
|
|
1640
1724
|
replyTo: str3(row2.replyTo) ?? "",
|
|
1641
1725
|
debugEmail: str3(row2.debugEmail),
|
|
1726
|
+
fromName: str3(row2.fromName),
|
|
1642
1727
|
refundPolicyText: str3(row2.refundPolicyText),
|
|
1643
1728
|
commitmentText: str3(row2.commitmentText),
|
|
1644
1729
|
normsText: str3(row2.normsText),
|
|
@@ -2769,24 +2854,22 @@ async function revokeRefundedApplicationRole(db, env, ctx, applicationId) {
|
|
|
2769
2854
|
}
|
|
2770
2855
|
}
|
|
2771
2856
|
|
|
2772
|
-
// src/
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
});
|
|
2786
|
-
async function paymentConfirmationVariables(db, app) {
|
|
2857
|
+
// src/notification-variables.ts
|
|
2858
|
+
function lifecycleNotifyDeps(db, env) {
|
|
2859
|
+
return {
|
|
2860
|
+
db,
|
|
2861
|
+
envName: env.ODLA_ENV,
|
|
2862
|
+
sender: env.SEND_EMAIL,
|
|
2863
|
+
from: env.EMAIL_FROM,
|
|
2864
|
+
debugEmail: env.ODLA_DEBUG_EMAIL,
|
|
2865
|
+
now: () => Date.now(),
|
|
2866
|
+
newId: () => crypto.randomUUID()
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
async function frozenCheckoutVariables(db, app) {
|
|
2787
2870
|
const intent = (await db.query({ subscriptionCheckoutIntents: { $: { where: { id: String(app.id) }, limit: 1 } } })).subscriptionCheckoutIntents?.[0];
|
|
2788
2871
|
const offer = intent?.membershipOffer, contract = offer?.contract;
|
|
2789
|
-
const offering = contract?.offering;
|
|
2872
|
+
const offering = contract?.offering, quote = intent?.paymentQuote;
|
|
2790
2873
|
const amount = contract?.dueTodayCents ?? intent?.priceCents;
|
|
2791
2874
|
const currency = offering?.currency ?? intent?.currency;
|
|
2792
2875
|
let formatted = "";
|
|
@@ -2794,9 +2877,75 @@ async function paymentConfirmationVariables(db, app) {
|
|
|
2794
2877
|
formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
|
|
2795
2878
|
}
|
|
2796
2879
|
return {
|
|
2797
|
-
firstName: typeof app.firstName === "string" ? app.firstName : "",
|
|
2798
2880
|
paymentAmount: formatted,
|
|
2799
2881
|
paymentAmountSummary: formatted ? ` of ${formatted}` : "",
|
|
2882
|
+
tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2885
|
+
async function catalogTierName(db, group, app) {
|
|
2886
|
+
try {
|
|
2887
|
+
return findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null)?.name ?? "";
|
|
2888
|
+
} catch {
|
|
2889
|
+
return "";
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
async function checkoutVariables(db, group, app) {
|
|
2893
|
+
const frozen = await frozenCheckoutVariables(db, app);
|
|
2894
|
+
return frozen.tierName ? frozen : { ...frozen, tierName: await catalogTierName(db, group, app) };
|
|
2895
|
+
}
|
|
2896
|
+
function formatInstant(at, timeZone) {
|
|
2897
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
2898
|
+
weekday: "long",
|
|
2899
|
+
month: "long",
|
|
2900
|
+
day: "numeric",
|
|
2901
|
+
year: "numeric",
|
|
2902
|
+
hour: "numeric",
|
|
2903
|
+
minute: "2-digit",
|
|
2904
|
+
timeZoneName: "short",
|
|
2905
|
+
timeZone
|
|
2906
|
+
}).format(new Date(at));
|
|
2907
|
+
}
|
|
2908
|
+
var adminUrlFor = (origin) => `${origin}/admin/`;
|
|
2909
|
+
var membersUrlFor = (origin) => `${origin}/members/`;
|
|
2910
|
+
function applicantVariables(app) {
|
|
2911
|
+
const text8 = (v) => typeof v === "string" ? v : "";
|
|
2912
|
+
return { firstName: text8(app.firstName), lastName: text8(app.lastName), email: text8(app.email), phone: text8(app.phone), state: text8(app.state) };
|
|
2913
|
+
}
|
|
2914
|
+
function sampleVariables(origin, timeZone, now = Date.now()) {
|
|
2915
|
+
const startAt = formatInstant(now + 864e5, timeZone);
|
|
2916
|
+
return {
|
|
2917
|
+
firstName: "Sample",
|
|
2918
|
+
lastName: "Person",
|
|
2919
|
+
email: "sample@example.com",
|
|
2920
|
+
phone: "(555) 010-0100",
|
|
2921
|
+
state: "CA",
|
|
2922
|
+
tier: "Membership",
|
|
2923
|
+
tierName: "Membership",
|
|
2924
|
+
paymentAmount: "$100.00",
|
|
2925
|
+
paymentAmountSummary: " of $100.00",
|
|
2926
|
+
paymentNextStep: "Next, schedule your introduction call.",
|
|
2927
|
+
submitNextStep: "Next, schedule your introduction call.",
|
|
2928
|
+
startAt,
|
|
2929
|
+
endAt: formatInstant(now + 864e5 + 45 * 6e4, timeZone),
|
|
2930
|
+
timezone: timeZone,
|
|
2931
|
+
meetUrl: "https://meet.google.com/sample",
|
|
2932
|
+
htmlLink: "https://calendar.google.com/event?eid=sample",
|
|
2933
|
+
meetingTime: startAt,
|
|
2934
|
+
meetingLink: "https://meet.google.com/sample",
|
|
2935
|
+
adminUrl: adminUrlFor(origin),
|
|
2936
|
+
membersUrl: membersUrlFor(origin)
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// src/payment-notifications.ts
|
|
2941
|
+
var groupFor = async (db, app) => {
|
|
2942
|
+
const result = await db.query({ groups: { $: { where: { id: String(app.groupId ?? "") }, limit: 1 } } });
|
|
2943
|
+
return result.groups?.[0];
|
|
2944
|
+
};
|
|
2945
|
+
async function paymentConfirmationVariables(db, app) {
|
|
2946
|
+
return {
|
|
2947
|
+
firstName: typeof app.firstName === "string" ? app.firstName : "",
|
|
2948
|
+
...await frozenCheckoutVariables(db, app),
|
|
2800
2949
|
paymentNextStep: app.status === "approved" ? "Your membership is approved." : typeof app.interviewWaivedAt === "number" ? "Your interview is waived. We are completing your membership approval." : "Next, schedule your introduction call."
|
|
2801
2950
|
};
|
|
2802
2951
|
}
|
|
@@ -2804,7 +2953,7 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
|
|
|
2804
2953
|
if (typeof app.email !== "string" || !app.email) throw new Error("payment notice recipient missing");
|
|
2805
2954
|
const group = await groupFor(db, app);
|
|
2806
2955
|
if (!group) throw new Error("payment notice configuration missing");
|
|
2807
|
-
const result = await sendTemplated(
|
|
2956
|
+
const result = await sendTemplated(lifecycleNotifyDeps(db, env), {
|
|
2808
2957
|
group: emailGroupFrom(group),
|
|
2809
2958
|
template: "paymentConfirmation",
|
|
2810
2959
|
to: app.email,
|
|
@@ -2814,21 +2963,20 @@ async function notifyPaymentConfirmed(db, env, eventId, app) {
|
|
|
2814
2963
|
});
|
|
2815
2964
|
if (!result.sent && !["disabled", "template-missing"].includes(result.reason ?? "")) throw new Error("payment notice reconciliation pending");
|
|
2816
2965
|
}
|
|
2817
|
-
async function notifyAdminOfPayment(db, env, eventId, app) {
|
|
2966
|
+
async function notifyAdminOfPayment(db, env, eventId, app, origin = "") {
|
|
2818
2967
|
try {
|
|
2819
2968
|
const group = await groupFor(db, app);
|
|
2820
2969
|
if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
|
|
2821
|
-
const
|
|
2822
|
-
await sendTemplated(
|
|
2970
|
+
const { tierName: tierName2 } = await frozenCheckoutVariables(db, app);
|
|
2971
|
+
await sendTemplated(lifecycleNotifyDeps(db, env), {
|
|
2823
2972
|
group: emailGroupFrom(group),
|
|
2824
2973
|
template: "adminNotification",
|
|
2825
2974
|
to: group.notificationEmail,
|
|
2826
2975
|
vars: {
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
state: text6(app.state)
|
|
2976
|
+
...applicantVariables(app),
|
|
2977
|
+
tier: tierName2,
|
|
2978
|
+
tierName: tierName2,
|
|
2979
|
+
...origin ? { adminUrl: adminUrlFor(origin), membersUrl: membersUrlFor(origin) } : {}
|
|
2832
2980
|
},
|
|
2833
2981
|
dedupeKey: `${eventId}:admin`,
|
|
2834
2982
|
applicationId: String(app.id)
|
|
@@ -3148,6 +3296,26 @@ function reportStripePartition(level, eventId, event, disposition) {
|
|
|
3148
3296
|
...stripeEventIdentity(event)
|
|
3149
3297
|
}));
|
|
3150
3298
|
}
|
|
3299
|
+
var QUARANTINE_DISPOSITIONS = ["quarantine_unmatched", "quarantine_collision"];
|
|
3300
|
+
var QUEUE_LIMIT = 500;
|
|
3301
|
+
var QUEUE_IDS = 50;
|
|
3302
|
+
async function alertStripeQuarantine(db, ctx, eventId, disposition) {
|
|
3303
|
+
const pages = await Promise.all(QUARANTINE_DISPOSITIONS.map(async (value) => (await db.query({ stripeEventReceipts: { $: { where: { disposition: value }, limit: QUEUE_LIMIT } } })).stripeEventReceipts ?? []));
|
|
3304
|
+
const queued = pages.flat();
|
|
3305
|
+
reportChapterAlert(ctx, new Error("stripe webhook quarantined"), {
|
|
3306
|
+
code: "stripe_webhook_quarantined",
|
|
3307
|
+
attributes: {
|
|
3308
|
+
"odla.stripe.disposition": disposition,
|
|
3309
|
+
"odla.stripe.quarantined_count": queued.length,
|
|
3310
|
+
"odla.stripe.queue_truncated": pages.some((page) => page.length >= QUEUE_LIMIT)
|
|
3311
|
+
},
|
|
3312
|
+
artifacts: { eventId, eventIds: queued.slice(0, QUEUE_IDS).map((row2) => String(row2.eventId)) }
|
|
3313
|
+
});
|
|
3314
|
+
}
|
|
3315
|
+
async function reportStripeCollision(db, ctx, eventId, event) {
|
|
3316
|
+
reportStripePartition("error", eventId, event, "quarantine_collision");
|
|
3317
|
+
await alertStripeQuarantine(db, ctx, eventId, "quarantine_collision");
|
|
3318
|
+
}
|
|
3151
3319
|
async function storeStripeReceipt(db, input) {
|
|
3152
3320
|
const existing = await receiptFor(db, input.eventId);
|
|
3153
3321
|
if (existing?.retryReady === true && existing.reviewedDigest === input.digest) {
|
|
@@ -3180,17 +3348,18 @@ async function storeStripeReceipt(db, input) {
|
|
|
3180
3348
|
const raced = await receiptFor(db, input.eventId);
|
|
3181
3349
|
return raced?.digest === input.digest || raced?.digest === input.legacyDigest ? "replayed" : "collision";
|
|
3182
3350
|
}
|
|
3183
|
-
async function quarantineStripeEvent(db, eventId, digest, event) {
|
|
3351
|
+
async function quarantineStripeEvent(db, eventId, digest, event, ctx) {
|
|
3184
3352
|
const disposition = "quarantine_unmatched";
|
|
3185
3353
|
const stored = await storeStripeReceipt(db, { eventId, digest, event, disposition, matched: false });
|
|
3186
3354
|
if (stored === "collision") {
|
|
3187
|
-
|
|
3355
|
+
await reportStripeCollision(db, ctx, eventId, event);
|
|
3188
3356
|
return json({ ok: true, matched: false, collision: true });
|
|
3189
3357
|
}
|
|
3190
3358
|
reportStripePartition("warn", eventId, event, disposition);
|
|
3359
|
+
if (stored === "stored") await alertStripeQuarantine(db, ctx, eventId, disposition);
|
|
3191
3360
|
return json({ ok: true, matched: false, disposition, ...stored === "replayed" ? { replayed: true } : {} });
|
|
3192
3361
|
}
|
|
3193
|
-
async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest) {
|
|
3362
|
+
async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx) {
|
|
3194
3363
|
const existing = await receiptFor(db, eventId);
|
|
3195
3364
|
if (!existing) return null;
|
|
3196
3365
|
if (existing.retryReady === true && existing.reviewedDigest === digest) return null;
|
|
@@ -3200,7 +3369,7 @@ async function replayedStripeReceipt(db, eventId, digest, event, legacyDigest) {
|
|
|
3200
3369
|
collisionDigest: digest,
|
|
3201
3370
|
retryReady: false
|
|
3202
3371
|
} }], { guards: [{ ns: "stripeEventReceipts", id: String(existing.id), exists: true, equals: { digest: existing.digest } }] });
|
|
3203
|
-
|
|
3372
|
+
await reportStripeCollision(db, ctx, eventId, event);
|
|
3204
3373
|
return json({ ok: true, matched: false, collision: true });
|
|
3205
3374
|
}
|
|
3206
3375
|
return json({ ok: true, matched: existing.matched === true, replayed: true });
|
|
@@ -3231,16 +3400,16 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3231
3400
|
reportStripePartition("warn", eventId, event, "ignore_sibling");
|
|
3232
3401
|
return json({ ok: true, matched: false, disposition: "ignore_sibling" });
|
|
3233
3402
|
}
|
|
3234
|
-
const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest);
|
|
3403
|
+
const replay2 = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
|
|
3235
3404
|
if (replay2) return replay2;
|
|
3236
3405
|
const localIntent = Boolean(intent && intent.chapterId === ctx.chapter.id && intent.environment === env.ODLA_ENV && (typeof intent.runtime !== "string" || intent.runtime === target.runtime));
|
|
3237
3406
|
const disposition2 = classifyMemberStripeEvent(identity, target, localIntent);
|
|
3238
3407
|
if (!localIntent || disposition2 === "quarantine_unmatched") {
|
|
3239
|
-
return quarantineStripeEvent(db, eventId, digest, event);
|
|
3408
|
+
return quarantineStripeEvent(db, eventId, digest, event, ctx);
|
|
3240
3409
|
}
|
|
3241
3410
|
if (event.kind === "gift_paid") {
|
|
3242
3411
|
if (intent.providerObjectId !== event.providerObjectId || intent.status === "refunded" || intent.status === "canceled") {
|
|
3243
|
-
return quarantineStripeEvent(db, eventId, digest, event);
|
|
3412
|
+
return quarantineStripeEvent(db, eventId, digest, event, ctx);
|
|
3244
3413
|
}
|
|
3245
3414
|
try {
|
|
3246
3415
|
const issued = await issueGiftClaim(db, intent, {
|
|
@@ -3249,7 +3418,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3249
3418
|
}, Date.now());
|
|
3250
3419
|
const stored3 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: true });
|
|
3251
3420
|
if (stored3 === "collision") {
|
|
3252
|
-
|
|
3421
|
+
await reportStripeCollision(db, ctx, eventId, event);
|
|
3253
3422
|
return json({ ok: true, matched: false, collision: true });
|
|
3254
3423
|
}
|
|
3255
3424
|
return json({ ok: true, matched: true, claimId: issued.claimId });
|
|
@@ -3265,7 +3434,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3265
3434
|
if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
|
|
3266
3435
|
const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition: disposition2, matched: Boolean(claim) });
|
|
3267
3436
|
if (stored2 === "collision") {
|
|
3268
|
-
|
|
3437
|
+
await reportStripeCollision(db, ctx, eventId, event);
|
|
3269
3438
|
return json({ ok: true, matched: false, collision: true });
|
|
3270
3439
|
}
|
|
3271
3440
|
return json({ ok: true, matched: Boolean(claim) });
|
|
@@ -3275,12 +3444,12 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3275
3444
|
reportStripePartition("warn", eventId, event, "ignore_sibling");
|
|
3276
3445
|
return json({ ok: true, matched: false, disposition: "ignore_sibling" });
|
|
3277
3446
|
}
|
|
3278
|
-
const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest);
|
|
3447
|
+
const replay = await replayedStripeReceipt(db, eventId, digest, event, legacyDigest, ctx);
|
|
3279
3448
|
if (replay) return replay;
|
|
3280
3449
|
const localApplication = Boolean(app && String(app.groupId ?? ctx.chapter.id) === ctx.chapter.id && (typeof app.stripeRuntime !== "string" || app.stripeRuntime === target.runtime));
|
|
3281
3450
|
const disposition = classifyMemberStripeEvent(identity, target, localApplication);
|
|
3282
3451
|
if (!localApplication || disposition === "quarantine_unmatched") {
|
|
3283
|
-
return quarantineStripeEvent(db, eventId, digest, event);
|
|
3452
|
+
return quarantineStripeEvent(db, eventId, digest, event, ctx);
|
|
3284
3453
|
}
|
|
3285
3454
|
if (await archivedChapterMembershipEvent(db, rawBody, app, env, ctx)) {
|
|
3286
3455
|
await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
|
|
@@ -3297,7 +3466,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3297
3466
|
if (receipt.seatId !== event.seatId || receipt.reconciled !== true) throw new Error("invalid seat receipt");
|
|
3298
3467
|
const stored2 = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
|
|
3299
3468
|
if (stored2 === "collision") {
|
|
3300
|
-
|
|
3469
|
+
await reportStripeCollision(db, ctx, eventId, event);
|
|
3301
3470
|
return json({ ok: true, matched: false, collision: true });
|
|
3302
3471
|
}
|
|
3303
3472
|
return json({ ok: true, seatId: event.seatId });
|
|
@@ -3321,7 +3490,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3321
3490
|
if (intent) {
|
|
3322
3491
|
const evidence = verifyInitialMembershipInvoice(intent, event.invoice);
|
|
3323
3492
|
if (evidence.status === "retry") return json({ error: "checkout reconciliation pending" }, 503);
|
|
3324
|
-
if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event);
|
|
3493
|
+
if (evidence.status === "mismatch") return quarantineStripeEvent(db, eventId, digest, event, ctx);
|
|
3325
3494
|
if (evidence.renewalAt !== void 0) authoritativeEvent = { ...event, renewalAt: evidence.renewalAt };
|
|
3326
3495
|
}
|
|
3327
3496
|
if (app.canceled !== true && !["refunded", "declined", "canceled"].includes(String(app.status))) {
|
|
@@ -3372,7 +3541,7 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3372
3541
|
} catch {
|
|
3373
3542
|
return json({ error: "payment notice reconciliation pending" }, 503);
|
|
3374
3543
|
}
|
|
3375
|
-
if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
|
|
3544
|
+
if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app, input.origin);
|
|
3376
3545
|
if (typeof next.interviewWaivedAt === "number") {
|
|
3377
3546
|
const approval = await approveApplication({
|
|
3378
3547
|
db,
|
|
@@ -3387,15 +3556,132 @@ async function reconcileChapterStripeEvent(input, env, ctx) {
|
|
|
3387
3556
|
}
|
|
3388
3557
|
const stored = await storeStripeReceipt(db, { eventId, digest, legacyDigest, event, disposition, matched: true });
|
|
3389
3558
|
if (stored === "collision") {
|
|
3390
|
-
|
|
3559
|
+
await reportStripeCollision(db, ctx, eventId, event);
|
|
3391
3560
|
return json({ ok: true, matched: false, collision: true });
|
|
3392
3561
|
}
|
|
3393
3562
|
return json({ ok: true });
|
|
3394
3563
|
}
|
|
3395
3564
|
|
|
3565
|
+
// src/network-commercial-resolve.ts
|
|
3566
|
+
var EVENT_ID = /^evt_[A-Za-z0-9_]{1,150}$/;
|
|
3567
|
+
var fail3 = (message, code) => Object.assign(new Error(message), { code });
|
|
3568
|
+
async function resolveNetworkCommercialCollision(db, sources, input, now = Date.now(), alerts) {
|
|
3569
|
+
if (!EVENT_ID.test(input.eventId)) throw fail3("invalid event reference", "invalid_event");
|
|
3570
|
+
const id6 = exceptionId(input.eventId, "collision"), reviewId = `resolve:${input.mutationId}`;
|
|
3571
|
+
const found = await db.query({
|
|
3572
|
+
networkCommercialExceptions: { $: { where: { id: id6 }, limit: 1 } },
|
|
3573
|
+
networkCommercialEvents: { $: { where: { eventId: input.eventId }, limit: 1 } }
|
|
3574
|
+
});
|
|
3575
|
+
const exception = found.networkCommercialExceptions?.[0], stored = found.networkCommercialEvents?.[0];
|
|
3576
|
+
if (exception?.status === "resolved" && exception.reviewId === reviewId) {
|
|
3577
|
+
return { eventId: input.eventId, reviewId, projection: await loadNetworkCommercialProjection(db, sources) };
|
|
3578
|
+
}
|
|
3579
|
+
if (!exception || exception.status !== "active") throw fail3("no active collision for this event", "collision_not_active");
|
|
3580
|
+
if (!stored) throw fail3("stored event not found", "event_not_found");
|
|
3581
|
+
const dismissed = typeof exception.incomingDigest === "string" ? exception.incomingDigest : void 0;
|
|
3582
|
+
const write = await db.transact([
|
|
3583
|
+
{ t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
|
|
3584
|
+
id: reviewId,
|
|
3585
|
+
eventId: input.eventId,
|
|
3586
|
+
actorUserId: input.actorUserId,
|
|
3587
|
+
reviewedAt: now,
|
|
3588
|
+
previousDigest: String(stored.digest),
|
|
3589
|
+
providerDigest: dismissed ?? "",
|
|
3590
|
+
scope: "network",
|
|
3591
|
+
previousState: exception,
|
|
3592
|
+
action: "resolve",
|
|
3593
|
+
reason: input.reason
|
|
3594
|
+
} },
|
|
3595
|
+
{ t: "update", ns: "networkCommercialExceptions", id: id6, attrs: {
|
|
3596
|
+
status: "resolved",
|
|
3597
|
+
resolvedDigest: stored.digest,
|
|
3598
|
+
...dismissed ? { reviewedDigest: dismissed } : {},
|
|
3599
|
+
resolvedBy: input.actorUserId,
|
|
3600
|
+
reviewId,
|
|
3601
|
+
updatedAt: now
|
|
3602
|
+
} }
|
|
3603
|
+
], { mutationId: input.mutationId, guards: [
|
|
3604
|
+
{ ns: "networkCommercialExceptions", id: id6, exists: true, equals: { status: "active", updatedAt: exception.updatedAt } }
|
|
3605
|
+
] });
|
|
3606
|
+
if (write.duplicate) throw fail3("mutationId was already used for another change", "mutation_reused");
|
|
3607
|
+
const projection = await rebuildNetworkCommercialProjection(db, sources, `${input.mutationId}:rebuild`, now, alerts);
|
|
3608
|
+
return { eventId: input.eventId, reviewId, projection };
|
|
3609
|
+
}
|
|
3610
|
+
async function retryNetworkCommercialEvent(db, sources, eventId, actorUserId, now = Date.now(), alerts) {
|
|
3611
|
+
if (!EVENT_ID.test(eventId)) throw fail3("invalid event reference", "invalid_event");
|
|
3612
|
+
const active = (await loadNetworkCommercialProjection(db, sources)).exceptions.filter((item) => item.eventId === eventId);
|
|
3613
|
+
if (active.length === 0) throw fail3("event has no active exception", "exception_not_active");
|
|
3614
|
+
const provider = await readStripeRecoveryEvent(db, eventId);
|
|
3615
|
+
const stored = (await db.query({ networkCommercialEvents: { $: { where: { eventId }, limit: 1 } } })).networkCommercialEvents?.[0];
|
|
3616
|
+
const reviewId = crypto.randomUUID();
|
|
3617
|
+
await db.transact([{ t: "update", ns: "paymentRecoveryReviews", id: reviewId, attrs: {
|
|
3618
|
+
id: reviewId,
|
|
3619
|
+
eventId,
|
|
3620
|
+
actorUserId,
|
|
3621
|
+
reviewedAt: now,
|
|
3622
|
+
previousDigest: String(stored?.digest ?? ""),
|
|
3623
|
+
providerDigest: provider.digest,
|
|
3624
|
+
scope: "network",
|
|
3625
|
+
previousState: { exceptions: active },
|
|
3626
|
+
action: "retry"
|
|
3627
|
+
} }], { mutationId: `payment-recovery:${reviewId}` });
|
|
3628
|
+
const fact = normalizeNetworkCommercialStripeEvent(provider.event, provider.digest);
|
|
3629
|
+
const result = await ingestNetworkCommercialFact(db, sources, fact, now, await stripePayloadDigest(provider.rawBody), alerts);
|
|
3630
|
+
return {
|
|
3631
|
+
eventId,
|
|
3632
|
+
reviewId,
|
|
3633
|
+
status: result.status,
|
|
3634
|
+
resolvedCollision: result.status === "replayed" && result.resolvedCollision === true,
|
|
3635
|
+
exceptions: result.projection.exceptions.length
|
|
3636
|
+
};
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
// src/worker-routes-network-commercial-recovery.ts
|
|
3640
|
+
var PATH = "/api/admin/payment-recovery";
|
|
3641
|
+
var handleNetworkCommercialRecovery = async (req, url, env, ctx) => {
|
|
3642
|
+
const model = ctx.chapter.networkCommercialReadModel;
|
|
3643
|
+
if (url.pathname !== PATH || !model) return null;
|
|
3644
|
+
if (req.method !== "GET" && req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
3645
|
+
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
3646
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3647
|
+
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "forbidden" }, 403);
|
|
3648
|
+
const db = raw2;
|
|
3649
|
+
if (req.method === "GET") {
|
|
3650
|
+
const { exceptions } = await loadNetworkCommercialProjection(db, model.sources);
|
|
3651
|
+
return json({
|
|
3652
|
+
scope: "network",
|
|
3653
|
+
count: exceptions.length,
|
|
3654
|
+
truncated: false,
|
|
3655
|
+
rows: exceptions.map((item) => ({
|
|
3656
|
+
eventId: item.eventId,
|
|
3657
|
+
kind: item.detail.eventType,
|
|
3658
|
+
disposition: item.kind,
|
|
3659
|
+
sourceKey: item.sourceKey ?? null,
|
|
3660
|
+
retryReady: /^evt_/.test(item.eventId)
|
|
3661
|
+
}))
|
|
3662
|
+
});
|
|
3663
|
+
}
|
|
3664
|
+
let eventId;
|
|
3665
|
+
try {
|
|
3666
|
+
eventId = String((await req.json()).eventId ?? "");
|
|
3667
|
+
} catch {
|
|
3668
|
+
return json({ error: "eventId required" }, 400);
|
|
3669
|
+
}
|
|
3670
|
+
try {
|
|
3671
|
+
const retried = await retryNetworkCommercialEvent(db, model.sources, eventId, user.userId, Date.now(), networkCommercialAlerts(ctx, PATH));
|
|
3672
|
+
return json({ ok: true, ...retried });
|
|
3673
|
+
} catch (error) {
|
|
3674
|
+
const code = error.code;
|
|
3675
|
+
if (code === "invalid_event") return json({ error: "invalid event reference" }, 400);
|
|
3676
|
+
if (code === "exception_not_active") return json({ error: "event has no active exception" }, 409);
|
|
3677
|
+
return json({ error: "Event recovery is pending. Check its provider evidence and retry." }, 503);
|
|
3678
|
+
}
|
|
3679
|
+
};
|
|
3680
|
+
|
|
3396
3681
|
// src/worker-routes-payment-recovery.ts
|
|
3397
3682
|
var handlePaymentRecovery = async (req, url, env, ctx) => {
|
|
3398
|
-
if (url.pathname !== "/api/admin/payment-recovery"
|
|
3683
|
+
if (url.pathname !== "/api/admin/payment-recovery") return null;
|
|
3684
|
+
if (ctx.chapter.mode !== "chapter") return handleNetworkCommercialRecovery(req, url, env, ctx);
|
|
3399
3685
|
if (!["GET", "POST"].includes(req.method)) return json({ error: "method not allowed" }, 405);
|
|
3400
3686
|
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
3401
3687
|
if (!user) return json({ error: "unauthorized" }, 401);
|
|
@@ -3457,10 +3743,10 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
|
|
|
3457
3743
|
};
|
|
3458
3744
|
|
|
3459
3745
|
// src/worker-routes-admin-named-seat.ts
|
|
3460
|
-
var
|
|
3746
|
+
var PATH2 = /^\/api\/admin\/applications\/([0-9a-f-]+)\/named-seat(\/refund)?$/;
|
|
3461
3747
|
var REFUNDABLE = /* @__PURE__ */ new Set(["renewal_pending", "pending_acceptance", "pending_approval", "pending_primary", "active"]);
|
|
3462
3748
|
var handleAdminNamedSeat = async (req, url, env, ctx) => {
|
|
3463
|
-
const match = url.pathname.match(
|
|
3749
|
+
const match = url.pathname.match(PATH2);
|
|
3464
3750
|
if (!match) return null;
|
|
3465
3751
|
const seats = ctx.membershipAuthority?.namedSeats;
|
|
3466
3752
|
if (ctx.chapter.mode !== "chapter" || !seats) return json({ error: "named seats are not configured" }, 404);
|
|
@@ -3513,42 +3799,42 @@ async function closeRecipientApplication(db, env, ctx, seatId) {
|
|
|
3513
3799
|
|
|
3514
3800
|
// src/membership-seat-refund-stripe.ts
|
|
3515
3801
|
import { stripeRequest as stripeRequest4 } from "@odla-ai/stripe";
|
|
3516
|
-
var
|
|
3802
|
+
var fail4 = (code = "named_seat_refund_mismatch") => {
|
|
3517
3803
|
throw Object.assign(new Error(code), { code });
|
|
3518
3804
|
};
|
|
3519
3805
|
async function refundNamedSeatStripePayment(secretKey, seat) {
|
|
3520
|
-
if (!seat.providerPaymentIntentId || seat.status !== "refund_pending")
|
|
3806
|
+
if (!seat.providerPaymentIntentId || seat.status !== "refund_pending") fail4();
|
|
3521
3807
|
const call = async (method, path, params, idempotencyKey) => {
|
|
3522
3808
|
const result = await stripeRequest4({ secretKey }, method, path, params, idempotencyKey ? { idempotencyKey } : {});
|
|
3523
|
-
if (!result.ok || !result.body) return
|
|
3809
|
+
if (!result.ok || !result.body) return fail4("named_seat_refund_provider_unavailable");
|
|
3524
3810
|
return result.body;
|
|
3525
3811
|
};
|
|
3526
3812
|
const metadata = { ...stripePartitionMetadata(seat), applicationId: seat.applicationId, membershipId: seat.primaryMembershipId, seatId: seat.id };
|
|
3527
3813
|
const payment = await call("GET", `/v1/payment_intents/${encodeURIComponent(seat.providerPaymentIntentId)}`);
|
|
3528
|
-
if (payment.id !== seat.providerPaymentIntentId || payment.customer !== seat.providerCustomerId || payment.amount !== seat.amountCents || payment.amount_received !== seat.amountCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod") || payment.status !== "succeeded" || !Object.entries(metadata).every(([key3, value]) => payment.metadata?.[key3] === value))
|
|
3814
|
+
if (payment.id !== seat.providerPaymentIntentId || payment.customer !== seat.providerCustomerId || payment.amount !== seat.amountCents || payment.amount_received !== seat.amountCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod") || payment.status !== "succeeded" || !Object.entries(metadata).every(([key3, value]) => payment.metadata?.[key3] === value)) fail4();
|
|
3529
3815
|
const sub = await call("GET", `/v1/subscriptions/${encodeURIComponent(seat.providerSubscriptionId)}`);
|
|
3530
|
-
if (sub.id !== seat.providerSubscriptionId || sub.customer !== seat.providerCustomerId || sub.livemode !== (seat.environment === "prod") || !Object.entries({ ...stripePartitionMetadata(seat), applicationId: seat.applicationId }).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(sub.items?.data))
|
|
3816
|
+
if (sub.id !== seat.providerSubscriptionId || sub.customer !== seat.providerCustomerId || sub.livemode !== (seat.environment === "prod") || !Object.entries({ ...stripePartitionMetadata(seat), applicationId: seat.applicationId }).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(sub.items?.data)) fail4();
|
|
3531
3817
|
const items = sub.items.data.filter((item) => item.metadata?.seatId === seat.id);
|
|
3532
|
-
if (items.length > 1)
|
|
3818
|
+
if (items.length > 1) fail4();
|
|
3533
3819
|
if (items.length && sub.status !== "canceled") {
|
|
3534
3820
|
const item = items[0];
|
|
3535
|
-
if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1)
|
|
3821
|
+
if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1) fail4();
|
|
3536
3822
|
const removed = await call("DELETE", `/v1/subscription_items/${encodeURIComponent(item.id)}`, { proration_behavior: "none" });
|
|
3537
|
-
if (removed.id !== item.id || removed.deleted !== true)
|
|
3823
|
+
if (removed.id !== item.id || removed.deleted !== true) fail4("named_seat_renewal_removal_unconfirmed");
|
|
3538
3824
|
}
|
|
3539
3825
|
const list = await call("GET", "/v1/refunds", { payment_intent: seat.providerPaymentIntentId, limit: 100 });
|
|
3540
|
-
if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100)
|
|
3826
|
+
if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail4("named_seat_refund_history_incomplete");
|
|
3541
3827
|
let refundedCents = 0, pendingCents = 0;
|
|
3542
3828
|
const refundIds = [];
|
|
3543
3829
|
for (const refund of list.data) {
|
|
3544
|
-
if (!/^re_[A-Za-z0-9_]+$/.test(refund.id) || refund.payment_intent !== seat.providerPaymentIntentId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status))
|
|
3830
|
+
if (!/^re_[A-Za-z0-9_]+$/.test(refund.id) || refund.payment_intent !== seat.providerPaymentIntentId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status)) fail4();
|
|
3545
3831
|
if (refund.status === "succeeded") {
|
|
3546
3832
|
refundedCents += refund.amount;
|
|
3547
3833
|
refundIds.push(refund.id);
|
|
3548
3834
|
} else if (refund.status === "pending" || refund.status === "requires_action") pendingCents += refund.amount;
|
|
3549
3835
|
}
|
|
3550
|
-
if (refundedCents + pendingCents > seat.amountCents)
|
|
3551
|
-
if (pendingCents)
|
|
3836
|
+
if (refundedCents + pendingCents > seat.amountCents) fail4();
|
|
3837
|
+
if (pendingCents) fail4("named_seat_refund_pending");
|
|
3552
3838
|
if (refundedCents < seat.amountCents) {
|
|
3553
3839
|
const remaining = seat.amountCents - refundedCents;
|
|
3554
3840
|
const refund = await call("POST", "/v1/refunds", {
|
|
@@ -3557,8 +3843,8 @@ async function refundNamedSeatStripePayment(secretKey, seat) {
|
|
|
3557
3843
|
reason: "requested_by_customer",
|
|
3558
3844
|
metadata
|
|
3559
3845
|
}, `named-seat-refund:${seat.id}:${remaining}`);
|
|
3560
|
-
if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id))
|
|
3561
|
-
if (refund.status !== "succeeded")
|
|
3846
|
+
if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail4();
|
|
3847
|
+
if (refund.status !== "succeeded") fail4("named_seat_refund_pending");
|
|
3562
3848
|
refundedCents += remaining;
|
|
3563
3849
|
refundIds.push(refund.id);
|
|
3564
3850
|
}
|
|
@@ -3763,6 +4049,107 @@ var handleAdminMembershipSeats = async (req, url, env, ctx) => {
|
|
|
3763
4049
|
}
|
|
3764
4050
|
};
|
|
3765
4051
|
|
|
4052
|
+
// src/membership-policy-versions.ts
|
|
4053
|
+
var LIMIT = 200;
|
|
4054
|
+
var text2 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
|
|
4055
|
+
var instant = (value) => {
|
|
4056
|
+
const at = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
|
|
4057
|
+
return Number.isSafeInteger(at) && at >= 0 ? at : void 0;
|
|
4058
|
+
};
|
|
4059
|
+
function provenance(row2) {
|
|
4060
|
+
const source = text2(row2.source), actor = text2(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
|
|
4061
|
+
return { ...source ? { source } : {}, ...actor ? { actor } : {}, ...recordedAt !== void 0 ? { recordedAt } : {} };
|
|
4062
|
+
}
|
|
4063
|
+
var live = (w, now) => w.from <= now && (w.until === void 0 || now < w.until);
|
|
4064
|
+
function stateOf(item, all, now, window, rivals) {
|
|
4065
|
+
if (item.status !== "published") return { state: item.status };
|
|
4066
|
+
const mine = window(item);
|
|
4067
|
+
if (now < mine.from) return { state: "scheduled" };
|
|
4068
|
+
const closed = mine.until !== void 0 && mine.until <= now;
|
|
4069
|
+
const higher = all.filter((other) => other.id !== item.id && other.status === "published" && other.version > item.version && rivals(item, other)).map(window).filter((w) => w.from <= now && (w.until === void 0 || w.until > mine.from));
|
|
4070
|
+
if (!closed && !higher.some((w) => live(w, now))) return { state: "active" };
|
|
4071
|
+
const dates = higher.map((w) => Math.max(w.from, mine.from));
|
|
4072
|
+
if (closed) dates.push(mine.until);
|
|
4073
|
+
return { state: "superseded", supersededAt: Math.min(...dates) };
|
|
4074
|
+
}
|
|
4075
|
+
var mechanism = (policy) => policy.percentOffBps === 0 || policy.discountEligibleSkuIds.length === 0 ? "none" : policy.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
|
|
4076
|
+
var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
|
|
4077
|
+
var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
|
|
4078
|
+
function normalize(rows5, collection, parse, invalid2) {
|
|
4079
|
+
const out = [];
|
|
4080
|
+
for (const row2 of rows5) {
|
|
4081
|
+
try {
|
|
4082
|
+
out.push({ value: parse(row2.snapshot), row: row2 });
|
|
4083
|
+
} catch (error) {
|
|
4084
|
+
invalid2.push({ collection, id: String(row2.id ?? ""), error: error instanceof Error ? error.message : String(error) });
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
return out;
|
|
4088
|
+
}
|
|
4089
|
+
async function listMembershipPolicyVersions(db, input) {
|
|
4090
|
+
const where = input.chapterId ? { chapterId: input.chapterId } : {};
|
|
4091
|
+
const rows5 = await db.query({
|
|
4092
|
+
membershipOfferingVersion: { $: { where, limit: LIMIT } },
|
|
4093
|
+
discountPolicyVersion: { $: { where, limit: LIMIT } }
|
|
4094
|
+
});
|
|
4095
|
+
const offeringRows = rows5.membershipOfferingVersion ?? [], policyRows = rows5.discountPolicyVersion ?? [];
|
|
4096
|
+
const invalid2 = [];
|
|
4097
|
+
const offerings = normalize(offeringRows, "membershipOfferingVersion", membershipOfferingVersion, invalid2);
|
|
4098
|
+
const policies = normalize(policyRows, "discountPolicyVersion", membershipDiscountPolicyVersion, invalid2);
|
|
4099
|
+
const offeringValues = offerings.map((item) => item.value), policyValues = policies.map((item) => item.value);
|
|
4100
|
+
return {
|
|
4101
|
+
now: input.now,
|
|
4102
|
+
chapterId: input.chapterId ?? null,
|
|
4103
|
+
offerings: offerings.map(({ value, row: row2 }) => ({
|
|
4104
|
+
...value,
|
|
4105
|
+
...provenance(row2),
|
|
4106
|
+
...stateOf(
|
|
4107
|
+
value,
|
|
4108
|
+
offeringValues,
|
|
4109
|
+
input.now,
|
|
4110
|
+
(v) => ({ from: v.effectiveFrom, ...v.effectiveUntil !== void 0 ? { until: v.effectiveUntil } : {} }),
|
|
4111
|
+
(a, b) => a.chapterId === b.chapterId && a.baseSkuId === b.baseSkuId
|
|
4112
|
+
)
|
|
4113
|
+
})).sort((a, b) => byChapter(a, b) || a.baseSkuId.localeCompare(b.baseSkuId) || b.version - a.version),
|
|
4114
|
+
discountPolicies: policies.map(({ value, row: row2 }) => ({
|
|
4115
|
+
...value,
|
|
4116
|
+
...provenance(row2),
|
|
4117
|
+
mechanism: mechanism(value),
|
|
4118
|
+
...stateOf(
|
|
4119
|
+
value,
|
|
4120
|
+
policyValues,
|
|
4121
|
+
input.now,
|
|
4122
|
+
(v) => ({ from: v.qualifyingStartsAt, until: v.qualifyingEndsAt }),
|
|
4123
|
+
(a, b) => a.chapterId === b.chapterId && sharesSku(a, b)
|
|
4124
|
+
)
|
|
4125
|
+
})).sort((a, b) => byChapter(a, b) || b.version - a.version),
|
|
4126
|
+
invalid: invalid2,
|
|
4127
|
+
truncated: offeringRows.length >= LIMIT || policyRows.length >= LIMIT
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
// src/worker-routes-admin-membership-policy.ts
|
|
4132
|
+
var PATH3 = "/api/admin/membership-policy-versions";
|
|
4133
|
+
var SCOPE2 = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
4134
|
+
var handleAdminMembershipPolicyVersions = async (req, url, env, ctx) => {
|
|
4135
|
+
if (url.pathname !== PATH3) return null;
|
|
4136
|
+
const operator = ctx.membershipAuthorityOperator;
|
|
4137
|
+
if (!operator) return json({ error: "not found" }, 404);
|
|
4138
|
+
if (req.method !== "GET") return json({ error: "method not allowed" }, 405);
|
|
4139
|
+
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
4140
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4141
|
+
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
4142
|
+
const chapterId = url.searchParams.get("chapterId");
|
|
4143
|
+
if (chapterId !== null && !SCOPE2.test(chapterId)) return json({ error: "chapterId is invalid" }, 400);
|
|
4144
|
+
let db;
|
|
4145
|
+
try {
|
|
4146
|
+
db = operator.makeDb(env);
|
|
4147
|
+
} catch {
|
|
4148
|
+
return json({ error: "the membership authority is not hosted by this deployment" }, 404);
|
|
4149
|
+
}
|
|
4150
|
+
return json(await listMembershipPolicyVersions(db, { chapterId, now: Date.now() }));
|
|
4151
|
+
};
|
|
4152
|
+
|
|
3766
4153
|
// src/worker-routes-membership-cancel.ts
|
|
3767
4154
|
var handleMembershipCancellation = async (req, url, env, ctx) => {
|
|
3768
4155
|
const match = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/cancel-renewal$/);
|
|
@@ -4615,8 +5002,8 @@ var APPLICATION_LABELS = {
|
|
|
4615
5002
|
state: "State"
|
|
4616
5003
|
};
|
|
4617
5004
|
function humanize(id6) {
|
|
4618
|
-
const
|
|
4619
|
-
return
|
|
5005
|
+
const text8 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
|
|
5006
|
+
return text8 ? `${text8[0].toUpperCase()}${text8.slice(1)}` : id6;
|
|
4620
5007
|
}
|
|
4621
5008
|
function applicationDefs(chapter) {
|
|
4622
5009
|
try {
|
|
@@ -4683,7 +5070,7 @@ var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
|
|
|
4683
5070
|
var PRICE = /^price_[A-Za-z0-9]{12,}$/;
|
|
4684
5071
|
var CURRENCY = /^[a-z]{3}$/;
|
|
4685
5072
|
var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
4686
|
-
var
|
|
5073
|
+
var text3 = (value, path, errors, max) => {
|
|
4687
5074
|
if (typeof value !== "string" || !value.trim()) {
|
|
4688
5075
|
errors.push(`${path} is required`);
|
|
4689
5076
|
return "";
|
|
@@ -4711,23 +5098,23 @@ function validateGiftOfferCatalog(input, tierIds) {
|
|
|
4711
5098
|
}
|
|
4712
5099
|
const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
4713
5100
|
for (const key3 of Object.keys(item)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
|
|
4714
|
-
const id6 =
|
|
5101
|
+
const id6 = text3(item.id, `${path}.id`, errors, 80);
|
|
4715
5102
|
if (id6 && !ID2.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
4716
5103
|
if (ids2.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
|
|
4717
5104
|
ids2.add(id6);
|
|
4718
5105
|
const targetRaw = record(item.target);
|
|
4719
5106
|
let target = { type: "entitlement", entitlementKey: "invalid" };
|
|
4720
5107
|
if (targetRaw?.type === "tier") {
|
|
4721
|
-
const tierId =
|
|
5108
|
+
const tierId = text3(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
|
|
4722
5109
|
if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
|
|
4723
5110
|
target = { type: "tier", tierId };
|
|
4724
5111
|
} else if (targetRaw?.type === "entitlement") {
|
|
4725
|
-
const entitlementKey =
|
|
5112
|
+
const entitlementKey = text3(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
|
|
4726
5113
|
if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
|
|
4727
5114
|
target = { type: "entitlement", entitlementKey };
|
|
4728
5115
|
} else errors.push(`${path}.target.type must be tier or entitlement`);
|
|
4729
5116
|
if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
|
|
4730
|
-
const stripePriceId =
|
|
5117
|
+
const stripePriceId = text3(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
|
|
4731
5118
|
if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
|
|
4732
5119
|
if (!Number.isSafeInteger(item.priceCents) || Number(item.priceCents) < 1 || Number(item.priceCents) > 1e8) errors.push(`${path}.priceCents must be an integer from 1 to 100,000,000`);
|
|
4733
5120
|
const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
|
|
@@ -4749,9 +5136,9 @@ function validateGiftOfferCatalog(input, tierIds) {
|
|
|
4749
5136
|
redemptionLifetimeHours: lifetime,
|
|
4750
5137
|
...activeFrom !== void 0 ? { activeFrom } : {},
|
|
4751
5138
|
...activeUntil !== void 0 ? { activeUntil } : {},
|
|
4752
|
-
title:
|
|
4753
|
-
description:
|
|
4754
|
-
ctaLabel:
|
|
5139
|
+
title: text3(item.title, `${path}.title`, errors, 160),
|
|
5140
|
+
description: text3(item.description, `${path}.description`, errors, 2e3),
|
|
5141
|
+
ctaLabel: text3(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
|
|
4755
5142
|
});
|
|
4756
5143
|
}
|
|
4757
5144
|
return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
|
|
@@ -4969,22 +5356,22 @@ async function createPaymentQuote(input) {
|
|
|
4969
5356
|
// src/membership-quote-projection.ts
|
|
4970
5357
|
var MEMBERSHIP_QUOTE_NAMESPACE = "membershipQuoteProjections";
|
|
4971
5358
|
var keys2 = ["appId", "chapterId", "environment", "runtime", "applicationId", "skuId"];
|
|
4972
|
-
var
|
|
5359
|
+
var fail5 = (code) => {
|
|
4973
5360
|
throw Object.assign(new Error(code), { code });
|
|
4974
5361
|
};
|
|
4975
5362
|
var load3 = async (db, ns, id6) => (await db.query({ [ns]: { $: { where: { id: id6 }, limit: 1 } } }))[ns]?.[0];
|
|
4976
5363
|
async function restore2(row2, target, now, allowExpired = false) {
|
|
4977
|
-
if (!row2 || keys2.some((key3) => row2[key3] !== target[key3]) || !Number.isSafeInteger(row2.revision) || Number(row2.revision) < 1 || typeof row2.requestId !== "string") return
|
|
4978
|
-
if (row2.status !== "ready") return
|
|
5364
|
+
if (!row2 || keys2.some((key3) => row2[key3] !== target[key3]) || !Number.isSafeInteger(row2.revision) || Number(row2.revision) < 1 || typeof row2.requestId !== "string") return fail5("membership_quote_unavailable");
|
|
5365
|
+
if (row2.status !== "ready") return fail5("membership_quote_pending");
|
|
4979
5366
|
const offer = await verifyIssuedMembershipOffer(row2.offer, { ...target, offerId: await membershipOfferIdForRequest({
|
|
4980
5367
|
...target,
|
|
4981
5368
|
requestId: row2.requestId
|
|
4982
5369
|
}), now, lifetimeMs: 6e4 });
|
|
4983
|
-
if (offer.issuedAt > now + 3e4 || !allowExpired && offer.expiresAt <= now) return
|
|
5370
|
+
if (offer.issuedAt > now + 3e4 || !allowExpired && offer.expiresAt <= now) return fail5("membership_quote_expired");
|
|
4984
5371
|
const tier = row2.tier, quote = row2.quote;
|
|
4985
5372
|
const offering = offer.contract.offering;
|
|
4986
5373
|
if (!tier || tier.id !== target.skuId || tier.groupId !== target.chapterId || !tier.stripePriceId || tier.priceCents !== offering.basePriceCents || tier.currency !== offering.currency || tier.interval !== offering.interval || !quote) {
|
|
4987
|
-
return
|
|
5374
|
+
return fail5("membership_catalog_mismatch");
|
|
4988
5375
|
}
|
|
4989
5376
|
const expected = await createPaymentQuote({
|
|
4990
5377
|
...target,
|
|
@@ -4995,7 +5382,7 @@ async function restore2(row2, target, now, allowExpired = false) {
|
|
|
4995
5382
|
presentation: quote.presentation,
|
|
4996
5383
|
founding: foundingOf(offer)
|
|
4997
5384
|
});
|
|
4998
|
-
if (quote.digest !== expected.digest) return
|
|
5385
|
+
if (quote.digest !== expected.digest) return fail5("membership_quote_conflict");
|
|
4999
5386
|
return { tier, quote: expected, billingTerms: offer.contract.billingTerms, membershipOffer: offer, projectionRevision: Number(row2.revision) };
|
|
5000
5387
|
}
|
|
5001
5388
|
async function readMembershipQuote(db, target, now) {
|
|
@@ -5003,9 +5390,9 @@ async function readMembershipQuote(db, target, now) {
|
|
|
5003
5390
|
}
|
|
5004
5391
|
async function prepareMembershipQuote(db, input, issue) {
|
|
5005
5392
|
const { applicationId: id6, now, app } = input;
|
|
5006
|
-
if (app.id !== id6 || app.groupId !== input.chapterId || app.tierId !== input.skuId || app.stripeRuntime !== input.runtime || app.status !== "submitted" || app.canceled === true || app.giftOfferId || app.giftClaimId || app.namedSeatId || app.namedSeatClaimPending) return
|
|
5393
|
+
if (app.id !== id6 || app.groupId !== input.chapterId || app.tierId !== input.skuId || app.stripeRuntime !== input.runtime || app.status !== "submitted" || app.canceled === true || app.giftOfferId || app.giftClaimId || app.namedSeatId || app.namedSeatClaimPending) return fail5("membership_quote_unavailable");
|
|
5007
5394
|
let row2 = await load3(db, MEMBERSHIP_QUOTE_NAMESPACE, id6);
|
|
5008
|
-
if (row2 && keys2.some((key3) => row2[key3] !== input[key3])) return
|
|
5395
|
+
if (row2 && keys2.some((key3) => row2[key3] !== input[key3])) return fail5("membership_quote_conflict");
|
|
5009
5396
|
if (row2?.status === "ready") {
|
|
5010
5397
|
try {
|
|
5011
5398
|
return await restore2(row2, input, now);
|
|
@@ -5050,7 +5437,7 @@ async function prepareMembershipQuote(db, input, issue) {
|
|
|
5050
5437
|
}
|
|
5051
5438
|
if (row2?.status === "ready") return restore2(row2, input, now);
|
|
5052
5439
|
if (!row2 || row2.status !== "pending" || keys2.some((key3) => row2[key3] !== input[key3]) || typeof row2.requestId !== "string") {
|
|
5053
|
-
return
|
|
5440
|
+
return fail5("membership_quote_pending");
|
|
5054
5441
|
}
|
|
5055
5442
|
const target = Object.fromEntries(keys2.map((key3) => [key3, input[key3]]));
|
|
5056
5443
|
const offer = await verifyIssuedMembershipOffer(await issue({ ...target, requestId: row2.requestId }), {
|
|
@@ -5314,17 +5701,17 @@ async function readBoundedRequestText(req, maxBytes) {
|
|
|
5314
5701
|
if (!reader) return "";
|
|
5315
5702
|
const decoder = new TextDecoder();
|
|
5316
5703
|
let bytes = 0;
|
|
5317
|
-
let
|
|
5704
|
+
let text8 = "";
|
|
5318
5705
|
for (; ; ) {
|
|
5319
5706
|
const { done, value } = await reader.read();
|
|
5320
5707
|
if (req.signal.aborted) return invalid2();
|
|
5321
5708
|
if (done) {
|
|
5322
5709
|
complete = true;
|
|
5323
|
-
return
|
|
5710
|
+
return text8 + decoder.decode();
|
|
5324
5711
|
}
|
|
5325
5712
|
bytes += value.byteLength;
|
|
5326
5713
|
if (bytes > maxBytes) return json({ error: "request body too large" }, 413);
|
|
5327
|
-
|
|
5714
|
+
text8 += decoder.decode(value, { stream: true });
|
|
5328
5715
|
}
|
|
5329
5716
|
} catch {
|
|
5330
5717
|
return invalid2();
|
|
@@ -5677,37 +6064,61 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
|
5677
6064
|
} catch {
|
|
5678
6065
|
}
|
|
5679
6066
|
}
|
|
5680
|
-
|
|
6067
|
+
|
|
6068
|
+
// src/submit-notifications.ts
|
|
6069
|
+
async function notifyAdminOfApplication(db, env, notice) {
|
|
5681
6070
|
try {
|
|
5682
|
-
const
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
6071
|
+
const { group, application: app } = notice;
|
|
6072
|
+
if (typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
|
|
6073
|
+
const { tierName: tierName2 } = await checkoutVariables(db, group, app);
|
|
6074
|
+
await sendTemplated(lifecycleNotifyDeps(db, env), {
|
|
6075
|
+
group: emailGroupFrom(group),
|
|
6076
|
+
template: "adminNotification",
|
|
6077
|
+
to: group.notificationEmail,
|
|
6078
|
+
vars: { ...applicantVariables(app), tier: tierName2, tierName: tierName2, adminUrl: adminUrlFor(notice.origin), membersUrl: membersUrlFor(notice.origin) },
|
|
6079
|
+
dedupeKey: `apply:${String(app.id)}:admin`,
|
|
6080
|
+
applicationId: String(app.id)
|
|
6081
|
+
});
|
|
6082
|
+
} catch {
|
|
6083
|
+
}
|
|
6084
|
+
}
|
|
6085
|
+
var FREE_CONTINUATIONS = /* @__PURE__ */ new Set(["booking", "accepted", "approve"]);
|
|
6086
|
+
function shouldConfirmSubmit(state, group) {
|
|
6087
|
+
if (state !== null && FREE_CONTINUATIONS.has(state)) return true;
|
|
6088
|
+
const templates = group.emailTemplates;
|
|
6089
|
+
return templates?.paymentConfirmation?.enabled === false;
|
|
6090
|
+
}
|
|
6091
|
+
function submitNextStep(state) {
|
|
6092
|
+
switch (state) {
|
|
6093
|
+
case "accepted":
|
|
6094
|
+
return "Your membership is approved.";
|
|
6095
|
+
case "booking":
|
|
6096
|
+
return "Next, schedule your introduction call.";
|
|
6097
|
+
case "payment":
|
|
6098
|
+
case "paymentPending":
|
|
6099
|
+
case "gift":
|
|
6100
|
+
return "Next, complete your membership payment.";
|
|
6101
|
+
default:
|
|
6102
|
+
return "";
|
|
6103
|
+
}
|
|
6104
|
+
}
|
|
6105
|
+
async function notifySubmitConfirmed(db, env, notice) {
|
|
6106
|
+
try {
|
|
6107
|
+
const { group, application: app } = notice;
|
|
6108
|
+
if (typeof app.email !== "string" || !app.email || !shouldConfirmSubmit(notice.state, group)) return;
|
|
6109
|
+
await sendTemplated(lifecycleNotifyDeps(db, env), {
|
|
6110
|
+
group: emailGroupFrom(group),
|
|
6111
|
+
template: "submitConfirmation",
|
|
6112
|
+
to: app.email,
|
|
6113
|
+
vars: {
|
|
6114
|
+
...applicantVariables(app),
|
|
6115
|
+
...await checkoutVariables(db, group, app),
|
|
6116
|
+
submitNextStep: submitNextStep(notice.state),
|
|
6117
|
+
membersUrl: membersUrlFor(notice.origin)
|
|
5695
6118
|
},
|
|
5696
|
-
{
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
to: group.notificationEmail,
|
|
5700
|
-
vars: {
|
|
5701
|
-
firstName: stringValue(fields.firstName),
|
|
5702
|
-
lastName: stringValue(fields.lastName),
|
|
5703
|
-
email: stringValue(fields.email),
|
|
5704
|
-
phone: stringValue(fields.phone),
|
|
5705
|
-
state: stringValue(fields.state)
|
|
5706
|
-
},
|
|
5707
|
-
dedupeKey: `apply:${applicationId}:admin`,
|
|
5708
|
-
applicationId
|
|
5709
|
-
}
|
|
5710
|
-
);
|
|
6119
|
+
dedupeKey: `apply:${String(app.id)}:confirm`,
|
|
6120
|
+
applicationId: String(app.id)
|
|
6121
|
+
});
|
|
5711
6122
|
} catch {
|
|
5712
6123
|
}
|
|
5713
6124
|
}
|
|
@@ -5905,17 +6316,17 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
5905
6316
|
if (provisioningFailure.status === 409) await rejectUnprovisionedSignup(db, result.id, canonical3.status);
|
|
5906
6317
|
return json(provisioningFailure.body, provisioningFailure.status);
|
|
5907
6318
|
}
|
|
5908
|
-
if (!result.duplicate && effectiveChapter.sends.adminNotification === "submit") {
|
|
5909
|
-
await notifyAdminOfApplication(db, env, chapter.id, result.id, canonical3);
|
|
5910
|
-
}
|
|
5911
6319
|
const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
|
|
5912
6320
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
5913
|
-
const
|
|
6321
|
+
const notice = group ? { group, application: canonical3, origin: url.origin } : null;
|
|
6322
|
+
if (notice && !result.duplicate && effectiveChapter.sends.adminNotification === "submit") await notifyAdminOfApplication(db, env, notice);
|
|
6323
|
+
const state = notice ? await continueJoin(db, ctx, env, url.origin, canonical3, notice.group) : null;
|
|
5914
6324
|
if (!state) return json({
|
|
5915
6325
|
error: "Application saved; its next step is temporarily unavailable. Retry this submission.",
|
|
5916
6326
|
code: "application_continuation_unavailable",
|
|
5917
6327
|
id: result.id
|
|
5918
6328
|
}, 503);
|
|
6329
|
+
if (notice && !result.duplicate) await notifySubmitConfirmed(db, env, { ...notice, state });
|
|
5919
6330
|
const final = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications?.[0];
|
|
5920
6331
|
if (!final || final.groupId !== chapter.id || state === "accepted" && final.status !== "approved") {
|
|
5921
6332
|
return json({ error: "application changed; reload its current state" }, 409);
|
|
@@ -6035,10 +6446,10 @@ function boundedString(value, path, errors, max, required = false) {
|
|
|
6035
6446
|
errors.push(`${path} must be a string`);
|
|
6036
6447
|
return void 0;
|
|
6037
6448
|
}
|
|
6038
|
-
const
|
|
6039
|
-
if (required && !
|
|
6040
|
-
if (
|
|
6041
|
-
return
|
|
6449
|
+
const text8 = value.trim();
|
|
6450
|
+
if (required && !text8) errors.push(`${path} is required`);
|
|
6451
|
+
if (text8.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
6452
|
+
return text8;
|
|
6042
6453
|
}
|
|
6043
6454
|
function validateMembershipPageDocument(input, options = {}) {
|
|
6044
6455
|
const errors = [];
|
|
@@ -6087,8 +6498,8 @@ function validateMembershipPageDocument(input, options = {}) {
|
|
|
6087
6498
|
else {
|
|
6088
6499
|
if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
|
|
6089
6500
|
benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
|
|
6090
|
-
const
|
|
6091
|
-
return
|
|
6501
|
+
const text8 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
|
|
6502
|
+
return text8 ? [text8] : [];
|
|
6092
6503
|
});
|
|
6093
6504
|
}
|
|
6094
6505
|
}
|
|
@@ -6182,7 +6593,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
|
|
|
6182
6593
|
function unknownKeys(value, allowed2, path, errors) {
|
|
6183
6594
|
for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
|
|
6184
6595
|
}
|
|
6185
|
-
function
|
|
6596
|
+
function text4(value, path, errors, max = 65536) {
|
|
6186
6597
|
if (typeof value !== "string" || !value.trim()) {
|
|
6187
6598
|
errors.push(`${path} is required`);
|
|
6188
6599
|
return "";
|
|
@@ -6205,11 +6616,11 @@ function tiersOf(value, errors) {
|
|
|
6205
6616
|
return [];
|
|
6206
6617
|
}
|
|
6207
6618
|
unknownKeys(item, TIER_KEYS2, path, errors);
|
|
6208
|
-
const id6 =
|
|
6619
|
+
const id6 = text4(item.id, `${path}.id`, errors, 80);
|
|
6209
6620
|
if (id6 && !TIER.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
6210
6621
|
if (seen.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
|
|
6211
6622
|
seen.add(id6);
|
|
6212
|
-
const name =
|
|
6623
|
+
const name = text4(item.name, `${path}.name`, errors, 160);
|
|
6213
6624
|
if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
|
|
6214
6625
|
if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
|
|
6215
6626
|
if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
|
|
@@ -6227,7 +6638,7 @@ function tiersOf(value, errors) {
|
|
|
6227
6638
|
if (item.interval !== void 0 && item.interval !== "year" && item.interval !== "month") {
|
|
6228
6639
|
errors.push(`${path}.interval must be year or month`);
|
|
6229
6640
|
}
|
|
6230
|
-
const blurb = item.blurb === void 0 ? void 0 :
|
|
6641
|
+
const blurb = item.blurb === void 0 ? void 0 : text4(item.blurb, `${path}.blurb`, errors, 4e3);
|
|
6231
6642
|
return [{
|
|
6232
6643
|
id: id6,
|
|
6233
6644
|
name,
|
|
@@ -6285,7 +6696,7 @@ function policyOf(value, errors, tiers) {
|
|
|
6285
6696
|
const result = {};
|
|
6286
6697
|
for (const key3 of allowed2) {
|
|
6287
6698
|
if (input[key3] === void 0 && key3 !== "disclaimerText" && key3 !== "refundPolicyText") continue;
|
|
6288
|
-
result[key3] =
|
|
6699
|
+
result[key3] = text4(input[key3], `document.policy.${key3}`, errors);
|
|
6289
6700
|
}
|
|
6290
6701
|
const tierPolicies = {};
|
|
6291
6702
|
if (input.tierPolicies !== void 0) {
|
|
@@ -6300,8 +6711,8 @@ function policyOf(value, errors, tiers) {
|
|
|
6300
6711
|
}
|
|
6301
6712
|
unknownKeys(policy, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
|
|
6302
6713
|
tierPolicies[id6] = {
|
|
6303
|
-
refundPolicyText:
|
|
6304
|
-
...policy.trustCopy !== void 0 ? { trustCopy:
|
|
6714
|
+
refundPolicyText: text4(policy.refundPolicyText, `${path}.refundPolicyText`, errors),
|
|
6715
|
+
...policy.trustCopy !== void 0 ? { trustCopy: text4(policy.trustCopy, `${path}.trustCopy`, errors) } : {}
|
|
6305
6716
|
};
|
|
6306
6717
|
}
|
|
6307
6718
|
}
|
|
@@ -6341,9 +6752,9 @@ function validateSignupControlDocument(input) {
|
|
|
6341
6752
|
if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
|
|
6342
6753
|
const source = recordOf(root.source);
|
|
6343
6754
|
const target = recordOf(root.target);
|
|
6344
|
-
const appId =
|
|
6345
|
-
const chapterId =
|
|
6346
|
-
const environment =
|
|
6755
|
+
const appId = text4(source?.appId, "document.source.appId", errors, 63);
|
|
6756
|
+
const chapterId = text4(target?.chapterId, "document.target.chapterId", errors, 63);
|
|
6757
|
+
const environment = text4(target?.environment, "document.target.environment", errors, 63);
|
|
6347
6758
|
if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
6348
6759
|
if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
|
|
6349
6760
|
if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
|
|
@@ -7522,7 +7933,7 @@ async function deliverAdmissionGrants(options) {
|
|
|
7522
7933
|
}
|
|
7523
7934
|
|
|
7524
7935
|
// src/worker-routes-network-admission.ts
|
|
7525
|
-
var
|
|
7936
|
+
var PATH4 = "/api/admin/network/admissions";
|
|
7526
7937
|
var RETRY_PATH = "/api/admin/network/admissions/retry";
|
|
7527
7938
|
var REVOKE_PATH = "/api/admin/network/admissions/revoke";
|
|
7528
7939
|
var MUTATION_ID2 = /^[A-Za-z0-9_-]{16,128}$/;
|
|
@@ -7602,11 +8013,11 @@ async function parseBody(req) {
|
|
|
7602
8013
|
}
|
|
7603
8014
|
}
|
|
7604
8015
|
var handleAdminNetworkAdmissions = async (req, url, env, ctx) => {
|
|
7605
|
-
if (![
|
|
8016
|
+
if (![PATH4, RETRY_PATH, REVOKE_PATH].includes(url.pathname)) return null;
|
|
7606
8017
|
const auth = await gate2(req, env, ctx);
|
|
7607
8018
|
if ("response" in auth) return auth.response;
|
|
7608
8019
|
const db = auth.db;
|
|
7609
|
-
if (url.pathname ===
|
|
8020
|
+
if (url.pathname === PATH4 && req.method === "GET") {
|
|
7610
8021
|
const targetId2 = url.searchParams.get("targetId") ?? "";
|
|
7611
8022
|
const target2 = targetFor(ctx, targetId2);
|
|
7612
8023
|
if (!target2) return reject6(404, "unknown_target", "admission target is not configured");
|
|
@@ -7707,6 +8118,41 @@ async function retryLeaderAdmissionDeliveries(env, ctx, limit = 100) {
|
|
|
7707
8118
|
|
|
7708
8119
|
// src/worker-routes-schedule.ts
|
|
7709
8120
|
import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
|
|
8121
|
+
|
|
8122
|
+
// src/booking-notifications.ts
|
|
8123
|
+
async function bookingConfirmationVariables(db, group, app, meeting) {
|
|
8124
|
+
const startAt = formatInstant(meeting.startAt, meeting.timezone);
|
|
8125
|
+
const meetUrl = meeting.meetUrl ?? "", htmlLink = meeting.htmlLink ?? "";
|
|
8126
|
+
return {
|
|
8127
|
+
...applicantVariables(app),
|
|
8128
|
+
...await checkoutVariables(db, group, app),
|
|
8129
|
+
startAt,
|
|
8130
|
+
endAt: formatInstant(meeting.endAt, meeting.timezone),
|
|
8131
|
+
timezone: meeting.timezone,
|
|
8132
|
+
meetUrl,
|
|
8133
|
+
htmlLink,
|
|
8134
|
+
// The placeholders the seeded template used before these variables existed.
|
|
8135
|
+
meetingTime: startAt,
|
|
8136
|
+
meetingLink: meetUrl || htmlLink
|
|
8137
|
+
};
|
|
8138
|
+
}
|
|
8139
|
+
async function notifyBookingConfirmed(db, env, input) {
|
|
8140
|
+
const app = input.application;
|
|
8141
|
+
if (typeof app.email !== "string" || !app.email) return;
|
|
8142
|
+
try {
|
|
8143
|
+
await sendTemplated(lifecycleNotifyDeps(db, env), {
|
|
8144
|
+
group: emailGroupFrom(input.group),
|
|
8145
|
+
template: "prepEmail",
|
|
8146
|
+
to: app.email,
|
|
8147
|
+
vars: await bookingConfirmationVariables(db, input.group, app, input.meeting),
|
|
8148
|
+
dedupeKey: `prep:${String(app.id)}`,
|
|
8149
|
+
applicationId: String(app.id)
|
|
8150
|
+
});
|
|
8151
|
+
} catch {
|
|
8152
|
+
}
|
|
8153
|
+
}
|
|
8154
|
+
|
|
8155
|
+
// src/worker-routes-schedule.ts
|
|
7710
8156
|
function errCode(err) {
|
|
7711
8157
|
if (err && typeof err === "object") {
|
|
7712
8158
|
const code = err.code;
|
|
@@ -7821,12 +8267,7 @@ async function bookSlot(req, env, ctx) {
|
|
|
7821
8267
|
await db.transact([meetingOp, appOp]);
|
|
7822
8268
|
const booked = { ...app, ...bookingAttrs };
|
|
7823
8269
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: booked, stage: String(booked.status ?? "") }).catch(() => void 0);
|
|
7824
|
-
|
|
7825
|
-
await sendTemplated(
|
|
7826
|
-
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, debugEmail: env.ODLA_DEBUG_EMAIL, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
7827
|
-
{ group: emailGroupFrom(group), template: "prepEmail", to: app.email, vars: { firstName: String(app.firstName ?? "") }, dedupeKey: `prep:${applicationId}`, applicationId }
|
|
7828
|
-
).catch(() => void 0);
|
|
7829
|
-
}
|
|
8270
|
+
await notifyBookingConfirmed(db, env, { group, application: app, meeting: { startAt, endAt, timezone: cfg.timezone, meetUrl, htmlLink } });
|
|
7830
8271
|
return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
|
|
7831
8272
|
}
|
|
7832
8273
|
var handleSchedule = async (req, url, env, ctx) => {
|
|
@@ -7857,21 +8298,21 @@ import { stripeRequest as stripeRequest5 } from "@odla-ai/stripe";
|
|
|
7857
8298
|
var row = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
7858
8299
|
var id2 = (value) => typeof value === "string" ? value : String(row(value).id ?? "");
|
|
7859
8300
|
var providerId2 = (value, prefix) => new RegExp(`^${prefix}_[A-Za-z0-9]+$`).test(value);
|
|
7860
|
-
function
|
|
8301
|
+
function fail6(code = "refund_target_mismatch") {
|
|
7861
8302
|
throw Object.assign(new Error(code), { code });
|
|
7862
8303
|
}
|
|
7863
8304
|
async function refundSubscriptionPayment(secretKey, input) {
|
|
7864
|
-
if (!providerId2(input.customerId, "cus") || !providerId2(input.subscriptionId, "sub"))
|
|
8305
|
+
if (!providerId2(input.customerId, "cus") || !providerId2(input.subscriptionId, "sub")) fail6();
|
|
7865
8306
|
const call = async (method, path, params, key3) => {
|
|
7866
8307
|
const result = await stripeRequest5({ secretKey }, method, path, params, key3 ? { idempotencyKey: key3 } : {});
|
|
7867
|
-
if (!result.ok)
|
|
7868
|
-
if (!result.body || typeof result.body !== "object" || Array.isArray(result.body))
|
|
8308
|
+
if (!result.ok) fail6("stripe_error");
|
|
8309
|
+
if (!result.body || typeof result.body !== "object" || Array.isArray(result.body)) fail6("stripe_invalid_response");
|
|
7869
8310
|
return result.body;
|
|
7870
8311
|
};
|
|
7871
8312
|
const sub = await call("GET", `/v1/subscriptions/${input.subscriptionId}`);
|
|
7872
|
-
if (id2(sub) !== input.subscriptionId || id2(sub.customer) !== input.customerId)
|
|
8313
|
+
if (id2(sub) !== input.subscriptionId || id2(sub.customer) !== input.customerId) fail6();
|
|
7873
8314
|
for (const [key3, value] of Object.entries(input.metadata ?? {})) {
|
|
7874
|
-
if (row(sub.metadata)[key3] !== value)
|
|
8315
|
+
if (row(sub.metadata)[key3] !== value) fail6();
|
|
7875
8316
|
}
|
|
7876
8317
|
let cursor;
|
|
7877
8318
|
let earliest;
|
|
@@ -7884,55 +8325,55 @@ async function refundSubscriptionPayment(secretKey, input) {
|
|
|
7884
8325
|
limit: 100,
|
|
7885
8326
|
...cursor ? { starting_after: cursor } : {}
|
|
7886
8327
|
});
|
|
7887
|
-
if (!Array.isArray(list.data) || typeof list.has_more !== "boolean")
|
|
8328
|
+
if (!Array.isArray(list.data) || typeof list.has_more !== "boolean") fail6("stripe_invalid_response");
|
|
7888
8329
|
for (const value of list.data) {
|
|
7889
8330
|
const invoice2 = row(value);
|
|
7890
8331
|
const subscription = invoice2.subscription ?? row(row(invoice2.parent).subscription_details).subscription;
|
|
7891
|
-
if (!providerId2(id2(invoice2), "in") || seen.has(id2(invoice2)) || id2(subscription) !== input.subscriptionId || id2(invoice2.customer) !== input.customerId || invoice2.status !== "paid" || !Number.isSafeInteger(invoice2.created) || !Number.isSafeInteger(invoice2.amount_paid) || Number(invoice2.amount_paid) < 0)
|
|
8332
|
+
if (!providerId2(id2(invoice2), "in") || seen.has(id2(invoice2)) || id2(subscription) !== input.subscriptionId || id2(invoice2.customer) !== input.customerId || invoice2.status !== "paid" || !Number.isSafeInteger(invoice2.created) || !Number.isSafeInteger(invoice2.amount_paid) || Number(invoice2.amount_paid) < 0) fail6();
|
|
7892
8333
|
seen.add(id2(invoice2));
|
|
7893
8334
|
if (Number(invoice2.amount_paid) > 0 && (!earliest || Number(invoice2.created) < Number(earliest.created))) earliest = invoice2;
|
|
7894
8335
|
}
|
|
7895
8336
|
if (!list.has_more) break;
|
|
7896
|
-
if (!list.data.length || page === 9)
|
|
8337
|
+
if (!list.data.length || page === 9) fail6("refund_history_incomplete");
|
|
7897
8338
|
cursor = id2(list.data[list.data.length - 1]);
|
|
7898
8339
|
}
|
|
7899
|
-
if (!earliest)
|
|
8340
|
+
if (!earliest) fail6("no_charge");
|
|
7900
8341
|
const invoice = earliest;
|
|
7901
|
-
if (input.expectedInvoiceId !== void 0 && id2(invoice) !== input.expectedInvoiceId || input.expectedAmountCents !== void 0 && invoice.amount_paid !== input.expectedAmountCents)
|
|
8342
|
+
if (input.expectedInvoiceId !== void 0 && id2(invoice) !== input.expectedInvoiceId || input.expectedAmountCents !== void 0 && invoice.amount_paid !== input.expectedAmountCents) fail6();
|
|
7902
8343
|
let chargeId = id2(invoice.charge);
|
|
7903
8344
|
let paymentIntentId = id2(invoice.payment_intent);
|
|
7904
8345
|
if (!chargeId && !paymentIntentId) {
|
|
7905
8346
|
const payments = await call("GET", "/v1/invoice_payments", { invoice: id2(invoice), status: "paid", limit: 100 });
|
|
7906
|
-
if (payments.has_more !== false || !Array.isArray(payments.data) || payments.data.length !== 1)
|
|
8347
|
+
if (payments.has_more !== false || !Array.isArray(payments.data) || payments.data.length !== 1) fail6("refund_payment_ambiguous");
|
|
7907
8348
|
const payment = row(payments.data[0]);
|
|
7908
|
-
if (id2(payment.invoice) !== id2(invoice) || payment.status !== "paid" || row(payment.payment).type !== "payment_intent")
|
|
8349
|
+
if (id2(payment.invoice) !== id2(invoice) || payment.status !== "paid" || row(payment.payment).type !== "payment_intent") fail6();
|
|
7909
8350
|
paymentIntentId = id2(row(payment.payment).payment_intent);
|
|
7910
8351
|
}
|
|
7911
8352
|
if (!chargeId) {
|
|
7912
|
-
if (!providerId2(paymentIntentId, "pi"))
|
|
8353
|
+
if (!providerId2(paymentIntentId, "pi")) fail6();
|
|
7913
8354
|
const payment = await call("GET", `/v1/payment_intents/${paymentIntentId}`);
|
|
7914
|
-
if (id2(payment) !== paymentIntentId || id2(payment.customer) !== input.customerId || payment.status !== "succeeded")
|
|
8355
|
+
if (id2(payment) !== paymentIntentId || id2(payment.customer) !== input.customerId || payment.status !== "succeeded") fail6();
|
|
7915
8356
|
chargeId = id2(payment.latest_charge);
|
|
7916
8357
|
}
|
|
7917
|
-
if (!providerId2(chargeId, "ch"))
|
|
8358
|
+
if (!providerId2(chargeId, "ch")) fail6();
|
|
7918
8359
|
const charge = await call("GET", `/v1/charges/${chargeId}`);
|
|
7919
|
-
if (id2(charge) !== chargeId || id2(charge.customer) !== input.customerId || charge.status !== "succeeded" || charge.invoice != null && id2(charge.invoice) !== id2(invoice) || paymentIntentId && id2(charge.payment_intent) !== paymentIntentId || !Number.isSafeInteger(charge.amount) || Number(charge.amount) <= 0 || !Number.isSafeInteger(charge.amount_refunded) || Number(charge.amount_refunded) < 0 || Number(charge.amount_refunded) > Number(charge.amount) || charge.currency !== invoice.currency || Number(charge.amount) !== Number(invoice.amount_paid))
|
|
7920
|
-
if (input.alreadyRefundedChargeId !== void 0 && (chargeId !== input.alreadyRefundedChargeId || charge.amount_refunded !== charge.amount || charge.refunded !== true))
|
|
8360
|
+
if (id2(charge) !== chargeId || id2(charge.customer) !== input.customerId || charge.status !== "succeeded" || charge.invoice != null && id2(charge.invoice) !== id2(invoice) || paymentIntentId && id2(charge.payment_intent) !== paymentIntentId || !Number.isSafeInteger(charge.amount) || Number(charge.amount) <= 0 || !Number.isSafeInteger(charge.amount_refunded) || Number(charge.amount_refunded) < 0 || Number(charge.amount_refunded) > Number(charge.amount) || charge.currency !== invoice.currency || Number(charge.amount) !== Number(invoice.amount_paid)) fail6();
|
|
8361
|
+
if (input.alreadyRefundedChargeId !== void 0 && (chargeId !== input.alreadyRefundedChargeId || charge.amount_refunded !== charge.amount || charge.refunded !== true)) fail6();
|
|
7921
8362
|
if (charge.amount_refunded !== charge.amount) {
|
|
7922
8363
|
const refund = await call("POST", "/v1/refunds", {
|
|
7923
8364
|
charge: chargeId,
|
|
7924
8365
|
...input.metadata ? { metadata: input.metadata } : {}
|
|
7925
8366
|
}, `chapter-initial-refund:${input.subscriptionId}:${chargeId}`);
|
|
7926
|
-
if (id2(refund.charge) !== chargeId || !["succeeded", "pending"].includes(String(refund.status)) || !Number.isSafeInteger(refund.amount) || Number(refund.amount) !== Number(charge.amount) - Number(charge.amount_refunded))
|
|
8367
|
+
if (id2(refund.charge) !== chargeId || !["succeeded", "pending"].includes(String(refund.status)) || !Number.isSafeInteger(refund.amount) || Number(refund.amount) !== Number(charge.amount) - Number(charge.amount_refunded)) fail6("refund_not_confirmed");
|
|
7927
8368
|
}
|
|
7928
8369
|
let subscriptionCanceled = sub.status === "canceled";
|
|
7929
8370
|
if (input.cancelSubscription !== false && !subscriptionCanceled) {
|
|
7930
8371
|
const canceled = await call("DELETE", `/v1/subscriptions/${input.subscriptionId}`);
|
|
7931
|
-
if (id2(canceled) !== input.subscriptionId || canceled.status !== "canceled")
|
|
8372
|
+
if (id2(canceled) !== input.subscriptionId || canceled.status !== "canceled") fail6("refund_cancel_incomplete");
|
|
7932
8373
|
subscriptionCanceled = true;
|
|
7933
8374
|
}
|
|
7934
8375
|
const confirmed = await call("GET", `/v1/charges/${chargeId}`);
|
|
7935
|
-
if (id2(confirmed) !== chargeId || id2(confirmed.customer) !== input.customerId || confirmed.amount !== charge.amount || confirmed.currency !== charge.currency || confirmed.amount_refunded !== charge.amount || confirmed.refunded !== true)
|
|
8376
|
+
if (id2(confirmed) !== chargeId || id2(confirmed.customer) !== input.customerId || confirmed.amount !== charge.amount || confirmed.currency !== charge.currency || confirmed.amount_refunded !== charge.amount || confirmed.refunded !== true) fail6("refund_not_confirmed");
|
|
7936
8377
|
return { refundedCents: Number(charge.amount), subscriptionCanceled };
|
|
7937
8378
|
}
|
|
7938
8379
|
|
|
@@ -7998,7 +8439,7 @@ async function stripeCall(sk, method, path, params, idempotencyKey) {
|
|
|
7998
8439
|
idempotencyKey ? { idempotencyKey } : {}
|
|
7999
8440
|
);
|
|
8000
8441
|
}
|
|
8001
|
-
function
|
|
8442
|
+
function fail7(op, r) {
|
|
8002
8443
|
const err = new Error(`stripe ${op} failed: ${r.status}`);
|
|
8003
8444
|
err.code = "stripe_error";
|
|
8004
8445
|
throw err;
|
|
@@ -8048,7 +8489,7 @@ function createStripeProvider(config) {
|
|
|
8048
8489
|
{ email: input.email, name: input.name, metadata: meta },
|
|
8049
8490
|
`cus:${input.applicationId}`
|
|
8050
8491
|
);
|
|
8051
|
-
if (!cust.ok)
|
|
8492
|
+
if (!cust.ok) fail7("customer create", cust);
|
|
8052
8493
|
if (typeof cust.body.id !== "string" || !/^cus_[A-Za-z0-9]+$/.test(cust.body.id)) subscriptionContractMismatch();
|
|
8053
8494
|
customerId = cust.body.id;
|
|
8054
8495
|
}
|
|
@@ -8080,7 +8521,7 @@ function createStripeProvider(config) {
|
|
|
8080
8521
|
// retry between create and the db write can't orphan a second one.
|
|
8081
8522
|
`sub:${input.applicationId}${input.purchaseAttemptId ? `:${input.purchaseAttemptId}` : ""}`
|
|
8082
8523
|
);
|
|
8083
|
-
if (!sub.ok)
|
|
8524
|
+
if (!sub.ok) fail7("subscription create", sub);
|
|
8084
8525
|
if (input.billingTerms?.discount?.duration === "once" && Array.isArray(sub.body.discounts) && sub.body.discounts.length === 0) {
|
|
8085
8526
|
const subscriptionId = sub.body.id;
|
|
8086
8527
|
if (typeof subscriptionId !== "string" || !/^sub_[A-Za-z0-9]+$/.test(subscriptionId)) subscriptionContractMismatch();
|
|
@@ -8090,14 +8531,14 @@ function createStripeProvider(config) {
|
|
|
8090
8531
|
"expand[2]": "discounts",
|
|
8091
8532
|
"expand[3]": "latest_invoice.discounts"
|
|
8092
8533
|
});
|
|
8093
|
-
if (!expanded.ok)
|
|
8534
|
+
if (!expanded.ok) fail7("subscription invoice evidence", expanded);
|
|
8094
8535
|
if (expanded.body.id !== subscriptionId) subscriptionContractMismatch();
|
|
8095
8536
|
sub = expanded;
|
|
8096
8537
|
}
|
|
8097
8538
|
verifySubscriptionResult(sub.body, input, customerId, meta);
|
|
8098
8539
|
const invoice = sub.body.latest_invoice;
|
|
8099
8540
|
const clientSecret2 = clientSecretOf(sub.body);
|
|
8100
|
-
if (!clientSecret2)
|
|
8541
|
+
if (!clientSecret2) fail7("subscription confirmation-secret missing", sub);
|
|
8101
8542
|
await tagSubscriptionPayment(
|
|
8102
8543
|
sk,
|
|
8103
8544
|
invoice,
|
|
@@ -8922,6 +9363,58 @@ var handleNamedSeat = async (req, url, env, ctx) => {
|
|
|
8922
9363
|
}
|
|
8923
9364
|
};
|
|
8924
9365
|
|
|
9366
|
+
// src/named-seat-accepted-template.ts
|
|
9367
|
+
var SEAT_ACCEPTED_TEMPLATE = {
|
|
9368
|
+
enabled: true,
|
|
9369
|
+
subject: "{{recipientName}} accepted your {{chapterName}} seat",
|
|
9370
|
+
text: "Hi {{firstName}},\n\n{{recipientName}} has accepted the {{tierName}} seat you purchased at {{chapterName}}. They will complete their own application and any membership requirements from here; nothing more is needed from you.\n\nWarmly,\n{{chapterName}}"
|
|
9371
|
+
};
|
|
9372
|
+
|
|
9373
|
+
// src/named-seat-accepted-notice.ts
|
|
9374
|
+
var text5 = (value) => typeof value === "string" ? value : "";
|
|
9375
|
+
async function notifyNamedSeatAccepted(db, env, ctx, input) {
|
|
9376
|
+
const purchaser = await membershipLoad(db, "applications", input.primaryApplicationId);
|
|
9377
|
+
if (!purchaser || !chapterBillingApplication(purchaser, ctx.chapter.id, env.ODLA_RUNTIME) || !text5(purchaser.email)) {
|
|
9378
|
+
return { sent: false, reason: "purchaser-missing" };
|
|
9379
|
+
}
|
|
9380
|
+
const group = await membershipLoad(db, "groups", ctx.chapter.id);
|
|
9381
|
+
if (!group) return { sent: false, reason: "group-missing" };
|
|
9382
|
+
const config = emailGroupFrom(group);
|
|
9383
|
+
config.emailTemplates = { seatAccepted: SEAT_ACCEPTED_TEMPLATE, ...config.emailTemplates };
|
|
9384
|
+
const purchaserName = [purchaser.firstName, purchaser.lastName].map(text5).join(" ").replace(/\s+/g, " ").trim();
|
|
9385
|
+
return sendTemplated({
|
|
9386
|
+
db,
|
|
9387
|
+
envName: env.ODLA_ENV,
|
|
9388
|
+
sender: env.SEND_EMAIL,
|
|
9389
|
+
from: env.EMAIL_FROM,
|
|
9390
|
+
debugEmail: env.ODLA_DEBUG_EMAIL,
|
|
9391
|
+
now: () => Date.now(),
|
|
9392
|
+
newId: () => crypto.randomUUID()
|
|
9393
|
+
}, {
|
|
9394
|
+
group: config,
|
|
9395
|
+
template: "seatAccepted",
|
|
9396
|
+
to: text5(purchaser.email),
|
|
9397
|
+
dedupeKey: `named-seat-accepted:${input.seatId}`,
|
|
9398
|
+
applicationId: input.primaryApplicationId,
|
|
9399
|
+
vars: {
|
|
9400
|
+
firstName: text5(purchaser.firstName),
|
|
9401
|
+
purchaserName,
|
|
9402
|
+
recipientName: input.recipientName,
|
|
9403
|
+
tierName: await tierName(db, group, input.skuId),
|
|
9404
|
+
chapterName: ctx.chapter.name,
|
|
9405
|
+
membersUrl: `${input.origin}/members/`
|
|
9406
|
+
}
|
|
9407
|
+
});
|
|
9408
|
+
}
|
|
9409
|
+
async function tierName(db, group, skuId) {
|
|
9410
|
+
try {
|
|
9411
|
+
const tier = (await loadTiers(db, group)).find((candidate) => candidate.id === skuId);
|
|
9412
|
+
if (tier) return tier.name;
|
|
9413
|
+
} catch {
|
|
9414
|
+
}
|
|
9415
|
+
return skuId;
|
|
9416
|
+
}
|
|
9417
|
+
|
|
8925
9418
|
// src/worker-routes-named-seat-recipient.ts
|
|
8926
9419
|
var handleNamedSeatRecipient = async (req, url, env, ctx) => {
|
|
8927
9420
|
if (url.pathname === "/api/named-seat/invitation") {
|
|
@@ -9039,6 +9532,13 @@ var handleNamedSeatRecipient = async (req, url, env, ctx) => {
|
|
|
9039
9532
|
const current = await membershipLoad(db, "applications", body.applicationId), group = await membershipLoad(db, "groups", ctx.chapter.id);
|
|
9040
9533
|
if (!current || current.namedSeatClaimPending !== false || !current.namedSeatAcceptedAt || !group) throw new Error("seat application reconciliation pending");
|
|
9041
9534
|
if (current.clerkUserId !== user.userId || current.groupId !== ctx.chapter.id || current.tierId !== invitation.skuId || stripeRuntimeIdentity(current.stripeRuntime) !== runtime) throw new Error("seat application identity changed");
|
|
9535
|
+
await notifyNamedSeatAccepted(db, env, ctx, {
|
|
9536
|
+
seatId: body.seatId,
|
|
9537
|
+
primaryApplicationId: invitation.primaryApplicationId,
|
|
9538
|
+
recipientName: invitation.recipientName,
|
|
9539
|
+
skuId: invitation.skuId,
|
|
9540
|
+
origin: url.origin
|
|
9541
|
+
}).catch(() => console.warn("named_seat_accepted_notice_incomplete", { seatId: body.seatId }));
|
|
9042
9542
|
await syncApplicationToCrm(crmDeps(db, ctx), { app: current, stage: String(current.status) }).catch(() => void 0);
|
|
9043
9543
|
const state = await continueJoin(db, ctx, env, url.origin, current, group);
|
|
9044
9544
|
if (!state) throw new Error("seat Journey reconciliation pending");
|
|
@@ -9399,8 +9899,8 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
|
9399
9899
|
};
|
|
9400
9900
|
|
|
9401
9901
|
// src/crm-account-repair.ts
|
|
9402
|
-
async function repairAccountPeoplePage(
|
|
9403
|
-
const users = (await
|
|
9902
|
+
async function repairAccountPeoplePage(deps, after) {
|
|
9903
|
+
const users = (await deps.db.query({ $users: { $: {
|
|
9404
9904
|
...after ? { where: { id: { $gt: after } } } : {},
|
|
9405
9905
|
order: { id: "asc" },
|
|
9406
9906
|
limit: 50
|
|
@@ -9409,7 +9909,7 @@ async function repairAccountPeoplePage(deps2, after) {
|
|
|
9409
9909
|
const errors = [];
|
|
9410
9910
|
for (const user of users) {
|
|
9411
9911
|
try {
|
|
9412
|
-
if (await syncAccountToCrm(
|
|
9912
|
+
if (await syncAccountToCrm(deps, String(user.id))) synced++;
|
|
9413
9913
|
} catch (error) {
|
|
9414
9914
|
const message = error instanceof Error ? error.message : "";
|
|
9415
9915
|
errors.push({ userId: String(user.id), code: /^crm_projection_identity_(ambiguous|archived|conflict)$/.test(message) ? message : "crm_projection_unavailable" });
|
|
@@ -9421,8 +9921,8 @@ async function repairAccountPeoplePage(deps2, after) {
|
|
|
9421
9921
|
// src/crm-application-repair.ts
|
|
9422
9922
|
import { getRecord as getRecord4, updateRecord as updateRecord5 } from "@odla-ai/crm";
|
|
9423
9923
|
async function repairApplicationLink(db, chapter, recordId, actorId) {
|
|
9424
|
-
const
|
|
9425
|
-
const record4 = await getRecord4(
|
|
9924
|
+
const deps = { crm: chapter.crm, db };
|
|
9925
|
+
const record4 = await getRecord4(deps, recordId);
|
|
9426
9926
|
if (!record4 || record4.type !== "person" || record4.archived === true) return { status: 404, error: "person_not_found" };
|
|
9427
9927
|
if (!chapter.crm.type("person").fields.applicationId) return { status: 409, error: "application_link_field_not_configured" };
|
|
9428
9928
|
const fields = record4.fields;
|
|
@@ -9440,7 +9940,7 @@ async function repairApplicationLink(db, chapter, recordId, actorId) {
|
|
|
9440
9940
|
}
|
|
9441
9941
|
const app = [...applications.values()][0];
|
|
9442
9942
|
if (!app || typeof app.id !== "string" || typeof record4.updatedAt !== "number") return { status: 409, error: "application_link_evidence_incomplete" };
|
|
9443
|
-
await updateRecord5(
|
|
9943
|
+
await updateRecord5(deps, {
|
|
9444
9944
|
id: recordId,
|
|
9445
9945
|
input: applicationCrmFields(chapter.crm, { applicationId: app.id, tierId: app.tierId }),
|
|
9446
9946
|
expectedUpdatedAt: record4.updatedAt,
|
|
@@ -10138,15 +10638,6 @@ async function loadGroup2(db, id6) {
|
|
|
10138
10638
|
return groups?.[0] ?? null;
|
|
10139
10639
|
}
|
|
10140
10640
|
var str2 = (v, d = "") => typeof v === "string" ? v : d;
|
|
10141
|
-
var notifyDeps = (db, env) => ({
|
|
10142
|
-
db,
|
|
10143
|
-
debugEmail: env.ODLA_DEBUG_EMAIL,
|
|
10144
|
-
envName: env.ODLA_ENV,
|
|
10145
|
-
sender: env.SEND_EMAIL,
|
|
10146
|
-
from: env.EMAIL_FROM,
|
|
10147
|
-
now: () => Date.now(),
|
|
10148
|
-
newId: () => crypto.randomUUID()
|
|
10149
|
-
});
|
|
10150
10641
|
var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
10151
10642
|
if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
10152
10643
|
const got = await gate5(req, env, ctx);
|
|
@@ -10165,6 +10656,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
10165
10656
|
groupId: group.id,
|
|
10166
10657
|
name: group.name,
|
|
10167
10658
|
replyTo: str2(group.replyTo),
|
|
10659
|
+
fromName: str2(group.fromName),
|
|
10168
10660
|
notificationEmail: str2(group.notificationEmail),
|
|
10169
10661
|
debugEmail: str2(group.debugEmail),
|
|
10170
10662
|
emailTemplates,
|
|
@@ -10191,11 +10683,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
10191
10683
|
for (const key3 of EMAIL_TEMPLATE_NAMES) {
|
|
10192
10684
|
const t = templates[key3];
|
|
10193
10685
|
const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
|
|
10194
|
-
const
|
|
10195
|
-
if (!subject || !
|
|
10686
|
+
const text8 = typeof t?.text === "string" ? t.text : "";
|
|
10687
|
+
if (!subject || !text8.trim()) return json({ error: `template "${key3}" needs a subject and a body` }, 400);
|
|
10196
10688
|
if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key3}" subject must be a single line under 200 characters` }, 400);
|
|
10197
|
-
if (
|
|
10198
|
-
clean[key3] = { subject, text:
|
|
10689
|
+
if (text8.length > 1e4) return json({ error: `template "${key3}" body is too long` }, 400);
|
|
10690
|
+
clean[key3] = { subject, text: text8, enabled: t?.enabled !== false };
|
|
10199
10691
|
}
|
|
10200
10692
|
const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
10201
10693
|
const notificationEmail = str2(body.notificationEmail).trim();
|
|
@@ -10204,13 +10696,15 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
|
|
|
10204
10696
|
if (!emailish.test(notificationEmail)) return json({ error: "notification address must be a valid email" }, 400);
|
|
10205
10697
|
if (!emailish.test(replyTo)) return json({ error: "reply-to address must be a valid email" }, 400);
|
|
10206
10698
|
if (debugEmail && !emailish.test(debugEmail)) return json({ error: "debug address must be a valid email" }, 400);
|
|
10699
|
+
const fromName = str2(body.fromName).trim();
|
|
10700
|
+
if (/[\r\n<>]/.test(fromName) || fromName.length > 100) return json({ error: "from name must be one line of at most 100 characters with no angle brackets" }, 400);
|
|
10207
10701
|
const commitmentText = str2(body.commitmentText);
|
|
10208
10702
|
const normsText = str2(body.normsText);
|
|
10209
10703
|
if (commitmentText.length > 5e3 || normsText.length > 5e3) return json({ error: "commitment/norms text is too long" }, 400);
|
|
10210
10704
|
const id6 = { ns: "groups", attr: "id", value: ctx.chapter.id };
|
|
10211
10705
|
await db.transact([
|
|
10212
10706
|
{ t: "merge", ns: "groups", id: id6, attrs: { emailTemplates: clean } },
|
|
10213
|
-
{ t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail } }
|
|
10707
|
+
{ t: "update", ns: "groups", id: id6, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
|
|
10214
10708
|
]);
|
|
10215
10709
|
return json({ ok: true });
|
|
10216
10710
|
};
|
|
@@ -10259,11 +10753,11 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
|
|
|
10259
10753
|
}
|
|
10260
10754
|
const group = await loadGroup2(db, ctx.chapter.id);
|
|
10261
10755
|
if (!group) return json({ error: "not found" }, 404);
|
|
10262
|
-
const res = await sendTemplated(
|
|
10756
|
+
const res = await sendTemplated(lifecycleNotifyDeps(db, env), {
|
|
10263
10757
|
group: emailGroupFrom(group),
|
|
10264
10758
|
template,
|
|
10265
10759
|
to: str2(group.notificationEmail),
|
|
10266
|
-
vars:
|
|
10760
|
+
vars: sampleVariables(url.origin, resolveScheduling(group.schedulingJson).timezone),
|
|
10267
10761
|
dedupeKey: `test:${template}:${Date.now()}`,
|
|
10268
10762
|
force: true
|
|
10269
10763
|
});
|
|
@@ -10290,7 +10784,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
10290
10784
|
const app = data.applications?.[0] ?? null;
|
|
10291
10785
|
if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
|
|
10292
10786
|
const group = await loadGroup2(db, str2(app?.groupId, ctx.chapter.id));
|
|
10293
|
-
const vars = app ? { firstName: str2(app.firstName), lastName: str2(app.lastName), email: str2(app.email), phone: str2(app.phone), state: str2(app.state), adminUrl:
|
|
10787
|
+
const vars = app ? { firstName: str2(app.firstName), lastName: str2(app.lastName), email: str2(app.email), phone: str2(app.phone), state: str2(app.state), adminUrl: adminUrlFor(url.origin), membersUrl: membersUrlFor(url.origin) } : null;
|
|
10294
10788
|
const emails = (data.emailLog ?? []).filter((r) => r.template !== "adminNotification").map((r) => {
|
|
10295
10789
|
let mailBody = typeof r.body === "string" ? r.body : null;
|
|
10296
10790
|
if (!mailBody && group && vars) mailBody = renderTemplateBody(emailGroupFrom(group), String(r.template), vars);
|
|
@@ -10314,6 +10808,28 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
10314
10808
|
import { getRecord as getRecord5, typeSummary } from "@odla-ai/crm";
|
|
10315
10809
|
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
|
|
10316
10810
|
|
|
10811
|
+
// src/network-edge.ts
|
|
10812
|
+
var EDGE_UNPAIRED = "edge_unpaired";
|
|
10813
|
+
function pairingCommand(leaderId, targetId, env) {
|
|
10814
|
+
const scope2 = env ? ` --env ${env}` : "";
|
|
10815
|
+
return `odla-ai chapter edge pair --leader ${leaderId} --follower ${targetId}${scope2}`;
|
|
10816
|
+
}
|
|
10817
|
+
function edgeUnpaired(leaderId, target, env) {
|
|
10818
|
+
return {
|
|
10819
|
+
code: EDGE_UNPAIRED,
|
|
10820
|
+
target: target.id,
|
|
10821
|
+
secretName: target.secretName,
|
|
10822
|
+
message: `edge to "${target.id}" is unpaired: leader vault slot "${target.secretName}" is empty`,
|
|
10823
|
+
hint: pairingCommand(leaderId, target.id, env)
|
|
10824
|
+
};
|
|
10825
|
+
}
|
|
10826
|
+
function unpairedResponse(unpaired) {
|
|
10827
|
+
return new Response(JSON.stringify({ error: unpaired.message, ...unpaired }), {
|
|
10828
|
+
status: 502,
|
|
10829
|
+
headers: { "content-type": "application/json" }
|
|
10830
|
+
});
|
|
10831
|
+
}
|
|
10832
|
+
|
|
10317
10833
|
// src/worker-network-push.ts
|
|
10318
10834
|
import { recordDeliveryAttempt } from "@odla-ai/crm";
|
|
10319
10835
|
import { signFederatedRequest as signFederatedRequest3 } from "@odla-ai/db";
|
|
@@ -10326,15 +10842,15 @@ async function pushNetworkRecord(db, env, ctx, target, record4) {
|
|
|
10326
10842
|
newId: () => crypto.randomUUID()
|
|
10327
10843
|
};
|
|
10328
10844
|
if (!secret) {
|
|
10329
|
-
const
|
|
10845
|
+
const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
|
|
10330
10846
|
await recordDeliveryAttempt(crmDeps3, {
|
|
10331
10847
|
recordId: record4.id,
|
|
10332
10848
|
targetId: target.id,
|
|
10333
10849
|
status: "failed",
|
|
10334
10850
|
payloadVersion: 2,
|
|
10335
|
-
error
|
|
10851
|
+
error: unpaired.message
|
|
10336
10852
|
});
|
|
10337
|
-
return { id: target.id, name: target.name, ok: false, error };
|
|
10853
|
+
return { id: target.id, name: target.name, ok: false, error: unpaired.message, code: unpaired.code, hint: unpaired.hint };
|
|
10338
10854
|
}
|
|
10339
10855
|
let payload;
|
|
10340
10856
|
try {
|
|
@@ -10475,17 +10991,20 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
10475
10991
|
publicSite: { available: null }
|
|
10476
10992
|
};
|
|
10477
10993
|
const secret = await getVaultSecret(db, target.secretName);
|
|
10478
|
-
if (!secret)
|
|
10479
|
-
|
|
10480
|
-
|
|
10481
|
-
|
|
10482
|
-
|
|
10483
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
10487
|
-
|
|
10488
|
-
|
|
10994
|
+
if (!secret) {
|
|
10995
|
+
const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
|
|
10996
|
+
return {
|
|
10997
|
+
...base,
|
|
10998
|
+
available: false,
|
|
10999
|
+
transport: {
|
|
11000
|
+
kind: transportKind,
|
|
11001
|
+
...target.binding ? { binding: target.binding } : {},
|
|
11002
|
+
reachable: null,
|
|
11003
|
+
error: unpaired
|
|
11004
|
+
},
|
|
11005
|
+
error: unpaired.message
|
|
11006
|
+
};
|
|
11007
|
+
}
|
|
10489
11008
|
const destination = new URL("/api/network/snapshot", target.url);
|
|
10490
11009
|
try {
|
|
10491
11010
|
const transport = resolveNetworkTargetTransport(env, target);
|
|
@@ -10965,7 +11484,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
10965
11484
|
if (hasFields && Object.keys(fields).some((name) => !capabilities.edit?.fields.some((field) => field.id === name))) {
|
|
10966
11485
|
return json({ error: "use the verified identity or application workflow for these fields", code: "field_forbidden" }, 403);
|
|
10967
11486
|
}
|
|
10968
|
-
const
|
|
11487
|
+
const deps = { crm: ctx.chapter.crm, db };
|
|
10969
11488
|
if (hasFields && ctx.chapter.mode === "chapter" && record4.type === "person") {
|
|
10970
11489
|
const updated2 = await updateChapterPerson({
|
|
10971
11490
|
db,
|
|
@@ -10984,7 +11503,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
10984
11503
|
record: await operationalReaderRecord(updated2.record, reader, db, env, ctx)
|
|
10985
11504
|
});
|
|
10986
11505
|
}
|
|
10987
|
-
const updated = hasFields ? await updateRecord6(
|
|
11506
|
+
const updated = hasFields ? await updateRecord6(deps, {
|
|
10988
11507
|
id: record4.id,
|
|
10989
11508
|
input: fields,
|
|
10990
11509
|
mutationId: activityId,
|
|
@@ -10995,7 +11514,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
10995
11514
|
authorId: `network:${verified.sender}`,
|
|
10996
11515
|
meta: auditMeta
|
|
10997
11516
|
}
|
|
10998
|
-
}) : await setStage3(
|
|
11517
|
+
}) : await setStage3(deps, {
|
|
10999
11518
|
id: record4.id,
|
|
11000
11519
|
to: input.stage,
|
|
11001
11520
|
authorId: `network:${verified.sender}`,
|
|
@@ -11095,7 +11614,10 @@ function targetFor2(ctx, id6) {
|
|
|
11095
11614
|
}
|
|
11096
11615
|
async function readTargetRecords(db, env, ctx, target, query) {
|
|
11097
11616
|
const secret = await getVaultSecret(db, target.secretName);
|
|
11098
|
-
if (!secret)
|
|
11617
|
+
if (!secret) {
|
|
11618
|
+
const unpaired = edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV);
|
|
11619
|
+
return { ok: false, error: unpaired.message, unpaired };
|
|
11620
|
+
}
|
|
11099
11621
|
const destination = new URL("/api/network/records", target.url);
|
|
11100
11622
|
destination.search = query.toString();
|
|
11101
11623
|
try {
|
|
@@ -11132,7 +11654,7 @@ async function mutateTargetRecord(db, env, ctx, target, input) {
|
|
|
11132
11654
|
return json({ error: "stage transitions are not enabled for this target", code: "stage_forbidden" }, 403);
|
|
11133
11655
|
}
|
|
11134
11656
|
const secret = await getVaultSecret(db, target.secretName);
|
|
11135
|
-
if (!secret) return
|
|
11657
|
+
if (!secret) return unpairedResponse(edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV));
|
|
11136
11658
|
const destination = new URL("/api/network/records", target.url);
|
|
11137
11659
|
const body = JSON.stringify(input);
|
|
11138
11660
|
try {
|
|
@@ -11214,7 +11736,9 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
11214
11736
|
if (window.search) query.set("search", window.search);
|
|
11215
11737
|
if (window.stage) query.set("stage", window.stage);
|
|
11216
11738
|
const result = await readTargetRecords(got.db, env, ctx, target, query);
|
|
11217
|
-
if (!result.ok)
|
|
11739
|
+
if (!result.ok) {
|
|
11740
|
+
return result.unpaired ? unpairedResponse(result.unpaired) : json({ error: result.error }, result.status ?? 502);
|
|
11741
|
+
}
|
|
11218
11742
|
if ((window.stage || url.searchParams.has("sort")) && (!result.page.appliedQuery || result.page.appliedQuery.sort !== window.sortId || result.page.appliedQuery.stage !== window.stage)) {
|
|
11219
11743
|
return json({ error: "follower did not apply the requested record query" }, 502);
|
|
11220
11744
|
}
|
|
@@ -11470,7 +11994,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
11470
11994
|
body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
|
|
11471
11995
|
}
|
|
11472
11996
|
const secret = await getVaultSecret(got.db, target.secretName);
|
|
11473
|
-
if (!secret) return
|
|
11997
|
+
if (!secret) return unpairedResponse(edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV));
|
|
11474
11998
|
const destination = new URL("/api/network/shared-notes", target.url);
|
|
11475
11999
|
destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
|
|
11476
12000
|
try {
|
|
@@ -11524,11 +12048,11 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
|
|
|
11524
12048
|
if (raw2.approved !== true || raw2.isRealPerson !== true) errors.push("import must be approved and identify a real person");
|
|
11525
12049
|
const sourceSystem = typeof raw2.sourceSystem === "string" ? raw2.sourceSystem.trim().toLowerCase() : "";
|
|
11526
12050
|
const sourceRecordId = typeof raw2.sourceRecordId === "string" ? raw2.sourceRecordId.trim() : "";
|
|
11527
|
-
const
|
|
12051
|
+
const provenance2 = typeof raw2.provenance === "string" ? raw2.provenance.trim() : "";
|
|
11528
12052
|
const displayName = typeof raw2.displayName === "string" ? raw2.displayName.trim() : "";
|
|
11529
12053
|
if (!TOKEN.test(sourceSystem) || /(?:^|[._:-])(fixture|test|development|dev)(?:$|[._:-])/.test(sourceSystem)) errors.push("sourceSystem must name an approved non-development external system");
|
|
11530
12054
|
if (!sourceRecordId || sourceRecordId.length > 200) errors.push("sourceRecordId is required and bounded");
|
|
11531
|
-
if (!
|
|
12055
|
+
if (!provenance2 || provenance2.length > 500) errors.push("provenance is required and bounded");
|
|
11532
12056
|
if (!displayName || displayName.length > 200) errors.push("displayName is required and bounded");
|
|
11533
12057
|
if (FIXTURE_MARKER.test(sourceRecordId) || FIXTURE_MARKER.test(displayName)) {
|
|
11534
12058
|
errors.push("fixture, test, sample, and dummy rows cannot become network Persons");
|
|
@@ -11596,7 +12120,7 @@ function validateNetworkPersonImport(input, allowedChapterIds) {
|
|
|
11596
12120
|
sourceKind: "external_reimport",
|
|
11597
12121
|
sourceSystem,
|
|
11598
12122
|
sourceRecordId,
|
|
11599
|
-
provenance,
|
|
12123
|
+
provenance: provenance2,
|
|
11600
12124
|
approved: true,
|
|
11601
12125
|
isRealPerson: true,
|
|
11602
12126
|
displayName,
|
|
@@ -12301,7 +12825,7 @@ async function revealNetworkPersonRegistryRecord(db, input, mutationId, actor, n
|
|
|
12301
12825
|
}
|
|
12302
12826
|
|
|
12303
12827
|
// src/worker-routes-network-person-registry.ts
|
|
12304
|
-
var
|
|
12828
|
+
var PATH5 = "/api/admin/network-person-registry";
|
|
12305
12829
|
var MAX_BODY2 = 8 * 1024 * 1024;
|
|
12306
12830
|
var MUTATION_ID4 = /^[A-Za-z0-9_-]{16,128}$/;
|
|
12307
12831
|
var positiveRevision = (value) => {
|
|
@@ -12326,7 +12850,7 @@ function registryError(error) {
|
|
|
12326
12850
|
return null;
|
|
12327
12851
|
}
|
|
12328
12852
|
var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
|
|
12329
|
-
if (url.pathname !==
|
|
12853
|
+
if (url.pathname !== PATH5) return null;
|
|
12330
12854
|
if (!ctx.chapter.networkPersonRegistry) return json({ error: "not found" }, 404);
|
|
12331
12855
|
if (req.method !== "GET" && req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
12332
12856
|
if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_BODY2) {
|
|
@@ -12338,9 +12862,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
|
|
|
12338
12862
|
if (req.method === "POST") {
|
|
12339
12863
|
let body;
|
|
12340
12864
|
try {
|
|
12341
|
-
const
|
|
12342
|
-
if (new TextEncoder().encode(
|
|
12343
|
-
const parsed = JSON.parse(
|
|
12865
|
+
const text8 = await req.text();
|
|
12866
|
+
if (new TextEncoder().encode(text8).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
|
|
12867
|
+
const parsed = JSON.parse(text8);
|
|
12344
12868
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
|
|
12345
12869
|
body = parsed;
|
|
12346
12870
|
} catch {
|
|
@@ -12509,7 +13033,7 @@ import { signFederatedRequest as signFederatedRequest7, verifyFederatedRequest a
|
|
|
12509
13033
|
// src/network-commercial-parity.ts
|
|
12510
13034
|
var DIGEST = /^[0-9a-f]{64}$/;
|
|
12511
13035
|
var MAX_RECORDS = 500;
|
|
12512
|
-
var
|
|
13036
|
+
var text6 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
|
|
12513
13037
|
function parityMessage(partition, kind, value) {
|
|
12514
13038
|
return [
|
|
12515
13039
|
"chapter-commercial-parity",
|
|
@@ -12541,18 +13065,18 @@ async function digestWithKey(key3, partition, kind, value) {
|
|
|
12541
13065
|
async function buildChapterCommercialParitySnapshot(input) {
|
|
12542
13066
|
const requestedMax = input.maxRecords ?? MAX_RECORDS;
|
|
12543
13067
|
const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
|
|
12544
|
-
const eligible = input.rows.filter((row2) =>
|
|
13068
|
+
const eligible = input.rows.filter((row2) => text6(row2.id) && (text6(row2.stripeCustomerId) || text6(row2.stripeSubscriptionId)));
|
|
12545
13069
|
const selected2 = eligible.slice(0, maxRecords);
|
|
12546
13070
|
const key3 = await parityKey(input.secret);
|
|
12547
13071
|
const records = await Promise.all(selected2.map(async (row2) => {
|
|
12548
|
-
const applicationId =
|
|
12549
|
-
const customerId =
|
|
12550
|
-
const subscriptionId =
|
|
13072
|
+
const applicationId = text6(row2.id);
|
|
13073
|
+
const customerId = text6(row2.stripeCustomerId);
|
|
13074
|
+
const subscriptionId = text6(row2.stripeSubscriptionId);
|
|
12551
13075
|
return {
|
|
12552
13076
|
applicationDigest: await digestWithKey(key3, input.partition, "application", applicationId),
|
|
12553
13077
|
customerDigest: customerId ? await digestWithKey(key3, input.partition, "customer", customerId) : null,
|
|
12554
13078
|
subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.partition, "subscription", subscriptionId) : null,
|
|
12555
|
-
status:
|
|
13079
|
+
status: text6(row2.status, 64),
|
|
12556
13080
|
canceled: row2.canceled === true
|
|
12557
13081
|
};
|
|
12558
13082
|
}));
|
|
@@ -12578,7 +13102,7 @@ function validSnapshot(snapshot, source) {
|
|
|
12578
13102
|
if (snapshot?.version !== 1 || snapshot.site?.id !== source.chapterId || !partition || !Array.isArray(records) || records.length > MAX_RECORDS || partition.appId !== source.appId || partition.chapterId !== source.chapterId || partition.environment !== source.environment || partition.dataEnvironment !== source.dataEnvironment || partition.runtime !== source.runtime || typeof snapshot.complete !== "boolean" || typeof snapshot.truncated !== "boolean" || !snapshot.counts) return false;
|
|
12579
13103
|
const seen = /* @__PURE__ */ new Set();
|
|
12580
13104
|
for (const record4 of records) {
|
|
12581
|
-
if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null &&
|
|
13105
|
+
if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null && text6(record4.status, 64) === null || typeof record4.canceled !== "boolean") return false;
|
|
12582
13106
|
const key3 = recordKey(record4);
|
|
12583
13107
|
if (seen.has(key3)) return false;
|
|
12584
13108
|
seen.add(key3);
|
|
@@ -12589,16 +13113,16 @@ async function reconcileCommercialParitySnapshot(input) {
|
|
|
12589
13113
|
if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
|
|
12590
13114
|
const sourceKey = networkCommercialSourceKey(input.source);
|
|
12591
13115
|
const key3 = await parityKey(input.secret);
|
|
12592
|
-
const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey &&
|
|
12593
|
-
const customerId =
|
|
12594
|
-
const subscriptionId =
|
|
13116
|
+
const shadow = await Promise.all(input.projection.subscriptions.filter((row2) => row2.sourceKey === sourceKey && text6(row2.applicationId) && text6(row2.objectId)).map(async (row2) => {
|
|
13117
|
+
const customerId = text6(row2.customerId);
|
|
13118
|
+
const subscriptionId = text6(row2.subscriptionId) ?? text6(row2.objectId);
|
|
12595
13119
|
return {
|
|
12596
13120
|
applicationDigest: await digestWithKey(key3, input.snapshot.partition, "application", row2.applicationId),
|
|
12597
13121
|
customerDigest: customerId ? await digestWithKey(key3, input.snapshot.partition, "customer", customerId) : null,
|
|
12598
13122
|
subscriptionDigest: subscriptionId ? await digestWithKey(key3, input.snapshot.partition, "subscription", subscriptionId) : null,
|
|
12599
13123
|
status: null,
|
|
12600
13124
|
canceled: false,
|
|
12601
|
-
stripeStatus:
|
|
13125
|
+
stripeStatus: text6(row2.status, 64),
|
|
12602
13126
|
stripeCanceled: row2.status === "canceled"
|
|
12603
13127
|
};
|
|
12604
13128
|
}));
|
|
@@ -12724,7 +13248,7 @@ var handleNetworkCommercialParity = async (req, url, env, ctx) => {
|
|
|
12724
13248
|
async function reconcileTarget(db, env, ctx, target, projection) {
|
|
12725
13249
|
const base = { targetId: target.id, name: target.name, url: target.url };
|
|
12726
13250
|
const secret = await getVaultSecret(db, target.secretName);
|
|
12727
|
-
if (!secret) return { ...base, available: false, ok: false, error:
|
|
13251
|
+
if (!secret) return { ...base, available: false, ok: false, error: edgeUnpaired(ctx.chapter.id, target, env.ODLA_ENV) };
|
|
12728
13252
|
const destination = new URL(PARITY_PATH, target.url);
|
|
12729
13253
|
try {
|
|
12730
13254
|
const transport = resolveNetworkTargetTransport(env, target);
|
|
@@ -12880,11 +13404,23 @@ var handleNetworkCommercialStripeWebhook = async (req, url, env, ctx) => {
|
|
|
12880
13404
|
const event = await constructStripeEvent(body, signature, secret);
|
|
12881
13405
|
const fact = normalizeNetworkCommercialStripeEvent(event, await stripeEventDigest(body));
|
|
12882
13406
|
if (!fact.eventId || !fact.eventType || !fact.objectId) return json({ error: "Stripe event identity is invalid" }, 400);
|
|
12883
|
-
const result = await ingestNetworkCommercialFact(
|
|
13407
|
+
const result = await ingestNetworkCommercialFact(
|
|
13408
|
+
db,
|
|
13409
|
+
ctx.chapter.networkCommercialReadModel.sources,
|
|
13410
|
+
fact,
|
|
13411
|
+
Date.now(),
|
|
13412
|
+
await stripePayloadDigest(body),
|
|
13413
|
+
networkCommercialAlerts(ctx, WEBHOOK_PATH)
|
|
13414
|
+
);
|
|
12884
13415
|
if (result.status === "collision") {
|
|
12885
13416
|
console.error("network commercial event collision", JSON.stringify({ eventId: fact.eventId, eventType: fact.eventType }));
|
|
12886
13417
|
}
|
|
12887
|
-
return json({
|
|
13418
|
+
return json({
|
|
13419
|
+
ok: true,
|
|
13420
|
+
status: result.status,
|
|
13421
|
+
eventId: fact.eventId,
|
|
13422
|
+
...result.status === "replayed" && result.resolvedCollision ? { resolvedCollision: true } : {}
|
|
13423
|
+
});
|
|
12888
13424
|
} catch (error) {
|
|
12889
13425
|
if (error instanceof StripeError) return json({ error: error.message, code: error.code }, 400);
|
|
12890
13426
|
throw error;
|
|
@@ -12908,18 +13444,39 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
|
|
|
12908
13444
|
} catch {
|
|
12909
13445
|
return json({ error: "invalid JSON body" }, 400);
|
|
12910
13446
|
}
|
|
13447
|
+
const alerts = networkCommercialAlerts(ctx, ADMIN_PATH2);
|
|
12911
13448
|
if (parsed.command === "recover-event") {
|
|
12912
13449
|
try {
|
|
12913
13450
|
return json(await recoverNetworkCommercialEvent(
|
|
12914
13451
|
db,
|
|
12915
13452
|
ctx.chapter.networkCommercialReadModel.sources,
|
|
12916
13453
|
String(parsed.eventId ?? ""),
|
|
12917
|
-
got.user.userId
|
|
13454
|
+
got.user.userId,
|
|
13455
|
+
Date.now(),
|
|
13456
|
+
alerts
|
|
12918
13457
|
));
|
|
12919
13458
|
} catch (error) {
|
|
12920
13459
|
return json({ error: error instanceof Error ? error.message : "event recovery failed" }, 409);
|
|
12921
13460
|
}
|
|
12922
13461
|
}
|
|
13462
|
+
if (parsed.command === "resolve") {
|
|
13463
|
+
if (typeof parsed.mutationId !== "string" || !MUTATION_ID5.test(parsed.mutationId)) return json({ error: "resolve requires a valid mutationId" }, 400);
|
|
13464
|
+
const reason = typeof parsed.reason === "string" ? parsed.reason.trim() : "";
|
|
13465
|
+
if (!reason || reason.length > 500) return json({ error: "resolve requires a reason of at most 500 characters" }, 400);
|
|
13466
|
+
try {
|
|
13467
|
+
const result = await resolveNetworkCommercialCollision(
|
|
13468
|
+
db,
|
|
13469
|
+
ctx.chapter.networkCommercialReadModel.sources,
|
|
13470
|
+
{ eventId: String(parsed.eventId ?? ""), mutationId: parsed.mutationId, actorUserId: got.user.userId, reason },
|
|
13471
|
+
Date.now(),
|
|
13472
|
+
alerts
|
|
13473
|
+
);
|
|
13474
|
+
return json({ resolved: true, eventId: result.eventId, reviewId: result.reviewId, ...safeProjection(result.projection) });
|
|
13475
|
+
} catch (error) {
|
|
13476
|
+
const code = error.code;
|
|
13477
|
+
return json({ error: error instanceof Error ? error.message : "resolve failed", code }, code === "invalid_event" ? 400 : 409);
|
|
13478
|
+
}
|
|
13479
|
+
}
|
|
12923
13480
|
if (parsed.command === "reconcile-chapters") {
|
|
12924
13481
|
const projection2 = await loadNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources);
|
|
12925
13482
|
return json(await reconcileChapterCommercialTargets(db, env, ctx, projection2));
|
|
@@ -12947,9 +13504,9 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
|
|
|
12947
13504
|
}
|
|
12948
13505
|
}
|
|
12949
13506
|
if (parsed.command !== "rebuild" || typeof parsed.mutationId !== "string" || !MUTATION_ID5.test(parsed.mutationId)) {
|
|
12950
|
-
return json({ error: "rebuild, reconcile-stripe, or reconcile-chapters command is required; rebuild also
|
|
13507
|
+
return json({ error: "rebuild, resolve, recover-event, reconcile-stripe, or reconcile-chapters command is required; rebuild and resolve also require a valid mutationId" }, 400);
|
|
12951
13508
|
}
|
|
12952
|
-
const projection = await rebuildNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources, parsed.mutationId);
|
|
13509
|
+
const projection = await rebuildNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources, parsed.mutationId, Date.now(), alerts);
|
|
12953
13510
|
return json({ rebuilt: true, ...safeProjection(projection) });
|
|
12954
13511
|
}
|
|
12955
13512
|
return json(safeProjection(await loadNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources)));
|
|
@@ -13059,7 +13616,7 @@ var record3 = (value) => value !== null && typeof value === "object" && !Array.i
|
|
|
13059
13616
|
function unknownKeys3(value, allowed2, path, errors) {
|
|
13060
13617
|
for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
|
|
13061
13618
|
}
|
|
13062
|
-
function
|
|
13619
|
+
function text7(value, path, errors, max = MAX_TEXT2, required = true) {
|
|
13063
13620
|
if (value === void 0 && !required) return void 0;
|
|
13064
13621
|
if (typeof value !== "string" || required && !value.trim()) {
|
|
13065
13622
|
errors.push(`${path} must be a non-empty string`);
|
|
@@ -13085,7 +13642,7 @@ function scopeValue(value, path, errors) {
|
|
|
13085
13642
|
return { kind: raw2.kind };
|
|
13086
13643
|
}
|
|
13087
13644
|
if (raw2.kind === "chapter") {
|
|
13088
|
-
const chapterId =
|
|
13645
|
+
const chapterId = text7(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
|
|
13089
13646
|
if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
|
|
13090
13647
|
return { kind: "chapter", chapterId };
|
|
13091
13648
|
}
|
|
@@ -13099,22 +13656,22 @@ function resourceValue(value, path, errors) {
|
|
|
13099
13656
|
return null;
|
|
13100
13657
|
}
|
|
13101
13658
|
unknownKeys3(raw2, RESOURCE_KEYS, path, errors);
|
|
13102
|
-
const id6 =
|
|
13659
|
+
const id6 = text7(raw2.id, `${path}.id`, errors, 80) ?? "";
|
|
13103
13660
|
if (id6 && !ID4.test(id6)) errors.push(`${path}.id must be lowercase kebab-case`);
|
|
13104
|
-
const kind =
|
|
13661
|
+
const kind = text7(raw2.kind, `${path}.kind`, errors) ?? "";
|
|
13105
13662
|
if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
|
|
13106
|
-
const title =
|
|
13107
|
-
const summary =
|
|
13108
|
-
const status2 =
|
|
13663
|
+
const title = text7(raw2.title, `${path}.title`, errors, 240) ?? "";
|
|
13664
|
+
const summary = text7(raw2.summary, `${path}.summary`, errors, 1e3, false);
|
|
13665
|
+
const status2 = text7(raw2.status, `${path}.status`, errors) ?? "";
|
|
13109
13666
|
if (!STATUSES.has(status2)) errors.push(`${path}.status must be draft, published, or archived`);
|
|
13110
13667
|
const revision = integer4(raw2.revision, `${path}.revision`, errors);
|
|
13111
13668
|
const scope2 = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
|
|
13112
|
-
const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) =>
|
|
13669
|
+
const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text7(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
|
|
13113
13670
|
if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
|
|
13114
13671
|
if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
|
|
13115
13672
|
if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
|
|
13116
|
-
const bodyMarkdown =
|
|
13117
|
-
const url =
|
|
13673
|
+
const bodyMarkdown = text7(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
|
|
13674
|
+
const url = text7(raw2.url, `${path}.url`, errors, 2048, false);
|
|
13118
13675
|
if (url) {
|
|
13119
13676
|
try {
|
|
13120
13677
|
if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
|
|
@@ -13126,9 +13683,9 @@ function resourceValue(value, path, errors) {
|
|
|
13126
13683
|
if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
|
|
13127
13684
|
if (asset) unknownKeys3(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
|
|
13128
13685
|
const brandAsset = asset ? {
|
|
13129
|
-
bookId:
|
|
13130
|
-
assetId:
|
|
13131
|
-
...asset.filename === void 0 ? {} : { filename:
|
|
13686
|
+
bookId: text7(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
|
|
13687
|
+
assetId: text7(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
|
|
13688
|
+
...asset.filename === void 0 ? {} : { filename: text7(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
|
|
13132
13689
|
} : void 0;
|
|
13133
13690
|
if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
|
|
13134
13691
|
if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
|
|
@@ -13143,21 +13700,21 @@ function resourceValue(value, path, errors) {
|
|
|
13143
13700
|
if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
|
|
13144
13701
|
if (promotedRaw) unknownKeys3(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
|
|
13145
13702
|
const promotedFrom = promotedRaw ? {
|
|
13146
|
-
chapterId:
|
|
13147
|
-
resourceId:
|
|
13703
|
+
chapterId: text7(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
|
|
13704
|
+
resourceId: text7(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
|
|
13148
13705
|
resourceRevision: integer4(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
|
|
13149
13706
|
documentRevision: integer4(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
|
|
13150
|
-
promotedBy:
|
|
13707
|
+
promotedBy: text7(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
|
|
13151
13708
|
promotedAt: integer4(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
|
|
13152
13709
|
} : void 0;
|
|
13153
|
-
const
|
|
13154
|
-
createdBy:
|
|
13710
|
+
const provenance2 = {
|
|
13711
|
+
createdBy: text7(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
|
|
13155
13712
|
createdAt: integer4(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
|
|
13156
|
-
updatedBy:
|
|
13713
|
+
updatedBy: text7(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
|
|
13157
13714
|
updatedAt: integer4(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
|
|
13158
13715
|
...promotedFrom ? { promotedFrom } : {}
|
|
13159
13716
|
};
|
|
13160
|
-
return { id: id6, kind, title, ...summary ? { summary } : {}, tags, scope: scope2, status: status2, revision, ...bodyMarkdown ? { bodyMarkdown } : {}, ...url ? { url } : {}, ...brandAsset ? { brandAsset } : {}, provenance };
|
|
13717
|
+
return { id: id6, kind, title, ...summary ? { summary } : {}, tags, scope: scope2, status: status2, revision, ...bodyMarkdown ? { bodyMarkdown } : {}, ...url ? { url } : {}, ...brandAsset ? { brandAsset } : {}, provenance: provenance2 };
|
|
13161
13718
|
}
|
|
13162
13719
|
function validateBuilderLibraryDocument(value, options = {}) {
|
|
13163
13720
|
const errors = [];
|
|
@@ -13172,7 +13729,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
|
|
|
13172
13729
|
if (sourceRaw) unknownKeys3(sourceRaw, SOURCE_KEYS2, "document.source", errors);
|
|
13173
13730
|
const sourceType = sourceRaw?.type;
|
|
13174
13731
|
if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
|
|
13175
|
-
const sourceAppId =
|
|
13732
|
+
const sourceAppId = text7(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
|
|
13176
13733
|
if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
13177
13734
|
if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
|
|
13178
13735
|
if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
|
|
@@ -13180,8 +13737,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
|
|
|
13180
13737
|
if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
|
|
13181
13738
|
if (targetRaw) unknownKeys3(targetRaw, TARGET_KEYS2, "document.target", errors);
|
|
13182
13739
|
const target = targetRaw ? {
|
|
13183
|
-
appId:
|
|
13184
|
-
environment:
|
|
13740
|
+
appId: text7(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
|
|
13741
|
+
environment: text7(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
|
|
13185
13742
|
} : void 0;
|
|
13186
13743
|
if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
|
|
13187
13744
|
if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
|
|
@@ -15193,7 +15750,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
|
|
|
15193
15750
|
};
|
|
15194
15751
|
signal.addEventListener("abort", cancel, { once: true });
|
|
15195
15752
|
let size = 0;
|
|
15196
|
-
let
|
|
15753
|
+
let text8 = "";
|
|
15197
15754
|
const decoder = new TextDecoder();
|
|
15198
15755
|
try {
|
|
15199
15756
|
while (true) {
|
|
@@ -15203,10 +15760,10 @@ async function readBoundedJson(response, signal, maxBytes, label) {
|
|
|
15203
15760
|
if (done) break;
|
|
15204
15761
|
size += value.byteLength;
|
|
15205
15762
|
if (size > maxBytes) throw new TypeError(`${label} exceeds limit`);
|
|
15206
|
-
|
|
15763
|
+
text8 += decoder.decode(value, { stream: true });
|
|
15207
15764
|
}
|
|
15208
15765
|
try {
|
|
15209
|
-
return JSON.parse(
|
|
15766
|
+
return JSON.parse(text8 + decoder.decode());
|
|
15210
15767
|
} catch {
|
|
15211
15768
|
throw new TypeError(`invalid ${label}`);
|
|
15212
15769
|
}
|
|
@@ -15868,6 +16425,7 @@ var BUILTIN_ROUTES = [
|
|
|
15868
16425
|
handlePaymentRecovery,
|
|
15869
16426
|
handleAdminNamedSeat,
|
|
15870
16427
|
handleAdminMembershipSeats,
|
|
16428
|
+
handleAdminMembershipPolicyVersions,
|
|
15871
16429
|
handleAdminMeetings,
|
|
15872
16430
|
handleAdminScheduling,
|
|
15873
16431
|
// Roster + identity
|