@odla-ai/chapter 0.5.0 → 0.8.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 +111 -28
- package/dist/index.cjs +175 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +157 -9
- package/dist/index.d.ts +157 -9
- package/dist/index.js +175 -17
- package/dist/index.js.map +1 -1
- package/dist/ui/index.d.ts +5 -1
- package/dist/ui/index.js +3 -2
- package/dist/ui/index.js.map +1 -1
- package/dist/worker/index.cjs +369 -17
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +69 -6
- package/dist/worker/index.d.ts +69 -6
- package/dist/worker/index.js +369 -17
- package/dist/worker/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @odla-ai/chapter
|
|
2
2
|
|
|
3
|
-
A foundation for **membership sites**. One `defineChapter({...})` config
|
|
4
|
-
|
|
5
|
-
member area
|
|
6
|
-
Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar +
|
|
3
|
+
A foundation for **membership sites**. One `defineChapter({...})` config stands up
|
|
4
|
+
a full public member site — join/apply → Stripe membership → Google booking →
|
|
5
|
+
member area — plus an admin console and a CRM, or an **admin-only hub**, on
|
|
6
|
+
odla-db + Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar +
|
|
7
|
+
email.
|
|
7
8
|
|
|
8
9
|
```sh
|
|
9
10
|
npm i @odla-ai/chapter
|
|
@@ -12,34 +13,53 @@ npm i @odla-ai/chapter
|
|
|
12
13
|
> **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
|
|
13
14
|
> with human review. Review the documented guarantees before relying on it.
|
|
14
15
|
|
|
15
|
-
> **
|
|
16
|
-
>
|
|
17
|
-
>
|
|
16
|
+
> **Pre-1.0.** The member surface and the operational worker (auth, join, Stripe,
|
|
17
|
+
> booking, email, CRM projection, admin operational routes) ship. The admin *UI*
|
|
18
|
+
> is a growing section catalog: `peopleSection` and the availability editor today,
|
|
19
|
+
> more sections landing per release.
|
|
18
20
|
|
|
19
21
|
## The shape
|
|
20
22
|
|
|
21
23
|
- **One config, two profiles.** `defineChapter()` validates at import and returns
|
|
22
24
|
a resolved engine. `mode: "chapter"` is the full public member site; `mode:
|
|
23
|
-
"hub"` is admin-only and CRM-focused
|
|
24
|
-
|
|
25
|
-
member/join/payment route surface.
|
|
25
|
+
"hub"` is admin-only and CRM-focused. The mode gates the member/join/payment
|
|
26
|
+
route surface; everything else (auth, CRM, chrome, provisioning) is shared.
|
|
26
27
|
- **The worker is the package.** `chapterWorker({ chapter })` is the whole
|
|
27
|
-
Cloudflare `ExportedHandler
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
`
|
|
42
|
-
|
|
28
|
+
Cloudflare `ExportedHandler`. It serves `/api/health`, `/api/config`,
|
|
29
|
+
`/api/me`, `/api/crm/*`, `/api/network/shared`; chapter mode adds the public
|
|
30
|
+
member surface (`/api/join-config`, `/api/applications`,
|
|
31
|
+
`/api/schedule/{slots,book}`, `/api/payments/subscription`,
|
|
32
|
+
`/api/webhooks/stripe`) and the admin operational routes (`/api/admin/*`), then
|
|
33
|
+
falls back to static assets. Your `src/worker.ts` is ~3 lines. Observability is
|
|
34
|
+
a host concern — wrap it with `withObservability` from `@odla-ai/o11y`.
|
|
35
|
+
Everything brand-specific (prices, policy copy, email templates, scheduling
|
|
36
|
+
rules) is read at runtime from a single odla-db `groups` row, never hardcoded.
|
|
37
|
+
- **A route seam, not a black box.** `chapterWorker({ chapter, routes })` runs
|
|
38
|
+
your handlers *before* the built-ins (add routes, or override/alias a path),
|
|
39
|
+
each receiving the same context the built-ins get. The worker entry also exports
|
|
40
|
+
`createWorkerContext` + the `WorkerContext`/`Route` types, so a wrapping worker
|
|
41
|
+
reuses chapter's JWT verify, db client, and role resolution
|
|
42
|
+
(`verifyUser`/`makeDb`/`roleFor`/`isAdmin`) instead of duplicating them.
|
|
43
|
+
- **Source-aware auth.** A JWT role-claim ladder (`provisional → member → admin`)
|
|
44
|
+
*or* an odla-db `admins` allowlist, plus a read-only `superAdmins` tier —
|
|
45
|
+
selected by `auth.source`, defaulting per mode. Escalation guards
|
|
46
|
+
(`canChangeRole`) are package-enforced.
|
|
47
|
+
- **Correctness is packaged, not per-site.** Exactly-once email
|
|
48
|
+
(`sendTemplated`/`isAlreadySent`), a non-prod delivery fail-safe
|
|
49
|
+
(`planDelivery`), status-never-backwards (`canTransition`), Stripe webhook
|
|
50
|
+
integrity (`verifyStripeSignature`) with the webhook as the authoritative writer
|
|
51
|
+
of paid/refunded, one-subscription-per-application idempotency, meetings-as-
|
|
52
|
+
canonical booking (a rebooking *reschedules* the event, preserving the Meet
|
|
53
|
+
link), Google-edit adoption (`reconcileMeetings`), and a one-way CRM projection.
|
|
54
|
+
- **Provisioning is declarative.** `createChapterIntegration(chapter)` composes
|
|
55
|
+
the crm namespaces + the chapter namespaces (`applications`, `groups`,
|
|
56
|
+
`meetings`, `emailLog`, plus the auth tables) + a guarded group-row seed. Drop
|
|
57
|
+
it in `odla.config.mjs` `integrations: [...]`.
|
|
58
|
+
- **The UI kit (`./ui`).** Member islands — `JoinIsland` (form → payment →
|
|
59
|
+
booking), `MembersArea`, `Rescheduler`, `SlotPicker`, `PaymentStep`; the admin
|
|
60
|
+
console `ChapterAdmin` + the section catalog (`peopleSection`); and brand tokens
|
|
61
|
+
(`brandTokens`/`<BrandStyle>`) that re-skin the whole UI from `brand`. Authored
|
|
62
|
+
against React, rendered as Preact via `preact/compat` in the reference sites.
|
|
43
63
|
|
|
44
64
|
## Quick start
|
|
45
65
|
|
|
@@ -50,9 +70,8 @@ import { defineChapter } from "@odla-ai/chapter";
|
|
|
50
70
|
export const chapter = defineChapter({
|
|
51
71
|
id: "example-chapter",
|
|
52
72
|
name: "Example Chapter",
|
|
53
|
-
url: "example.com",
|
|
54
73
|
mode: "chapter",
|
|
55
|
-
brand: { palette: {
|
|
74
|
+
brand: { palette: { "--ui-accent": "#2f6f4f" }, fonts: { display: "GT Sectra" } },
|
|
56
75
|
prices: { standardCents: 100000, foundingDiscountCents: 10000 },
|
|
57
76
|
emails: { notificationEmail: "hello@example.com" },
|
|
58
77
|
});
|
|
@@ -76,4 +95,68 @@ export default {
|
|
|
76
95
|
};
|
|
77
96
|
```
|
|
78
97
|
|
|
98
|
+
## Adopting into an existing site
|
|
99
|
+
|
|
100
|
+
A real conversion (the site this was extracted from) went from a 2,094-line
|
|
101
|
+
worker to 6 lines and deleted ~2,500 lines. The order that worked:
|
|
102
|
+
|
|
103
|
+
1. **Config first, assert parity BEFORE deleting anything.** `defineChapter()`
|
|
104
|
+
your site, then diff `chapter.schema` against your existing schema in a test
|
|
105
|
+
and require byte-equality. That single assertion is what makes the deletion
|
|
106
|
+
safe rather than hopeful.
|
|
107
|
+
2. **Freeze the old schema as a test fixture** (e.g. `test/fixtures/legacy-schema.mjs`)
|
|
108
|
+
and keep asserting against it, so an upstream default change fails a test
|
|
109
|
+
instead of a live `provision`. It is the only durable guard against drift in a
|
|
110
|
+
generated schema.
|
|
111
|
+
3. **Swap provisioning** — `createChapterIntegration(chapter)`, inert until the
|
|
112
|
+
next provision run. It supplies schema + rules + seeds, so your
|
|
113
|
+
`odla.config.mjs` `db` block goes away entirely.
|
|
114
|
+
4. **Then the worker**, keeping every bespoke route as a host route
|
|
115
|
+
(`chapterWorker({ chapter, routes })`). Don't hand routes to chapter in the
|
|
116
|
+
same change as the framework swap.
|
|
117
|
+
5. **Override rather than inherit** wherever local behavior was a decision.
|
|
118
|
+
|
|
119
|
+
### Behavior deltas to audit
|
|
120
|
+
|
|
121
|
+
These bite silently — a smoke test won't catch them:
|
|
122
|
+
|
|
123
|
+
- **Per-field caps.** `application.defaultMaxLen` is 2000. If your form accepts
|
|
124
|
+
longer input, pass `maxLen` explicitly or the default starts rejecting it.
|
|
125
|
+
- **`services` default** is `["db","calendar","o11y"]`; `smoke` compares config
|
|
126
|
+
against the platform, so set `services` explicitly if you don't run the o11y
|
|
127
|
+
collector.
|
|
128
|
+
- **Which email fires from which route.** `adminNotification` fires from
|
|
129
|
+
`POST /api/applications` (on submit), `prepEmail` from `/api/schedule/book`,
|
|
130
|
+
`paymentConfirmation` from the Stripe webhook. If your policy differs (e.g.
|
|
131
|
+
notify on payment, not submit), override that route.
|
|
132
|
+
- **Account model.** `account: "invite"` (default) mints a Clerk invitation,
|
|
133
|
+
`"create"` makes the account server-side (so join can say the account is
|
|
134
|
+
ready), `"none"` skips it — all need `clerk_secret_key` in the tenant vault. A
|
|
135
|
+
site that wants server-side create must set `account: "create"`; inheriting the
|
|
136
|
+
default silently changes the model.
|
|
137
|
+
- **CRM projection points.** chapter projects the person on application submit
|
|
138
|
+
(`projectApplicant`), not on booking or on webhook status change. If you mirror
|
|
139
|
+
pipeline stage into the CRM, keep those routes.
|
|
140
|
+
- **Route names.** chapter serves `/api/config`, `/api/join-config`, etc. Alias
|
|
141
|
+
legacy names in ~4 lines with a host route in `chapterWorker({ routes })` rather
|
|
142
|
+
than rewriting pages.
|
|
143
|
+
|
|
144
|
+
### Install + scope notes
|
|
145
|
+
|
|
146
|
+
- **Skip `@odla-ai/auth-clerk` for a worker-only adoption.** The worker never
|
|
147
|
+
imports it (JWTs are verified with `jose` via `ctx.verifyUser`); it is an
|
|
148
|
+
optional peer for `chapter/ui` only. Reach for `@odla-ai/auth-clerk/invitations`
|
|
149
|
+
when you want to send your own branded invitation mail.
|
|
150
|
+
- **The admin surface is intentionally minimal** — `/api/admin/scheduling` +
|
|
151
|
+
`/api/admin/meetings`, plus `peopleSection`. Dashboard, billing, per-person
|
|
152
|
+
comms, approve/refund, etc. are your own host routes via the seam; don't plan
|
|
153
|
+
around them shipping.
|
|
154
|
+
|
|
155
|
+
### Verify from the types, not this file
|
|
156
|
+
|
|
157
|
+
At several releases a day, prose lags. Treat this README as intent and verify the
|
|
158
|
+
real surface from `dist/*.d.ts` and by grepping the built bundle for route
|
|
159
|
+
strings. One testing gotcha: Clerk session tokens expire in ~60s, so a script
|
|
160
|
+
that mints a JWT then runs a batch of curls must re-mint per batch.
|
|
161
|
+
|
|
79
162
|
MIT © odla
|
package/dist/index.cjs
CHANGED
|
@@ -20,7 +20,6 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
-
BOOKABLE_STATUSES: () => BOOKABLE_STATUSES,
|
|
24
23
|
SCHEDULING_DEFAULTS: () => SCHEDULING_DEFAULTS,
|
|
25
24
|
applicationBookingUpdate: () => applicationBookingUpdate,
|
|
26
25
|
applicationSummary: () => applicationSummary,
|
|
@@ -29,14 +28,18 @@ __export(index_exports, {
|
|
|
29
28
|
buildGroupSeed: () => buildGroupSeed,
|
|
30
29
|
canApprove: () => canApprove,
|
|
31
30
|
canBook: () => canBook,
|
|
32
|
-
canBookFrom: () => canBookFrom,
|
|
33
31
|
canChangeRole: () => canChangeRole,
|
|
34
32
|
canTransition: () => canTransition,
|
|
35
33
|
canceledPatch: () => canceledPatch,
|
|
36
34
|
chapterDb: () => chapterDb,
|
|
35
|
+
clerkInviteRequest: () => clerkInviteRequest,
|
|
36
|
+
clerkUserRequest: () => clerkUserRequest,
|
|
37
37
|
createChapterIntegration: () => createChapterIntegration,
|
|
38
|
+
createClerkInvitation: () => createClerkInvitation,
|
|
39
|
+
createClerkUser: () => createClerkUser,
|
|
38
40
|
defaultCrm: () => defaultCrm,
|
|
39
41
|
defineChapter: () => defineChapter,
|
|
42
|
+
emailGroupFrom: () => emailGroupFrom,
|
|
40
43
|
endForSlot: () => endForSlot,
|
|
41
44
|
findApplicationRef: () => findApplicationRef,
|
|
42
45
|
firstPaymentPatch: () => firstPaymentPatch,
|
|
@@ -44,6 +47,7 @@ __export(index_exports, {
|
|
|
44
47
|
introIdempotencyKey: () => introIdempotencyKey,
|
|
45
48
|
isAdminRole: () => isAdminRole,
|
|
46
49
|
isAlreadySent: () => isAlreadySent,
|
|
50
|
+
isReconcilable: () => isReconcilable,
|
|
47
51
|
isSlotAvailable: () => isSlotAvailable,
|
|
48
52
|
joinConfig: () => joinConfig,
|
|
49
53
|
meetingCreateRow: () => meetingCreateRow,
|
|
@@ -53,7 +57,9 @@ __export(index_exports, {
|
|
|
53
57
|
normalizeWebhookEvent: () => normalizeWebhookEvent,
|
|
54
58
|
paymentsReady: () => paymentsReady,
|
|
55
59
|
planDelivery: () => planDelivery,
|
|
60
|
+
projectApplicant: () => projectApplicant,
|
|
56
61
|
projectSharedRecord: () => projectSharedRecord,
|
|
62
|
+
reconcileMeetings: () => reconcileMeetings,
|
|
57
63
|
refundedPatch: () => refundedPatch,
|
|
58
64
|
render: () => render,
|
|
59
65
|
renderSummary: () => renderSummary,
|
|
@@ -64,6 +70,7 @@ __export(index_exports, {
|
|
|
64
70
|
resolvePipeline: () => resolvePipeline,
|
|
65
71
|
resolveScheduling: () => resolveScheduling,
|
|
66
72
|
roleFromClaim: () => roleFromClaim,
|
|
73
|
+
sendTemplated: () => sendTemplated,
|
|
67
74
|
sharedPersonInput: () => sharedPersonInput,
|
|
68
75
|
slotWindow: () => slotWindow,
|
|
69
76
|
stageIndex: () => stageIndex,
|
|
@@ -303,7 +310,7 @@ Warmly,
|
|
|
303
310
|
${name}`;
|
|
304
311
|
return {
|
|
305
312
|
adminNotification: {
|
|
306
|
-
subject: `New application
|
|
313
|
+
subject: `New application: {{firstName}} {{lastName}}`,
|
|
307
314
|
text: `A new application came in for ${name}.
|
|
308
315
|
|
|
309
316
|
Name: {{firstName}} {{lastName}}
|
|
@@ -322,7 +329,7 @@ Your membership payment is confirmed. We'll be in touch to schedule your intro c
|
|
|
322
329
|
Looking forward to our call at {{meetingTime}}. {{meetingLink}}${sign}`
|
|
323
330
|
},
|
|
324
331
|
onboardingInvite: {
|
|
325
|
-
subject: `You're in
|
|
332
|
+
subject: `You're in, ${name}`,
|
|
326
333
|
text: `Hi {{firstName}},
|
|
327
334
|
|
|
328
335
|
Welcome to ${name}. Your member area is here: {{membersUrl}}${sign}`
|
|
@@ -547,6 +554,10 @@ function defineChapter(config) {
|
|
|
547
554
|
const application = resolveApplication(config.application);
|
|
548
555
|
const { schema, rules } = chapterDb(mode, auth);
|
|
549
556
|
const services = config.services ?? ["db", "calendar", "o11y"];
|
|
557
|
+
const account = config.account ?? "invite";
|
|
558
|
+
if (account !== "invite" && account !== "create" && account !== "none") {
|
|
559
|
+
throw new Error(`defineChapter.account: must be "invite", "create", or "none" \u2014 got "${String(account)}"`);
|
|
560
|
+
}
|
|
550
561
|
const chapter = {
|
|
551
562
|
config,
|
|
552
563
|
id: id2,
|
|
@@ -559,6 +570,7 @@ function defineChapter(config) {
|
|
|
559
570
|
schema,
|
|
560
571
|
rules,
|
|
561
572
|
services,
|
|
573
|
+
account,
|
|
562
574
|
groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
|
|
563
575
|
};
|
|
564
576
|
if (config.url !== void 0) chapter.url = config.url;
|
|
@@ -585,7 +597,7 @@ function createChapterIntegration(chapter, options = {}) {
|
|
|
585
597
|
}
|
|
586
598
|
return {
|
|
587
599
|
id: "chapter",
|
|
588
|
-
title: `Chapter
|
|
600
|
+
title: `Chapter: ${chapter.name}`,
|
|
589
601
|
npm: "@odla-ai/chapter",
|
|
590
602
|
schema: {
|
|
591
603
|
entities: { ...crmDesc.schema.entities, ...chapter.schema.entities },
|
|
@@ -633,6 +645,72 @@ function planDelivery(input) {
|
|
|
633
645
|
return { deliver: true, transport, to, subject, text, redirected: redirect };
|
|
634
646
|
}
|
|
635
647
|
|
|
648
|
+
// src/notify.ts
|
|
649
|
+
async function sendTemplated(deps, input) {
|
|
650
|
+
const { emailLog: emailLog2 } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
|
|
651
|
+
const prior = Array.isArray(emailLog2) ? emailLog2 : [];
|
|
652
|
+
if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
|
|
653
|
+
const cloudflareReady = Boolean(deps.sender && deps.from);
|
|
654
|
+
const decision = planDelivery({
|
|
655
|
+
envName: deps.envName,
|
|
656
|
+
group: input.group,
|
|
657
|
+
template: input.template,
|
|
658
|
+
to: input.to,
|
|
659
|
+
vars: input.vars,
|
|
660
|
+
cloudflareReady,
|
|
661
|
+
force: input.force
|
|
662
|
+
});
|
|
663
|
+
if (!decision.deliver) return { sent: false, reason: decision.reason };
|
|
664
|
+
let error;
|
|
665
|
+
let messageId;
|
|
666
|
+
if (decision.transport === "cloudflare" && deps.sender && deps.from) {
|
|
667
|
+
try {
|
|
668
|
+
const res = await deps.sender.send({
|
|
669
|
+
from: deps.from,
|
|
670
|
+
to: [decision.to],
|
|
671
|
+
subject: decision.subject,
|
|
672
|
+
text: decision.text,
|
|
673
|
+
replyTo: input.group.replyTo
|
|
674
|
+
});
|
|
675
|
+
messageId = res.messageId;
|
|
676
|
+
} catch (e) {
|
|
677
|
+
error = e instanceof Error ? e.message : String(e);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const id2 = deps.newId();
|
|
681
|
+
const row = {
|
|
682
|
+
id: id2,
|
|
683
|
+
groupId: input.group.id,
|
|
684
|
+
to: decision.to,
|
|
685
|
+
template: input.template,
|
|
686
|
+
subject: decision.subject,
|
|
687
|
+
body: decision.text,
|
|
688
|
+
transport: decision.transport,
|
|
689
|
+
redirected: decision.redirected,
|
|
690
|
+
dedupeKey: input.dedupeKey,
|
|
691
|
+
sentAt: deps.now(),
|
|
692
|
+
...input.applicationId ? { applicationId: input.applicationId } : {},
|
|
693
|
+
...messageId ? { messageId } : {},
|
|
694
|
+
...error ? { error } : {}
|
|
695
|
+
};
|
|
696
|
+
await deps.db.transact([{ t: "update", ns: "emailLog", id: id2, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
|
|
697
|
+
return error ? { sent: false, reason: error } : { sent: true };
|
|
698
|
+
}
|
|
699
|
+
function emailGroupFrom(row) {
|
|
700
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
701
|
+
const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
|
|
702
|
+
return {
|
|
703
|
+
id: String(row.id),
|
|
704
|
+
name: String(row.name ?? ""),
|
|
705
|
+
replyTo: str(row.replyTo) ?? "",
|
|
706
|
+
debugEmail: str(row.debugEmail),
|
|
707
|
+
refundPolicyText: str(row.refundPolicyText),
|
|
708
|
+
commitmentText: str(row.commitmentText),
|
|
709
|
+
normsText: str(row.normsText),
|
|
710
|
+
emailTemplates: templates
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
636
714
|
// src/payments.ts
|
|
637
715
|
function parseSigHeader(header) {
|
|
638
716
|
const parts = {};
|
|
@@ -742,21 +820,73 @@ function sharedPersonInput(person) {
|
|
|
742
820
|
if (person.linkedin) input.linkedin = person.linkedin;
|
|
743
821
|
return input;
|
|
744
822
|
}
|
|
745
|
-
async function
|
|
746
|
-
const email =
|
|
747
|
-
const input = sharedPersonInput(person);
|
|
823
|
+
async function upsertPerson(deps, opts) {
|
|
824
|
+
const email = opts.email.toLowerCase();
|
|
748
825
|
const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
749
|
-
const { crm_record } = await deps.db.query({
|
|
750
|
-
crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
|
|
751
|
-
});
|
|
826
|
+
const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
|
|
752
827
|
const existing = crm_record?.[0];
|
|
753
828
|
if (existing && typeof existing.id === "string") {
|
|
754
|
-
await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input });
|
|
829
|
+
await (0, import_crm3.updateRecord)(crmDeps, { id: existing.id, input: opts.input });
|
|
755
830
|
return { recordId: existing.id };
|
|
756
831
|
}
|
|
757
|
-
const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input, mutationId:
|
|
832
|
+
const created = await (0, import_crm3.createRecord)(crmDeps, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
758
833
|
return { recordId: created.id };
|
|
759
834
|
}
|
|
835
|
+
async function projectSharedRecord(deps, person) {
|
|
836
|
+
return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
|
|
837
|
+
}
|
|
838
|
+
async function projectApplicant(deps, applicant) {
|
|
839
|
+
const input = sharedPersonInput({
|
|
840
|
+
email: applicant.email,
|
|
841
|
+
firstName: applicant.firstName,
|
|
842
|
+
lastName: applicant.lastName,
|
|
843
|
+
phone: applicant.phone,
|
|
844
|
+
linkedin: applicant.linkedin,
|
|
845
|
+
hubRecordId: applicant.applicationId
|
|
846
|
+
});
|
|
847
|
+
return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/clerk.ts
|
|
851
|
+
function clerkInviteRequest(input) {
|
|
852
|
+
return {
|
|
853
|
+
path: "/v1/invitations",
|
|
854
|
+
body: {
|
|
855
|
+
email_address: input.email,
|
|
856
|
+
notify: true,
|
|
857
|
+
...input.redirectUrl ? { redirect_url: input.redirectUrl } : {}
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
|
|
862
|
+
const { path, body } = clerkInviteRequest(input);
|
|
863
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
864
|
+
method: "POST",
|
|
865
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
866
|
+
body: JSON.stringify(body)
|
|
867
|
+
});
|
|
868
|
+
return { ok: res.ok, status: res.status };
|
|
869
|
+
}
|
|
870
|
+
function clerkUserRequest(input) {
|
|
871
|
+
return {
|
|
872
|
+
path: "/v1/users",
|
|
873
|
+
body: {
|
|
874
|
+
email_address: [input.email],
|
|
875
|
+
skip_password_requirement: true,
|
|
876
|
+
...input.firstName ? { first_name: input.firstName } : {},
|
|
877
|
+
...input.lastName ? { last_name: input.lastName } : {}
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
async function createClerkUser(secretKey, input, fetchImpl = fetch) {
|
|
882
|
+
const { path, body } = clerkUserRequest(input);
|
|
883
|
+
const res = await fetchImpl(`https://api.clerk.com${path}`, {
|
|
884
|
+
method: "POST",
|
|
885
|
+
headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
|
|
886
|
+
body: JSON.stringify(body)
|
|
887
|
+
});
|
|
888
|
+
return { ok: res.ok, status: res.status };
|
|
889
|
+
}
|
|
760
890
|
|
|
761
891
|
// src/session.ts
|
|
762
892
|
function applicationSummary(app) {
|
|
@@ -822,6 +952,38 @@ function brandTokens(brand) {
|
|
|
822
952
|
` : "";
|
|
823
953
|
}
|
|
824
954
|
|
|
955
|
+
// src/reconcile.ts
|
|
956
|
+
function isReconcilable(meeting, now) {
|
|
957
|
+
return meeting.status === "scheduled" && Boolean(meeting.googleEventId) && (meeting.startAt ?? 0) > now - 36e5;
|
|
958
|
+
}
|
|
959
|
+
function reconcileMeetings(meetings2, events, now) {
|
|
960
|
+
const byEvent = new Map(events.map((e) => [e.eventId, e]));
|
|
961
|
+
const decisions = [];
|
|
962
|
+
for (const m of meetings2) {
|
|
963
|
+
if (!isReconcilable(m, now) || !m.googleEventId) continue;
|
|
964
|
+
const g = byEvent.get(m.googleEventId);
|
|
965
|
+
if (!g || g.status === "cancelled") {
|
|
966
|
+
decisions.push({
|
|
967
|
+
meetingId: m.id,
|
|
968
|
+
applicationId: m.applicationId,
|
|
969
|
+
kind: "cancelled",
|
|
970
|
+
meetingPatch: { status: "cancelled", drift: "none", adoptedFromGoogleAt: now },
|
|
971
|
+
applicationPatch: { meetingAt: 0, meetingLink: "" }
|
|
972
|
+
});
|
|
973
|
+
} else if (g.startAt !== void 0 && g.startAt !== m.startAt) {
|
|
974
|
+
const duration = (m.endAt ?? 0) - (m.startAt ?? 0);
|
|
975
|
+
decisions.push({
|
|
976
|
+
meetingId: m.id,
|
|
977
|
+
applicationId: m.applicationId,
|
|
978
|
+
kind: "moved",
|
|
979
|
+
meetingPatch: { startAt: g.startAt, endAt: g.endAt ?? g.startAt + duration, drift: "none", adoptedFromGoogleAt: now },
|
|
980
|
+
applicationPatch: { meetingAt: g.startAt }
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return decisions;
|
|
985
|
+
}
|
|
986
|
+
|
|
825
987
|
// src/scheduling.ts
|
|
826
988
|
var SCHEDULING_DEFAULTS = {
|
|
827
989
|
slotMinutes: 45,
|
|
@@ -868,10 +1030,6 @@ function resolveScheduling(config) {
|
|
|
868
1030
|
if (typeof c.summaryTemplate !== "string") fail("summaryTemplate must be a string");
|
|
869
1031
|
return { ...c, days };
|
|
870
1032
|
}
|
|
871
|
-
var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
|
|
872
|
-
function canBookFrom(status) {
|
|
873
|
-
return BOOKABLE_STATUSES.includes(status);
|
|
874
|
-
}
|
|
875
1033
|
function slotWindow(now, windowDays) {
|
|
876
1034
|
return { from: now, to: now + windowDays * 864e5 };
|
|
877
1035
|
}
|