@odla-ai/chapter 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -28
- package/dist/index.cjs +169 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +136 -8
- package/dist/index.d.ts +136 -8
- package/dist/index.js +169 -14
- package/dist/index.js.map +1 -1
- package/dist/ui/index.d.ts +54 -3
- package/dist/ui/index.js +188 -93
- package/dist/ui/index.js.map +1 -1
- package/dist/worker/index.cjs +344 -17
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +60 -6
- package/dist/worker/index.d.ts +60 -6
- package/dist/worker/index.js +344 -17
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
package/dist/worker/index.d.cts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CrmConfig, Crm } from '@odla-ai/crm';
|
|
2
|
+
import { initAdmin } from '@odla-ai/db';
|
|
2
3
|
|
|
3
4
|
/** Which feature profile a site runs. `chapter` is the full public member site
|
|
4
5
|
* (join, Stripe membership, booking, member area, admin, CRM); `hub` is
|
|
@@ -246,15 +247,68 @@ interface ChapterWorkerOptions {
|
|
|
246
247
|
chapter: Chapter;
|
|
247
248
|
/** CRM mount point. Default "/api/crm". */
|
|
248
249
|
crmBasePath?: string;
|
|
250
|
+
/** Host routes, tried BEFORE the built-ins — so a wrapping site can add its own
|
|
251
|
+
* routes (or override/alias a built-in path) and reuse chapter's auth via the
|
|
252
|
+
* shared {@link WorkerContext}, instead of re-verifying JWTs itself. */
|
|
253
|
+
routes?: Route[];
|
|
249
254
|
}
|
|
255
|
+
/** The registry public-config a site reads to boot Clerk sign-in. */
|
|
256
|
+
type PublicConfig = {
|
|
257
|
+
env?: string;
|
|
258
|
+
clerkPublishableKey?: string | null;
|
|
259
|
+
issuer?: string | null;
|
|
260
|
+
};
|
|
261
|
+
/** The odla-db admin client type. */
|
|
262
|
+
type Db = ReturnType<typeof initAdmin>;
|
|
263
|
+
/** A verified session: the Clerk `sub`, optional email, and the raw JWT payload
|
|
264
|
+
* (so the role claim can be read for auth source "claim"). */
|
|
265
|
+
interface Verified {
|
|
266
|
+
userId: string;
|
|
267
|
+
email?: string;
|
|
268
|
+
payload: Record<string, unknown>;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Build the per-site Worker context: env-independent helpers closing over the
|
|
272
|
+
* public-config and JWKS caches, plus the source-aware auth gate (JWT claim or
|
|
273
|
+
* the odla-db `admins` allowlist). Constructed once per `chapterWorker` and
|
|
274
|
+
* shared by every route module.
|
|
275
|
+
*/
|
|
276
|
+
declare function createWorkerContext(options: ChapterWorkerOptions): {
|
|
277
|
+
chapter: Chapter;
|
|
278
|
+
auth: ResolvedAuth;
|
|
279
|
+
crmBase: string;
|
|
280
|
+
getPublicConfig: (env: ChapterEnv) => Promise<PublicConfig>;
|
|
281
|
+
verifyUser: (req: Request, env: ChapterEnv) => Promise<Verified | null>;
|
|
282
|
+
makeDb: (env: ChapterEnv) => Db;
|
|
283
|
+
isAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
|
|
284
|
+
isSuperAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
|
|
285
|
+
roleFor: (db: Db, u: Verified) => Promise<string>;
|
|
286
|
+
isAdmin: (db: Db, u: Verified) => Promise<boolean>;
|
|
287
|
+
crmSender: (env: ChapterEnv) => {
|
|
288
|
+
send(payload: EmailPayload): Promise<{
|
|
289
|
+
messageId: string;
|
|
290
|
+
}>;
|
|
291
|
+
} | undefined;
|
|
292
|
+
};
|
|
293
|
+
/** The value returned by {@link createWorkerContext}, threaded to every route. */
|
|
294
|
+
type WorkerContext = ReturnType<typeof createWorkerContext>;
|
|
295
|
+
/** A worker route handler: owns the request (returns a Response) or falls through
|
|
296
|
+
* (returns null). Host routes passed to `chapterWorker` compose against the same
|
|
297
|
+
* {@link WorkerContext} the built-ins receive. */
|
|
298
|
+
type Route = (req: Request, url: URL, env: ChapterEnv, ctx: WorkerContext) => Promise<Response | null>;
|
|
250
299
|
|
|
251
300
|
/**
|
|
252
301
|
* Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
|
|
253
|
-
* verification, the
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
302
|
+
* verification, the source-aware admin gate, the mounted @odla-ai/crm routes, the
|
|
303
|
+
* hub→chapter network projection, and the static-asset fallback. Hub mode serves
|
|
304
|
+
* /api/health, /api/config, /api/me, /api/crm/*, /api/network/shared; chapter mode
|
|
305
|
+
* adds the public member surface (join/apply/pay/book) and the admin surface
|
|
306
|
+
* (/api/admin/*).
|
|
307
|
+
*
|
|
308
|
+
* A wrapping site adds its own routes via `options.routes` — each receives the
|
|
309
|
+
* same {@link WorkerContext} the built-ins get (so it reuses chapter's JWT
|
|
310
|
+
* verify, db client, and role resolution instead of duplicating them), and runs
|
|
311
|
+
* BEFORE the built-ins so it can override or alias a path.
|
|
258
312
|
*
|
|
259
313
|
* Observability is a host concern, not a chapter dependency. To trace, wrap the
|
|
260
314
|
* result in your worker entry — `export default withObservability(chapterWorker(
|
|
@@ -265,4 +319,4 @@ declare function chapterWorker(options: ChapterWorkerOptions): {
|
|
|
265
319
|
fetch(req: Request, env: ChapterEnv): Promise<Response>;
|
|
266
320
|
};
|
|
267
321
|
|
|
268
|
-
export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
|
|
322
|
+
export { type ChapterEnv, type ChapterWorkerOptions, type Route, type WorkerContext, chapterWorker, createWorkerContext };
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CrmConfig, Crm } from '@odla-ai/crm';
|
|
2
|
+
import { initAdmin } from '@odla-ai/db';
|
|
2
3
|
|
|
3
4
|
/** Which feature profile a site runs. `chapter` is the full public member site
|
|
4
5
|
* (join, Stripe membership, booking, member area, admin, CRM); `hub` is
|
|
@@ -246,15 +247,68 @@ interface ChapterWorkerOptions {
|
|
|
246
247
|
chapter: Chapter;
|
|
247
248
|
/** CRM mount point. Default "/api/crm". */
|
|
248
249
|
crmBasePath?: string;
|
|
250
|
+
/** Host routes, tried BEFORE the built-ins — so a wrapping site can add its own
|
|
251
|
+
* routes (or override/alias a built-in path) and reuse chapter's auth via the
|
|
252
|
+
* shared {@link WorkerContext}, instead of re-verifying JWTs itself. */
|
|
253
|
+
routes?: Route[];
|
|
249
254
|
}
|
|
255
|
+
/** The registry public-config a site reads to boot Clerk sign-in. */
|
|
256
|
+
type PublicConfig = {
|
|
257
|
+
env?: string;
|
|
258
|
+
clerkPublishableKey?: string | null;
|
|
259
|
+
issuer?: string | null;
|
|
260
|
+
};
|
|
261
|
+
/** The odla-db admin client type. */
|
|
262
|
+
type Db = ReturnType<typeof initAdmin>;
|
|
263
|
+
/** A verified session: the Clerk `sub`, optional email, and the raw JWT payload
|
|
264
|
+
* (so the role claim can be read for auth source "claim"). */
|
|
265
|
+
interface Verified {
|
|
266
|
+
userId: string;
|
|
267
|
+
email?: string;
|
|
268
|
+
payload: Record<string, unknown>;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Build the per-site Worker context: env-independent helpers closing over the
|
|
272
|
+
* public-config and JWKS caches, plus the source-aware auth gate (JWT claim or
|
|
273
|
+
* the odla-db `admins` allowlist). Constructed once per `chapterWorker` and
|
|
274
|
+
* shared by every route module.
|
|
275
|
+
*/
|
|
276
|
+
declare function createWorkerContext(options: ChapterWorkerOptions): {
|
|
277
|
+
chapter: Chapter;
|
|
278
|
+
auth: ResolvedAuth;
|
|
279
|
+
crmBase: string;
|
|
280
|
+
getPublicConfig: (env: ChapterEnv) => Promise<PublicConfig>;
|
|
281
|
+
verifyUser: (req: Request, env: ChapterEnv) => Promise<Verified | null>;
|
|
282
|
+
makeDb: (env: ChapterEnv) => Db;
|
|
283
|
+
isAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
|
|
284
|
+
isSuperAdminEmail: (db: Db, email: string | undefined) => Promise<boolean>;
|
|
285
|
+
roleFor: (db: Db, u: Verified) => Promise<string>;
|
|
286
|
+
isAdmin: (db: Db, u: Verified) => Promise<boolean>;
|
|
287
|
+
crmSender: (env: ChapterEnv) => {
|
|
288
|
+
send(payload: EmailPayload): Promise<{
|
|
289
|
+
messageId: string;
|
|
290
|
+
}>;
|
|
291
|
+
} | undefined;
|
|
292
|
+
};
|
|
293
|
+
/** The value returned by {@link createWorkerContext}, threaded to every route. */
|
|
294
|
+
type WorkerContext = ReturnType<typeof createWorkerContext>;
|
|
295
|
+
/** A worker route handler: owns the request (returns a Response) or falls through
|
|
296
|
+
* (returns null). Host routes passed to `chapterWorker` compose against the same
|
|
297
|
+
* {@link WorkerContext} the built-ins receive. */
|
|
298
|
+
type Route = (req: Request, url: URL, env: ChapterEnv, ctx: WorkerContext) => Promise<Response | null>;
|
|
250
299
|
|
|
251
300
|
/**
|
|
252
301
|
* Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
|
|
253
|
-
* verification, the
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
302
|
+
* verification, the source-aware admin gate, the mounted @odla-ai/crm routes, the
|
|
303
|
+
* hub→chapter network projection, and the static-asset fallback. Hub mode serves
|
|
304
|
+
* /api/health, /api/config, /api/me, /api/crm/*, /api/network/shared; chapter mode
|
|
305
|
+
* adds the public member surface (join/apply/pay/book) and the admin surface
|
|
306
|
+
* (/api/admin/*).
|
|
307
|
+
*
|
|
308
|
+
* A wrapping site adds its own routes via `options.routes` — each receives the
|
|
309
|
+
* same {@link WorkerContext} the built-ins get (so it reuses chapter's JWT
|
|
310
|
+
* verify, db client, and role resolution instead of duplicating them), and runs
|
|
311
|
+
* BEFORE the built-ins so it can override or alias a path.
|
|
258
312
|
*
|
|
259
313
|
* Observability is a host concern, not a chapter dependency. To trace, wrap the
|
|
260
314
|
* result in your worker entry — `export default withObservability(chapterWorker(
|
|
@@ -265,4 +319,4 @@ declare function chapterWorker(options: ChapterWorkerOptions): {
|
|
|
265
319
|
fetch(req: Request, env: ChapterEnv): Promise<Response>;
|
|
266
320
|
};
|
|
267
321
|
|
|
268
|
-
export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
|
|
322
|
+
export { type ChapterEnv, type ChapterWorkerOptions, type Route, type WorkerContext, chapterWorker, createWorkerContext };
|
package/dist/worker/index.js
CHANGED
|
@@ -94,6 +94,27 @@ function createWorkerContext(options) {
|
|
|
94
94
|
// src/worker-routes.ts
|
|
95
95
|
import { createCrmRoutes } from "@odla-ai/crm";
|
|
96
96
|
|
|
97
|
+
// src/clerk.ts
|
|
98
|
+
function clerkInviteRequest(input) {
|
|
99
|
+
return {
|
|
100
|
+
path: "/v1/invitations",
|
|
101
|
+
body: {
|
|
102
|
+
email_address: input.email,
|
|
103
|
+
notify: true,
|
|
104
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
109
|
+
const { path, body } = clerkInviteRequest(input);
|
|
110
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
113
|
+
body: JSON.stringify(body)
|
|
114
|
+
});
|
|
115
|
+
return { ok: res.ok, status: res.status };
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
// src/member.ts
|
|
98
119
|
async function submitApplication(db, chapter, fields, opts) {
|
|
99
120
|
const app = chapter.application;
|
|
@@ -147,21 +168,32 @@ function sharedPersonInput(person) {
|
|
|
147
168
|
if (person.linkedin) input.linkedin = person.linkedin;
|
|
148
169
|
return input;
|
|
149
170
|
}
|
|
150
|
-
async function
|
|
151
|
-
const email =
|
|
152
|
-
const input = sharedPersonInput(person);
|
|
171
|
+
async function upsertPerson(deps, opts) {
|
|
172
|
+
const email = opts.email.toLowerCase();
|
|
153
173
|
const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
154
|
-
const { crm_record } = await deps.db.query({
|
|
155
|
-
crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
|
|
156
|
-
});
|
|
174
|
+
const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
|
|
157
175
|
const existing = crm_record?.[0];
|
|
158
176
|
if (existing && typeof existing.id === "string") {
|
|
159
|
-
await updateRecord(crmDeps, { id: existing.id, input });
|
|
177
|
+
await updateRecord(crmDeps, { id: existing.id, input: opts.input });
|
|
160
178
|
return { recordId: existing.id };
|
|
161
179
|
}
|
|
162
|
-
const created = await createRecord(crmDeps, { type: "person", input, mutationId:
|
|
180
|
+
const created = await createRecord(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
163
181
|
return { recordId: created.id };
|
|
164
182
|
}
|
|
183
|
+
async function projectSharedRecord(deps, person) {
|
|
184
|
+
return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
|
|
185
|
+
}
|
|
186
|
+
async function projectApplicant(deps, applicant) {
|
|
187
|
+
const input = sharedPersonInput({
|
|
188
|
+
email: applicant.email,
|
|
189
|
+
firstName: applicant.firstName,
|
|
190
|
+
lastName: applicant.lastName,
|
|
191
|
+
phone: applicant.phone,
|
|
192
|
+
linkedin: applicant.linkedin,
|
|
193
|
+
hubRecordId: applicant.applicationId
|
|
194
|
+
});
|
|
195
|
+
return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
|
|
196
|
+
}
|
|
165
197
|
|
|
166
198
|
// src/scheduling.ts
|
|
167
199
|
var SCHEDULING_DEFAULTS = {
|
|
@@ -209,10 +241,6 @@ function resolveScheduling(config) {
|
|
|
209
241
|
if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
|
|
210
242
|
return { ...c, days };
|
|
211
243
|
}
|
|
212
|
-
var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
|
|
213
|
-
function canBookFrom(status) {
|
|
214
|
-
return BOOKABLE_STATUSES.includes(status);
|
|
215
|
-
}
|
|
216
244
|
function slotWindow(now, windowDays) {
|
|
217
245
|
return { from: now, to: now + windowDays * 864e5 };
|
|
218
246
|
}
|
|
@@ -291,7 +319,141 @@ function memberApplication(app, meeting, defaultTimezone) {
|
|
|
291
319
|
return { ...summary, meetingAt, meetUrl, timezone };
|
|
292
320
|
}
|
|
293
321
|
|
|
322
|
+
// src/email.ts
|
|
323
|
+
function render(template, vars) {
|
|
324
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
325
|
+
}
|
|
326
|
+
function groupVars(group, vars) {
|
|
327
|
+
return {
|
|
328
|
+
...vars,
|
|
329
|
+
refundPolicyText: group.refundPolicyText ?? "",
|
|
330
|
+
commitmentText: group.commitmentText ?? "",
|
|
331
|
+
normsText: group.normsText ?? ""
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function isAlreadySent(priorRows) {
|
|
335
|
+
return priorRows.some((row) => !row.error);
|
|
336
|
+
}
|
|
337
|
+
function planDelivery(input) {
|
|
338
|
+
const tpl = input.group.emailTemplates?.[input.template];
|
|
339
|
+
if (!tpl) return { deliver: false, reason: "template-missing" };
|
|
340
|
+
if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
|
|
341
|
+
const vars = groupVars(input.group, input.vars);
|
|
342
|
+
const isProd = input.envName === "prod";
|
|
343
|
+
const redirect = !isProd && !!input.group.debugEmail;
|
|
344
|
+
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
345
|
+
const to = redirect ? input.group.debugEmail : input.to;
|
|
346
|
+
const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
|
|
347
|
+
const text = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
348
|
+
|
|
349
|
+
` + render(tpl.text, vars) : render(tpl.text, vars);
|
|
350
|
+
return { deliver: true, transport, to, subject, text, redirected: redirect };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/notify.ts
|
|
354
|
+
async function sendTemplated(deps, input) {
|
|
355
|
+
const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
|
|
356
|
+
const prior = Array.isArray(emailLog) ? emailLog : [];
|
|
357
|
+
if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
|
|
358
|
+
const cloudflareReady = Boolean(deps.sender && deps.from);
|
|
359
|
+
const decision = planDelivery({
|
|
360
|
+
envName: deps.envName,
|
|
361
|
+
group: input.group,
|
|
362
|
+
template: input.template,
|
|
363
|
+
to: input.to,
|
|
364
|
+
vars: input.vars,
|
|
365
|
+
cloudflareReady,
|
|
366
|
+
force: input.force
|
|
367
|
+
});
|
|
368
|
+
if (!decision.deliver) return { sent: false, reason: decision.reason };
|
|
369
|
+
let error;
|
|
370
|
+
let messageId;
|
|
371
|
+
if (decision.transport === "cloudflare" && deps.sender && deps.from) {
|
|
372
|
+
try {
|
|
373
|
+
const res = await deps.sender.send({
|
|
374
|
+
from: deps.from,
|
|
375
|
+
to: [decision.to],
|
|
376
|
+
subject: decision.subject,
|
|
377
|
+
text: decision.text,
|
|
378
|
+
replyTo: input.group.replyTo
|
|
379
|
+
});
|
|
380
|
+
messageId = res.messageId;
|
|
381
|
+
} catch (e) {
|
|
382
|
+
error = e instanceof Error ? e.message : String(e);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const id = deps.newId();
|
|
386
|
+
const row = {
|
|
387
|
+
id,
|
|
388
|
+
groupId: input.group.id,
|
|
389
|
+
to: decision.to,
|
|
390
|
+
template: input.template,
|
|
391
|
+
subject: decision.subject,
|
|
392
|
+
body: decision.text,
|
|
393
|
+
transport: decision.transport,
|
|
394
|
+
redirected: decision.redirected,
|
|
395
|
+
dedupeKey: input.dedupeKey,
|
|
396
|
+
sentAt: deps.now(),
|
|
397
|
+
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
398
|
+
...messageId ? { messageId } : {},
|
|
399
|
+
...error ? { error } : {}
|
|
400
|
+
};
|
|
401
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
402
|
+
return error ? { sent: false, reason: error } : { sent: true };
|
|
403
|
+
}
|
|
404
|
+
function emailGroupFrom(row) {
|
|
405
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
406
|
+
const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
|
|
407
|
+
return {
|
|
408
|
+
id: String(row.id),
|
|
409
|
+
name: String(row.name ?? ""),
|
|
410
|
+
replyTo: str(row.replyTo) ?? "",
|
|
411
|
+
debugEmail: str(row.debugEmail),
|
|
412
|
+
refundPolicyText: str(row.refundPolicyText),
|
|
413
|
+
commitmentText: str(row.commitmentText),
|
|
414
|
+
normsText: str(row.normsText),
|
|
415
|
+
emailTemplates: templates
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
294
419
|
// src/worker-routes.ts
|
|
420
|
+
async function provisionApplicant(db, chapter, applicationId, fields) {
|
|
421
|
+
const email = typeof fields.email === "string" ? fields.email : "";
|
|
422
|
+
if (!email) return;
|
|
423
|
+
const s = (v) => typeof v === "string" ? v : void 0;
|
|
424
|
+
try {
|
|
425
|
+
await projectApplicant(
|
|
426
|
+
{ crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
427
|
+
{ applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin) }
|
|
428
|
+
);
|
|
429
|
+
} catch {
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
433
|
+
if (secret) await createClerkInvitation(secret, { email });
|
|
434
|
+
} catch {
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
|
|
438
|
+
try {
|
|
439
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
440
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
441
|
+
if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
|
|
442
|
+
const s = (v) => typeof v === "string" ? v : "";
|
|
443
|
+
await sendTemplated(
|
|
444
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
445
|
+
{
|
|
446
|
+
group: emailGroupFrom(group),
|
|
447
|
+
template: "adminNotification",
|
|
448
|
+
to: group.notificationEmail,
|
|
449
|
+
vars: { firstName: s(fields.firstName), lastName: s(fields.lastName), email: s(fields.email), phone: s(fields.phone), state: s(fields.state) },
|
|
450
|
+
dedupeKey: `apply:${applicationId}:admin`,
|
|
451
|
+
applicationId
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
} catch {
|
|
455
|
+
}
|
|
456
|
+
}
|
|
295
457
|
async function memberSessionApplication(db, chapterId, email) {
|
|
296
458
|
const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
|
|
297
459
|
const app = Array.isArray(apps) ? apps[0] : void 0;
|
|
@@ -303,6 +465,7 @@ async function memberSessionApplication(db, chapterId, email) {
|
|
|
303
465
|
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
304
466
|
return memberApplication(app, meeting, timezone);
|
|
305
467
|
}
|
|
468
|
+
var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
|
|
306
469
|
var handleConfig = async (_req, url, env, ctx) => {
|
|
307
470
|
if (url.pathname !== "/api/config") return null;
|
|
308
471
|
try {
|
|
@@ -392,13 +555,18 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
392
555
|
return json({ error: "invalid JSON body" }, 400);
|
|
393
556
|
}
|
|
394
557
|
const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
|
|
395
|
-
const
|
|
558
|
+
const db = ctx.makeDb(env);
|
|
559
|
+
const result = await submitApplication(db, chapter, parsed, {
|
|
396
560
|
submissionId,
|
|
397
561
|
groupId: chapter.id,
|
|
398
562
|
now: Date.now(),
|
|
399
563
|
newId: () => crypto.randomUUID()
|
|
400
564
|
});
|
|
401
565
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
566
|
+
if (!result.duplicate) {
|
|
567
|
+
await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
|
|
568
|
+
await provisionApplicant(db, chapter, result.id, parsed);
|
|
569
|
+
}
|
|
402
570
|
return json({ id: result.id, duplicate: result.duplicate, status: result.status });
|
|
403
571
|
}
|
|
404
572
|
return null;
|
|
@@ -406,6 +574,13 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
406
574
|
|
|
407
575
|
// src/worker-routes-schedule.ts
|
|
408
576
|
import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
|
|
577
|
+
|
|
578
|
+
// src/pipeline.ts
|
|
579
|
+
function canBook(status, p) {
|
|
580
|
+
return p.bookableFrom.includes(status);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/worker-routes-schedule.ts
|
|
409
584
|
function errCode(err) {
|
|
410
585
|
if (err && typeof err === "object") {
|
|
411
586
|
const code = err.code;
|
|
@@ -447,7 +622,7 @@ async function bookSlot(req, env, ctx) {
|
|
|
447
622
|
const app = await firstRow(db, "applications", { where: { id: applicationId }, limit: 1 });
|
|
448
623
|
if (!app) return json({ error: "not found" }, 404);
|
|
449
624
|
const status = String(app.status ?? "");
|
|
450
|
-
if (!
|
|
625
|
+
if (!canBook(status, ctx.chapter.pipeline)) return json({ error: `cannot book from status "${status}"` }, 409);
|
|
451
626
|
const group = await firstRow(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
452
627
|
if (!group) return json({ error: "group not found" }, 500);
|
|
453
628
|
const cfg = resolveScheduling(group.schedulingJson);
|
|
@@ -509,6 +684,12 @@ async function bookSlot(req, env, ctx) {
|
|
|
509
684
|
}
|
|
510
685
|
const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
|
|
511
686
|
await db.transact([meetingOp, appOp]);
|
|
687
|
+
if (typeof app.email === "string" && app.email) {
|
|
688
|
+
await sendTemplated(
|
|
689
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
690
|
+
{ group: emailGroupFrom(group), template: "prepEmail", to: app.email, vars: { firstName: String(app.firstName ?? "") }, dedupeKey: `prep:${applicationId}`, applicationId }
|
|
691
|
+
).catch(() => void 0);
|
|
692
|
+
}
|
|
512
693
|
return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
|
|
513
694
|
}
|
|
514
695
|
var handleSchedule = async (req, url, env, ctx) => {
|
|
@@ -744,6 +925,25 @@ async function findApplication(db, event) {
|
|
|
744
925
|
}
|
|
745
926
|
return void 0;
|
|
746
927
|
}
|
|
928
|
+
async function notifyPaymentConfirmed(db, env, eventId, app) {
|
|
929
|
+
try {
|
|
930
|
+
if (typeof app.email !== "string" || !app.email) return;
|
|
931
|
+
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? "") }, limit: 1 });
|
|
932
|
+
if (!group) return;
|
|
933
|
+
await sendTemplated(
|
|
934
|
+
{ db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
935
|
+
{
|
|
936
|
+
group: emailGroupFrom(group),
|
|
937
|
+
template: "paymentConfirmation",
|
|
938
|
+
to: app.email,
|
|
939
|
+
vars: { firstName: typeof app.firstName === "string" ? app.firstName : "" },
|
|
940
|
+
dedupeKey: `${eventId}:confirm`,
|
|
941
|
+
applicationId: String(app.id)
|
|
942
|
+
}
|
|
943
|
+
);
|
|
944
|
+
} catch {
|
|
945
|
+
}
|
|
946
|
+
}
|
|
747
947
|
function webhookPatch(event, status) {
|
|
748
948
|
switch (event.kind) {
|
|
749
949
|
case "first_payment":
|
|
@@ -815,6 +1015,7 @@ async function ingestWebhook(req, env, ctx) {
|
|
|
815
1015
|
if (Object.keys(patch).length) {
|
|
816
1016
|
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
817
1017
|
}
|
|
1018
|
+
if (event.kind === "first_payment") await notifyPaymentConfirmed(db, env, eventId, app);
|
|
818
1019
|
return json({ ok: true });
|
|
819
1020
|
}
|
|
820
1021
|
async function refundApplication(req, url, env, ctx) {
|
|
@@ -851,14 +1052,139 @@ var handlePayments = async (req, url, env, ctx) => {
|
|
|
851
1052
|
return null;
|
|
852
1053
|
};
|
|
853
1054
|
|
|
1055
|
+
// src/worker-routes-admin.ts
|
|
1056
|
+
import { initCalendar as initCalendar2 } from "@odla-ai/calendar";
|
|
1057
|
+
|
|
1058
|
+
// src/reconcile.ts
|
|
1059
|
+
function isReconcilable(meeting, now) {
|
|
1060
|
+
return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
|
|
1061
|
+
}
|
|
1062
|
+
function reconcileMeetings(meetings, events, now) {
|
|
1063
|
+
const byEvent = new Map(events.map((e) => [e.eventId, e]));
|
|
1064
|
+
const decisions = [];
|
|
1065
|
+
for (const m of meetings) {
|
|
1066
|
+
if (!isReconcilable(m, now) || !m.googleEventId) continue;
|
|
1067
|
+
const g = byEvent.get(m.googleEventId);
|
|
1068
|
+
if (!g || g.status === "cancelled") {
|
|
1069
|
+
decisions.push({
|
|
1070
|
+
meetingId: m.id,
|
|
1071
|
+
applicationId: m.applicationId,
|
|
1072
|
+
kind: "cancelled",
|
|
1073
|
+
meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
|
|
1074
|
+
applicationPatch: { meetingAt: 0, meetingLink: "" }
|
|
1075
|
+
});
|
|
1076
|
+
} else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
|
|
1077
|
+
const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
|
|
1078
|
+
decisions.push({
|
|
1079
|
+
meetingId: m.id,
|
|
1080
|
+
applicationId: m.applicationId,
|
|
1081
|
+
kind: "moved",
|
|
1082
|
+
meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
|
|
1083
|
+
applicationPatch: { meetingAt: g.startAt }
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return decisions;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/worker-routes-admin.ts
|
|
1091
|
+
async function adminGroup(req, env, ctx, url) {
|
|
1092
|
+
const rawDb = ctx.makeDb(env);
|
|
1093
|
+
const u = await ctx.verifyUser(req, env);
|
|
1094
|
+
if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1095
|
+
const db = rawDb;
|
|
1096
|
+
const groupId = url.searchParams.get("group") ?? ctx.chapter.id;
|
|
1097
|
+
const group = (await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } })).groups?.[0];
|
|
1098
|
+
if (!group) return json({ error: "not found" }, 404);
|
|
1099
|
+
return { db, group };
|
|
1100
|
+
}
|
|
1101
|
+
var handleAdminScheduling = async (req, url, env, ctx) => {
|
|
1102
|
+
if (url.pathname !== "/api/admin/scheduling" || req.method !== "GET" && req.method !== "PUT") return null;
|
|
1103
|
+
const got = await adminGroup(req, env, ctx, url);
|
|
1104
|
+
if (got instanceof Response) return got;
|
|
1105
|
+
const { db, group } = got;
|
|
1106
|
+
if (req.method === "GET") {
|
|
1107
|
+
return json({ scheduling: resolveScheduling(group.schedulingJson) });
|
|
1108
|
+
}
|
|
1109
|
+
let body;
|
|
1110
|
+
try {
|
|
1111
|
+
body = JSON.parse(await req.text());
|
|
1112
|
+
} catch {
|
|
1113
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1114
|
+
}
|
|
1115
|
+
let resolved;
|
|
1116
|
+
try {
|
|
1117
|
+
resolved = resolveScheduling(body);
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
return json({ error: e instanceof Error ? e.message : "invalid scheduling config" }, 400);
|
|
1120
|
+
}
|
|
1121
|
+
await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: resolved } }]);
|
|
1122
|
+
return json({ scheduling: resolved });
|
|
1123
|
+
};
|
|
1124
|
+
async function upcomingEvents(env) {
|
|
1125
|
+
const cal = initCalendar2({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
|
|
1126
|
+
const res = await cal.availability.upcoming();
|
|
1127
|
+
return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
|
|
1128
|
+
}
|
|
1129
|
+
function toReconcile(rows) {
|
|
1130
|
+
return rows.map((m) => ({
|
|
1131
|
+
id: String(m.id),
|
|
1132
|
+
applicationId: String(m.applicationId),
|
|
1133
|
+
googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
|
|
1134
|
+
status: String(m.status ?? ""),
|
|
1135
|
+
startAt: typeof m.startAt === "number" ? m.startAt : null,
|
|
1136
|
+
endAt: typeof m.endAt === "number" ? m.endAt : null
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
var handleAdminMeetings = async (req, url, env, ctx) => {
|
|
1140
|
+
if (req.method !== "GET" || url.pathname !== "/api/admin/meetings") return null;
|
|
1141
|
+
const rawDb = ctx.makeDb(env);
|
|
1142
|
+
const u = await ctx.verifyUser(req, env);
|
|
1143
|
+
if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
1144
|
+
const db = rawDb;
|
|
1145
|
+
const query = await db.query({ meetings: { $: { where: { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } } });
|
|
1146
|
+
const rows = Array.isArray(query.meetings) ? query.meetings : [];
|
|
1147
|
+
let decisions = [];
|
|
1148
|
+
try {
|
|
1149
|
+
decisions = reconcileMeetings(toReconcile(rows), await upcomingEvents(env), Date.now());
|
|
1150
|
+
} catch {
|
|
1151
|
+
}
|
|
1152
|
+
const patched = /* @__PURE__ */ new Map();
|
|
1153
|
+
for (const d of decisions) {
|
|
1154
|
+
const ops = [
|
|
1155
|
+
{ t: "update", ns: "meetings", id: d.meetingId, attrs: d.meetingPatch },
|
|
1156
|
+
{ t: "update", ns: "applications", id: d.applicationId, attrs: d.applicationPatch }
|
|
1157
|
+
];
|
|
1158
|
+
try {
|
|
1159
|
+
await db.transact(ops);
|
|
1160
|
+
patched.set(d.meetingId, d.meetingPatch);
|
|
1161
|
+
} catch {
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const meetings = rows.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {} })).filter((m) => m.status === "scheduled");
|
|
1165
|
+
return json({ meetings, adopted: decisions.length });
|
|
1166
|
+
};
|
|
1167
|
+
|
|
854
1168
|
// src/worker.ts
|
|
855
|
-
var
|
|
1169
|
+
var BUILTIN_ROUTES = [
|
|
1170
|
+
handleHealth,
|
|
1171
|
+
handleConfig,
|
|
1172
|
+
handleMe,
|
|
1173
|
+
handleCrm,
|
|
1174
|
+
handleNetworkShared,
|
|
1175
|
+
handleMember,
|
|
1176
|
+
handleSchedule,
|
|
1177
|
+
handlePayments,
|
|
1178
|
+
handleAdminMeetings,
|
|
1179
|
+
handleAdminScheduling
|
|
1180
|
+
];
|
|
856
1181
|
function chapterWorker(options) {
|
|
857
1182
|
const ctx = createWorkerContext(options);
|
|
1183
|
+
const routes = [...options.routes ?? [], ...BUILTIN_ROUTES];
|
|
858
1184
|
return {
|
|
859
1185
|
async fetch(req, env) {
|
|
860
1186
|
const url = new URL(req.url);
|
|
861
|
-
for (const route of
|
|
1187
|
+
for (const route of routes) {
|
|
862
1188
|
const res = await route(req, url, env, ctx);
|
|
863
1189
|
if (res) return res;
|
|
864
1190
|
}
|
|
@@ -867,6 +1193,7 @@ function chapterWorker(options) {
|
|
|
867
1193
|
};
|
|
868
1194
|
}
|
|
869
1195
|
export {
|
|
870
|
-
chapterWorker
|
|
1196
|
+
chapterWorker,
|
|
1197
|
+
createWorkerContext
|
|
871
1198
|
};
|
|
872
1199
|
//# sourceMappingURL=index.js.map
|