@odla-ai/chapter 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,345 @@
1
+ // src/config.ts
2
+ import { defineCrm } from "@odla-ai/crm";
3
+
4
+ // src/schema.ts
5
+ function attr(type, flags = {}) {
6
+ return {
7
+ type,
8
+ unique: flags.unique ?? false,
9
+ indexed: flags.indexed ?? false,
10
+ optional: flags.optional ?? false
11
+ };
12
+ }
13
+ var id = () => attr("string", { unique: true, indexed: true });
14
+ var admins = {
15
+ attrs: {
16
+ id: id(),
17
+ email: attr("string", { unique: true, indexed: true }),
18
+ name: attr("string", { optional: true }),
19
+ note: attr("string", { optional: true })
20
+ }
21
+ };
22
+ var applications = {
23
+ attrs: {
24
+ id: id(),
25
+ firstName: attr("string"),
26
+ lastName: attr("string"),
27
+ email: attr("string", { indexed: true }),
28
+ referral: attr("string"),
29
+ referralName: attr("string", { optional: true }),
30
+ whoYouAre: attr("string"),
31
+ focus: attr("json"),
32
+ linkedin: attr("string", { optional: true }),
33
+ message: attr("string"),
34
+ status: attr("string", { indexed: true }),
35
+ createdAt: attr("number", { indexed: true }),
36
+ meetingAt: attr("number", { indexed: true, optional: true }),
37
+ meetingLink: attr("string", { optional: true }),
38
+ clerkUserId: attr("string", { indexed: true, optional: true }),
39
+ phone: attr("string", { optional: true }),
40
+ state: attr("string", { optional: true }),
41
+ groupId: attr("string", { indexed: true, optional: true }),
42
+ stripeCustomerId: attr("string", { indexed: true, optional: true }),
43
+ stripeSubscriptionId: attr("string", { indexed: true, optional: true }),
44
+ renewalAt: attr("number", { optional: true }),
45
+ disclaimerAckAt: attr("number", { optional: true }),
46
+ refundPolicyAckAt: attr("number", { optional: true }),
47
+ prepEmailSentAt: attr("number", { optional: true }),
48
+ canceled: attr("boolean", { optional: true })
49
+ }
50
+ };
51
+ var groups = {
52
+ attrs: {
53
+ id: id(),
54
+ name: attr("string"),
55
+ standardPriceCents: attr("number"),
56
+ foundingDiscountCents: attr("number"),
57
+ stripePriceId: attr("string", { optional: true }),
58
+ stripePublishableKey: attr("string", { optional: true }),
59
+ notificationEmail: attr("string"),
60
+ replyTo: attr("string"),
61
+ debugEmail: attr("string", { optional: true }),
62
+ calendarLink: attr("string", { optional: true }),
63
+ disclaimerText: attr("string"),
64
+ refundPolicyText: attr("string"),
65
+ trustCopy: attr("string"),
66
+ commitmentText: attr("string", { optional: true }),
67
+ normsText: attr("string", { optional: true }),
68
+ emailTemplates: attr("json"),
69
+ schedulingJson: attr("json", { optional: true }),
70
+ createdAt: attr("number", { indexed: true })
71
+ }
72
+ };
73
+ var meetings = {
74
+ attrs: {
75
+ id: id(),
76
+ applicationId: attr("string", { indexed: true }),
77
+ groupId: attr("string", { indexed: true }),
78
+ startAt: attr("number", { indexed: true }),
79
+ endAt: attr("number"),
80
+ timezone: attr("string"),
81
+ status: attr("string", { indexed: true }),
82
+ googleEventId: attr("string", { indexed: true, optional: true }),
83
+ meetUrl: attr("string", { optional: true }),
84
+ htmlLink: attr("string", { optional: true }),
85
+ drift: attr("string", { indexed: true, optional: true }),
86
+ driftGoogleStartAt: attr("number", { optional: true }),
87
+ driftDetectedAt: attr("number", { optional: true }),
88
+ adoptedFromGoogleAt: attr("number", { optional: true }),
89
+ createdAt: attr("number", { indexed: true })
90
+ }
91
+ };
92
+ var emailLog = {
93
+ attrs: {
94
+ id: id(),
95
+ groupId: attr("string", { indexed: true }),
96
+ applicationId: attr("string", { indexed: true, optional: true }),
97
+ to: attr("string", { indexed: true }),
98
+ template: attr("string", { indexed: true }),
99
+ subject: attr("string"),
100
+ body: attr("string", { optional: true }),
101
+ transport: attr("string"),
102
+ messageId: attr("string", { optional: true }),
103
+ redirected: attr("boolean", { optional: true }),
104
+ dedupeKey: attr("string", { indexed: true, optional: true }),
105
+ error: attr("string", { optional: true }),
106
+ sentAt: attr("number", { indexed: true })
107
+ }
108
+ };
109
+ function chapterDb(mode) {
110
+ const entities = mode === "hub" ? { admins } : { admins, applications, groups, meetings, emailLog };
111
+ const schema = { entities, links: {} };
112
+ const rules = {};
113
+ for (const ns of Object.keys(entities)) {
114
+ rules[ns] = { view: "false", create: "false", update: "false", delete: "false" };
115
+ }
116
+ return { schema, rules };
117
+ }
118
+
119
+ // src/defaults.ts
120
+ var TEMPLATES = {
121
+ personal: {
122
+ class: "transactional",
123
+ vars: ["firstName", "subject", "body"],
124
+ defaults: { subject: "{{subject}}", text: "{{body}}" }
125
+ },
126
+ announcement: {
127
+ class: "marketing",
128
+ vars: ["firstName", "subject", "body", "unsubscribeUrl"],
129
+ defaults: { subject: "{{subject}}", text: "{{body}}\n\n{{unsubscribeUrl}}" }
130
+ }
131
+ };
132
+ function personType() {
133
+ return {
134
+ label: "Person",
135
+ labelPlural: "People",
136
+ nameField: "name",
137
+ emailField: "email",
138
+ fields: {
139
+ name: { type: "string", label: "Name", required: true },
140
+ email: { type: "email", label: "Email" },
141
+ firstName: { type: "string", label: "First name" },
142
+ lastName: { type: "string", label: "Last name" },
143
+ phone: { type: "string", label: "Phone" },
144
+ state: { type: "string", label: "State", slot: "s1" },
145
+ whoYouAre: { type: "string", label: "Who they are", slot: "s2" },
146
+ referral: { type: "string", label: "Referral source", slot: "s3" },
147
+ linkedin: { type: "string", label: "LinkedIn" },
148
+ focus: { type: "json", label: "Focus areas" },
149
+ message: { type: "string", label: "Intro message" }
150
+ },
151
+ pipeline: {
152
+ stages: [
153
+ { id: "submitted", label: "Submitted" },
154
+ { id: "paid_pending_vetting", label: "Paid, pending vetting" },
155
+ { id: "call_scheduled", label: "Call scheduled" },
156
+ { id: "interviewed", label: "Interviewed" },
157
+ { id: "approved", label: "Approved" },
158
+ { id: "declined", label: "Declined" },
159
+ { id: "refunded", label: "Refunded" }
160
+ ]
161
+ },
162
+ facets: { identity: true, email: true, rank: "manual" }
163
+ };
164
+ }
165
+ function companyType() {
166
+ return {
167
+ label: "Business",
168
+ labelPlural: "Businesses",
169
+ nameField: "name",
170
+ fields: {
171
+ name: { type: "string", label: "Name", required: true },
172
+ domain: { type: "string", label: "Domain / website", slot: "s1" },
173
+ industry: { type: "string", label: "Industry", slot: "s2" },
174
+ location: { type: "string", label: "Location", slot: "s3" },
175
+ chapter: { type: "string", label: "Chapter", slot: "s4" },
176
+ linkedin: { type: "string", label: "LinkedIn" },
177
+ notes: { type: "string", label: "Notes" }
178
+ },
179
+ facets: { rank: "manual" }
180
+ };
181
+ }
182
+ function defaultCrm(mode) {
183
+ if (mode === "hub") {
184
+ return {
185
+ types: { person: personType(), company: companyType() },
186
+ relations: { works_at: { from: "person", to: "company", label: "works at", reverseLabel: "team" } },
187
+ templates: TEMPLATES
188
+ };
189
+ }
190
+ return {
191
+ types: { person: personType() },
192
+ templates: TEMPLATES
193
+ };
194
+ }
195
+
196
+ // src/group.ts
197
+ var DEFAULT_SCHEDULING = {
198
+ slotMinutes: 45,
199
+ days: [1, 2, 3, 4, 5],
200
+ startHour: 9,
201
+ endHour: 17,
202
+ timezone: "America/Los_Angeles",
203
+ minNoticeHours: 24,
204
+ windowDays: 14
205
+ };
206
+ function defaultEmailTemplates(name) {
207
+ const sign = `
208
+
209
+ Warmly,
210
+ ${name}`;
211
+ return {
212
+ adminNotification: {
213
+ subject: `New application \u2014 {{firstName}} {{lastName}}`,
214
+ text: `A new application came in for ${name}.
215
+
216
+ Name: {{firstName}} {{lastName}}
217
+ Email: {{email}}`
218
+ },
219
+ paymentConfirmation: {
220
+ subject: `Welcome to ${name}`,
221
+ text: `Hi {{firstName}},
222
+
223
+ Your membership payment is confirmed. We'll be in touch to schedule your intro call.${sign}`
224
+ },
225
+ prepEmail: {
226
+ subject: `Your ${name} intro call`,
227
+ text: `Hi {{firstName}},
228
+
229
+ Looking forward to our call at {{meetingTime}}. {{meetingLink}}${sign}`
230
+ },
231
+ onboardingInvite: {
232
+ subject: `You're in \u2014 ${name}`,
233
+ text: `Hi {{firstName}},
234
+
235
+ Welcome to ${name}. Your member area is here: {{membersUrl}}${sign}`
236
+ }
237
+ };
238
+ }
239
+ function buildGroupSeed(config) {
240
+ const prices = config.prices;
241
+ const emails = config.emails;
242
+ const policy = config.policy ?? {};
243
+ const scheduling = {
244
+ ...DEFAULT_SCHEDULING,
245
+ ...config.scheduling ?? {},
246
+ summaryTemplate: config.scheduling?.summaryTemplate ?? `${config.name}: introduction call with {{firstName}} {{lastName}}`
247
+ };
248
+ const row = {
249
+ id: config.id,
250
+ name: config.name,
251
+ standardPriceCents: prices?.standardCents ?? 0,
252
+ foundingDiscountCents: prices?.foundingDiscountCents ?? 0,
253
+ notificationEmail: emails?.notificationEmail ?? "",
254
+ replyTo: emails?.replyTo ?? emails?.notificationEmail ?? "",
255
+ disclaimerText: policy.disclaimerText ?? "",
256
+ refundPolicyText: policy.refundPolicyText ?? "",
257
+ trustCopy: policy.trustCopy ?? "",
258
+ emailTemplates: emails?.templates ?? defaultEmailTemplates(config.name),
259
+ schedulingJson: scheduling
260
+ };
261
+ if (emails?.debugEmail) row.debugEmail = emails.debugEmail;
262
+ if (policy.commitmentText) row.commitmentText = policy.commitmentText;
263
+ if (policy.normsText) row.normsText = policy.normsText;
264
+ return row;
265
+ }
266
+
267
+ // src/config.ts
268
+ var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
269
+ function isResolvedCrm(x) {
270
+ return !!x && typeof x === "object" && "prepare" in x && typeof x.prepare === "function";
271
+ }
272
+ function defineChapter(config) {
273
+ if (!config || typeof config !== "object") throw new Error("defineChapter: a config object is required");
274
+ const { id: id2, name } = config;
275
+ if (typeof id2 !== "string" || !SLUG.test(id2)) {
276
+ throw new Error(`defineChapter.id: must be a lowercase slug [a-z0-9-] (2-63 chars) \u2014 got ${JSON.stringify(id2)}`);
277
+ }
278
+ if (typeof name !== "string" || name.trim() === "") throw new Error("defineChapter.name: a non-empty name is required");
279
+ const mode = config.mode ?? "chapter";
280
+ if (mode !== "chapter" && mode !== "hub") throw new Error(`defineChapter.mode: must be "chapter" or "hub" \u2014 got ${JSON.stringify(config.mode)}`);
281
+ const crm = isResolvedCrm(config.crm) ? config.crm : defineCrm(config.crm ?? defaultCrm(mode));
282
+ if (mode === "chapter") {
283
+ if (!config.emails || typeof config.emails.notificationEmail !== "string" || config.emails.notificationEmail === "") {
284
+ throw new Error("defineChapter.emails.notificationEmail: required in chapter mode");
285
+ }
286
+ if (!config.prices || typeof config.prices.standardCents !== "number") {
287
+ throw new Error("defineChapter.prices.standardCents: required in chapter mode");
288
+ }
289
+ }
290
+ const { schema, rules } = chapterDb(mode);
291
+ const services = config.services ?? ["db", "calendar", "o11y"];
292
+ const chapter = {
293
+ config,
294
+ id: id2,
295
+ name,
296
+ mode,
297
+ crm,
298
+ schema,
299
+ rules,
300
+ services,
301
+ groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
302
+ };
303
+ if (config.url !== void 0) chapter.url = config.url;
304
+ return chapter;
305
+ }
306
+
307
+ // src/descriptor.ts
308
+ import { createCrmIntegration } from "@odla-ai/crm";
309
+ function createChapterIntegration(chapter, options = {}) {
310
+ const basePath = options.basePath ?? "/api/crm";
311
+ const now = options.now ?? Date.now();
312
+ const emails = chapter.config.emails;
313
+ const crmDesc = createCrmIntegration(chapter.crm, {
314
+ basePath,
315
+ now,
316
+ ...emails?.notificationEmail ? { notificationEmail: emails.notificationEmail } : {},
317
+ ...emails?.replyTo ? { replyTo: emails.replyTo } : {},
318
+ ...emails?.debugEmail ? { debugEmail: emails.debugEmail } : {}
319
+ });
320
+ const seeds = [...crmDesc.seeds ?? []];
321
+ const group = chapter.groupSeed();
322
+ if (group) {
323
+ seeds.push({ id: "group", ns: "groups", key: { attr: "id", value: chapter.id }, attrs: { ...group, createdAt: now } });
324
+ }
325
+ return {
326
+ id: "chapter",
327
+ title: `Chapter \u2014 ${chapter.name}`,
328
+ npm: "@odla-ai/chapter",
329
+ schema: {
330
+ entities: { ...crmDesc.schema.entities, ...chapter.schema.entities },
331
+ links: { ...crmDesc.schema.links, ...chapter.schema.links }
332
+ },
333
+ rules: { ...crmDesc.rules, ...chapter.rules },
334
+ seeds,
335
+ probes: [...crmDesc.probes ?? []]
336
+ };
337
+ }
338
+ export {
339
+ buildGroupSeed,
340
+ chapterDb,
341
+ createChapterIntegration,
342
+ defaultCrm,
343
+ defineChapter
344
+ };
345
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts","../src/schema.ts","../src/defaults.ts","../src/group.ts","../src/descriptor.ts"],"sourcesContent":["// defineChapter — validate-at-import config, reusing @odla-ai/crm's defineCrm.\n// A bad config throws here (startup), never at request time.\nimport { defineCrm } from \"@odla-ai/crm\";\nimport type { CrmConfig, Crm } from \"@odla-ai/crm\";\nimport type { Chapter, ChapterConfig, ChapterMode } from \"./types\";\nimport { chapterDb } from \"./schema\";\nimport { defaultCrm } from \"./defaults\";\nimport { buildGroupSeed } from \"./group\";\n\nconst SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;\n\nfunction isResolvedCrm(x: CrmConfig | Crm | undefined): x is Crm {\n return (\n !!x &&\n typeof x === \"object\" &&\n \"prepare\" in x &&\n typeof (x as { prepare?: unknown }).prepare === \"function\"\n );\n}\n\n/**\n * Validate a chapter/hub config and resolve its engine — the CRM, the chapter's\n * odla-db schema/rules, and the seed groups row. Throws at import on a bad\n * config (bad slug, wrong mode, missing chapter-mode prices/emails), never at\n * request time.\n */\nexport function defineChapter(config: ChapterConfig): Chapter {\n if (!config || typeof config !== \"object\") throw new Error(\"defineChapter: a config object is required\");\n const { id, name } = config;\n if (typeof id !== \"string\" || !SLUG.test(id)) {\n throw new Error(`defineChapter.id: must be a lowercase slug [a-z0-9-] (2-63 chars) — got ${JSON.stringify(id)}`);\n }\n if (typeof name !== \"string\" || name.trim() === \"\") throw new Error(\"defineChapter.name: a non-empty name is required\");\n\n const mode: ChapterMode = config.mode ?? \"chapter\";\n if (mode !== \"chapter\" && mode !== \"hub\") throw new Error(`defineChapter.mode: must be \"chapter\" or \"hub\" — got ${JSON.stringify(config.mode)}`);\n\n const crm: Crm = isResolvedCrm(config.crm) ? config.crm : defineCrm(config.crm ?? defaultCrm(mode));\n\n if (mode === \"chapter\") {\n if (!config.emails || typeof config.emails.notificationEmail !== \"string\" || config.emails.notificationEmail === \"\") {\n throw new Error(\"defineChapter.emails.notificationEmail: required in chapter mode\");\n }\n if (!config.prices || typeof config.prices.standardCents !== \"number\") {\n throw new Error(\"defineChapter.prices.standardCents: required in chapter mode\");\n }\n }\n\n const { schema, rules } = chapterDb(mode);\n const services = config.services ?? [\"db\", \"calendar\", \"o11y\"];\n\n const chapter: Chapter = {\n config,\n id,\n name,\n mode,\n crm,\n schema,\n rules,\n services,\n groupSeed: () => (mode === \"chapter\" ? buildGroupSeed(config) : null),\n };\n if (config.url !== undefined) chapter.url = config.url;\n return chapter;\n}\n","// The chapter's own odla-db namespaces: applications, groups, meetings, and\n// emailLog, plus a single default-deny `admins` allowlist. The `crm_*`\n// namespaces are contributed separately by @odla-ai/crm's integration and\n// merged by the CLI.\nimport type { Attr, AttrType, DbRules, DbSchema, Entity, ChapterMode } from \"./types\";\n\nfunction attr(type: AttrType, flags: { unique?: boolean; indexed?: boolean; optional?: boolean } = {}): Attr {\n return {\n type,\n unique: flags.unique ?? false,\n indexed: flags.indexed ?? false,\n optional: flags.optional ?? false,\n };\n}\nconst id = (): Attr => attr(\"string\", { unique: true, indexed: true });\n\n// The allowlist that gates admin access in BOTH modes. Studio-write-only:\n// deny-all, and no worker route ever writes it. Creation time is odla-db's\n// built-in $createdAt. One row per admin, keyed by lowercased email.\nconst admins: Entity = {\n attrs: {\n id: id(),\n email: attr(\"string\", { unique: true, indexed: true }),\n name: attr(\"string\", { optional: true }),\n note: attr(\"string\", { optional: true }),\n },\n};\n\n// One row per membership application (chapter mode). Field names mirror the\n// join form. status pipeline drives the provisional -> member promotion.\nconst applications: Entity = {\n attrs: {\n id: id(),\n firstName: attr(\"string\"),\n lastName: attr(\"string\"),\n email: attr(\"string\", { indexed: true }),\n referral: attr(\"string\"),\n referralName: attr(\"string\", { optional: true }),\n whoYouAre: attr(\"string\"),\n focus: attr(\"json\"),\n linkedin: attr(\"string\", { optional: true }),\n message: attr(\"string\"),\n status: attr(\"string\", { indexed: true }),\n createdAt: attr(\"number\", { indexed: true }),\n meetingAt: attr(\"number\", { indexed: true, optional: true }),\n meetingLink: attr(\"string\", { optional: true }),\n clerkUserId: attr(\"string\", { indexed: true, optional: true }),\n phone: attr(\"string\", { optional: true }),\n state: attr(\"string\", { optional: true }),\n groupId: attr(\"string\", { indexed: true, optional: true }),\n stripeCustomerId: attr(\"string\", { indexed: true, optional: true }),\n stripeSubscriptionId: attr(\"string\", { indexed: true, optional: true }),\n renewalAt: attr(\"number\", { optional: true }),\n disclaimerAckAt: attr(\"number\", { optional: true }),\n refundPolicyAckAt: attr(\"number\", { optional: true }),\n prepEmailSentAt: attr(\"number\", { optional: true }),\n canceled: attr(\"boolean\", { optional: true }),\n },\n};\n\n// Per-group settings — prices, policy copy, email templates, scheduling — never\n// in code. Seeded once from the defineChapter config (see group.ts).\nconst groups: Entity = {\n attrs: {\n id: id(),\n name: attr(\"string\"),\n standardPriceCents: attr(\"number\"),\n foundingDiscountCents: attr(\"number\"),\n stripePriceId: attr(\"string\", { optional: true }),\n stripePublishableKey: attr(\"string\", { optional: true }),\n notificationEmail: attr(\"string\"),\n replyTo: attr(\"string\"),\n debugEmail: attr(\"string\", { optional: true }),\n calendarLink: attr(\"string\", { optional: true }),\n disclaimerText: attr(\"string\"),\n refundPolicyText: attr(\"string\"),\n trustCopy: attr(\"string\"),\n commitmentText: attr(\"string\", { optional: true }),\n normsText: attr(\"string\", { optional: true }),\n emailTemplates: attr(\"json\"),\n schedulingJson: attr(\"json\", { optional: true }),\n createdAt: attr(\"number\", { indexed: true }),\n },\n};\n\n// Intro-call meetings: the source of truth for scheduling; Google Calendar is a\n// projection. Drift fields record when Google disagrees with us.\nconst meetings: Entity = {\n attrs: {\n id: id(),\n applicationId: attr(\"string\", { indexed: true }),\n groupId: attr(\"string\", { indexed: true }),\n startAt: attr(\"number\", { indexed: true }),\n endAt: attr(\"number\"),\n timezone: attr(\"string\"),\n status: attr(\"string\", { indexed: true }),\n googleEventId: attr(\"string\", { indexed: true, optional: true }),\n meetUrl: attr(\"string\", { optional: true }),\n htmlLink: attr(\"string\", { optional: true }),\n drift: attr(\"string\", { indexed: true, optional: true }),\n driftGoogleStartAt: attr(\"number\", { optional: true }),\n driftDetectedAt: attr(\"number\", { optional: true }),\n adoptedFromGoogleAt: attr(\"number\", { optional: true }),\n createdAt: attr(\"number\", { indexed: true }),\n },\n};\n\n// Audit of every transactional send.\nconst emailLog: Entity = {\n attrs: {\n id: id(),\n groupId: attr(\"string\", { indexed: true }),\n applicationId: attr(\"string\", { indexed: true, optional: true }),\n to: attr(\"string\", { indexed: true }),\n template: attr(\"string\", { indexed: true }),\n subject: attr(\"string\"),\n body: attr(\"string\", { optional: true }),\n transport: attr(\"string\"),\n messageId: attr(\"string\", { optional: true }),\n redirected: attr(\"boolean\", { optional: true }),\n dedupeKey: attr(\"string\", { indexed: true, optional: true }),\n error: attr(\"string\", { optional: true }),\n sentAt: attr(\"number\", { indexed: true }),\n },\n};\n\n/** The chapter's own schema + deny-all rules for a mode. `hub` needs only the\n * `admins` allowlist (its records live in `crm_*`); `chapter` adds the\n * operational membership tables. */\nexport function chapterDb(mode: ChapterMode): { schema: DbSchema; rules: DbRules } {\n const entities: Record<string, Entity> =\n mode === \"hub\" ? { admins } : { admins, applications, groups, meetings, emailLog };\n const schema: DbSchema = { entities, links: {} };\n const rules: DbRules = {};\n for (const ns of Object.keys(entities)) {\n rules[ns] = { view: \"false\", create: \"false\", update: \"false\", delete: \"false\" };\n }\n return { schema, rules };\n}\n","// Per-mode default CRM configs. Consumers pass their own via defineChapter's\n// `crm`; these are sensible starting points. `chapter` is a person lead pipeline\n// (fed from `applications`); `hub` adds businesses (people + companies).\nimport type { CrmConfig } from \"@odla-ai/crm\";\nimport type { ChapterMode } from \"./types\";\n\nconst TEMPLATES: CrmConfig[\"templates\"] = {\n personal: {\n class: \"transactional\",\n vars: [\"firstName\", \"subject\", \"body\"],\n defaults: { subject: \"{{subject}}\", text: \"{{body}}\" },\n },\n announcement: {\n class: \"marketing\",\n vars: [\"firstName\", \"subject\", \"body\", \"unsubscribeUrl\"],\n defaults: { subject: \"{{subject}}\", text: \"{{body}}\\n\\n{{unsubscribeUrl}}\" },\n },\n};\n\nfunction personType(): NonNullable<CrmConfig[\"types\"]>[string] {\n return {\n label: \"Person\",\n labelPlural: \"People\",\n nameField: \"name\",\n emailField: \"email\",\n fields: {\n name: { type: \"string\", label: \"Name\", required: true },\n email: { type: \"email\", label: \"Email\" },\n firstName: { type: \"string\", label: \"First name\" },\n lastName: { type: \"string\", label: \"Last name\" },\n phone: { type: \"string\", label: \"Phone\" },\n state: { type: \"string\", label: \"State\", slot: \"s1\" },\n whoYouAre: { type: \"string\", label: \"Who they are\", slot: \"s2\" },\n referral: { type: \"string\", label: \"Referral source\", slot: \"s3\" },\n linkedin: { type: \"string\", label: \"LinkedIn\" },\n focus: { type: \"json\", label: \"Focus areas\" },\n message: { type: \"string\", label: \"Intro message\" },\n },\n pipeline: {\n stages: [\n { id: \"submitted\", label: \"Submitted\" },\n { id: \"paid_pending_vetting\", label: \"Paid, pending vetting\" },\n { id: \"call_scheduled\", label: \"Call scheduled\" },\n { id: \"interviewed\", label: \"Interviewed\" },\n { id: \"approved\", label: \"Approved\" },\n { id: \"declined\", label: \"Declined\" },\n { id: \"refunded\", label: \"Refunded\" },\n ],\n },\n facets: { identity: true, email: true, rank: \"manual\" },\n };\n}\n\nfunction companyType(): NonNullable<CrmConfig[\"types\"]>[string] {\n return {\n label: \"Business\",\n labelPlural: \"Businesses\",\n nameField: \"name\",\n fields: {\n name: { type: \"string\", label: \"Name\", required: true },\n domain: { type: \"string\", label: \"Domain / website\", slot: \"s1\" },\n industry: { type: \"string\", label: \"Industry\", slot: \"s2\" },\n location: { type: \"string\", label: \"Location\", slot: \"s3\" },\n chapter: { type: \"string\", label: \"Chapter\", slot: \"s4\" },\n linkedin: { type: \"string\", label: \"LinkedIn\" },\n notes: { type: \"string\", label: \"Notes\" },\n },\n facets: { rank: \"manual\" },\n };\n}\n\n/** The per-mode default CRM config: `chapter` = a person lead pipeline;\n * `hub` = people + businesses with a works_at relation. */\nexport function defaultCrm(mode: ChapterMode): CrmConfig {\n if (mode === \"hub\") {\n return {\n types: { person: personType(), company: companyType() },\n relations: { works_at: { from: \"person\", to: \"company\", label: \"works at\", reverseLabel: \"team\" } },\n templates: TEMPLATES,\n };\n }\n return {\n types: { person: personType() },\n templates: TEMPLATES,\n };\n}\n","// Build the seed `groups` row from a defineChapter config. This is the whole\n// per-chapter payload the worker reads at runtime (prices/policy/emails/\n// scheduling), so nothing brand-specific is hardcoded in the worker. createdAt\n// is stamped by the integration/seed layer, not here.\nimport type { ChapterConfig, ChapterScheduling } from \"./types\";\n\nconst DEFAULT_SCHEDULING: Required<Omit<ChapterScheduling, \"summaryTemplate\">> = {\n slotMinutes: 45,\n days: [1, 2, 3, 4, 5],\n startHour: 9,\n endHour: 17,\n timezone: \"America/Los_Angeles\",\n minNoticeHours: 24,\n windowDays: 14,\n};\n\nfunction defaultEmailTemplates(name: string): Record<string, { subject: string; text: string }> {\n const sign = `\\n\\nWarmly,\\n${name}`;\n return {\n adminNotification: {\n subject: `New application — {{firstName}} {{lastName}}`,\n text: `A new application came in for ${name}.\\n\\nName: {{firstName}} {{lastName}}\\nEmail: {{email}}`,\n },\n paymentConfirmation: {\n subject: `Welcome to ${name}`,\n text: `Hi {{firstName}},\\n\\nYour membership payment is confirmed. We'll be in touch to schedule your intro call.${sign}`,\n },\n prepEmail: {\n subject: `Your ${name} intro call`,\n text: `Hi {{firstName}},\\n\\nLooking forward to our call at {{meetingTime}}. {{meetingLink}}${sign}`,\n },\n onboardingInvite: {\n subject: `You're in — ${name}`,\n text: `Hi {{firstName}},\\n\\nWelcome to ${name}. Your member area is here: {{membersUrl}}${sign}`,\n },\n };\n}\n\n/** The `groups` row (attrs) for `chapter` mode. Missing config falls back to\n * empty copy / defaults, so a minimal config still provisions cleanly. */\nexport function buildGroupSeed(config: ChapterConfig): Record<string, unknown> {\n const prices = config.prices;\n const emails = config.emails;\n const policy = config.policy ?? {};\n const scheduling = {\n ...DEFAULT_SCHEDULING,\n ...(config.scheduling ?? {}),\n summaryTemplate:\n config.scheduling?.summaryTemplate ?? `${config.name}: introduction call with {{firstName}} {{lastName}}`,\n };\n const row: Record<string, unknown> = {\n id: config.id,\n name: config.name,\n standardPriceCents: prices?.standardCents ?? 0,\n foundingDiscountCents: prices?.foundingDiscountCents ?? 0,\n notificationEmail: emails?.notificationEmail ?? \"\",\n replyTo: emails?.replyTo ?? emails?.notificationEmail ?? \"\",\n disclaimerText: policy.disclaimerText ?? \"\",\n refundPolicyText: policy.refundPolicyText ?? \"\",\n trustCopy: policy.trustCopy ?? \"\",\n emailTemplates: emails?.templates ?? defaultEmailTemplates(config.name),\n schedulingJson: scheduling,\n };\n if (emails?.debugEmail) row.debugEmail = emails.debugEmail;\n if (policy.commitmentText) row.commitmentText = policy.commitmentText;\n if (policy.normsText) row.normsText = policy.normsText;\n return row;\n}\n","// The CLI-consumable provisioning descriptor. It composes @odla-ai/crm's\n// integration (crm_* namespaces + crm_config seed + route probe) with the\n// chapter's own namespaces and a guarded `groups`-row seed, so a site's\n// odla.config.mjs lists ONE integration. The CLI reads it structurally\n// (matching OdlaIntegration) and merges schema/rules by namespace.\nimport { createCrmIntegration } from \"@odla-ai/crm\";\nimport type { Chapter } from \"./types\";\n\n/** Options for {@link createChapterIntegration}. */\nexport interface ChapterIntegrationOptions {\n /** CRM route mount point. Default \"/api/crm\". */\n basePath?: string;\n /** Seed timestamp override for reproducible builds/tests. */\n now?: number;\n}\n\ninterface IntegrationSeed {\n id: string;\n ns: string;\n key: { attr: string; value: string };\n attrs: Record<string, unknown>;\n}\n\n/** Structural match for the CLI's `OdlaIntegration`. */\nexport interface ChapterIntegrationDescriptor {\n id: string;\n title: string;\n npm: string;\n schema: { entities: Record<string, unknown>; links: Record<string, unknown> };\n rules: Record<string, unknown>;\n seeds: IntegrationSeed[];\n probes: Array<{ path: string; expectedStatus: number }>;\n}\n\n/**\n * Build the one CLI-consumable integration for a chapter/hub: the crm_*\n * namespaces + crm_config seed + route probe, merged with the chapter's own\n * namespaces and a guarded `groups`-row seed. Drop it in odla.config.mjs's\n * `integrations` array.\n */\nexport function createChapterIntegration(\n chapter: Chapter,\n options: ChapterIntegrationOptions = {},\n): ChapterIntegrationDescriptor {\n const basePath = options.basePath ?? \"/api/crm\";\n const now = options.now ?? Date.now();\n const emails = chapter.config.emails;\n\n const crmDesc = createCrmIntegration(chapter.crm, {\n basePath,\n now,\n ...(emails?.notificationEmail ? { notificationEmail: emails.notificationEmail } : {}),\n ...(emails?.replyTo ? { replyTo: emails.replyTo } : {}),\n ...(emails?.debugEmail ? { debugEmail: emails.debugEmail } : {}),\n });\n\n const seeds: IntegrationSeed[] = [...(crmDesc.seeds ?? [])];\n const group = chapter.groupSeed();\n if (group) {\n seeds.push({ id: \"group\", ns: \"groups\", key: { attr: \"id\", value: chapter.id }, attrs: { ...group, createdAt: now } });\n }\n\n return {\n id: \"chapter\",\n title: `Chapter — ${chapter.name}`,\n npm: \"@odla-ai/chapter\",\n schema: {\n entities: { ...crmDesc.schema.entities, ...chapter.schema.entities },\n links: { ...crmDesc.schema.links, ...chapter.schema.links },\n },\n rules: { ...crmDesc.rules, ...chapter.rules },\n seeds,\n probes: [...(crmDesc.probes ?? [])],\n };\n}\n"],"mappings":"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","names":["id"]}
@@ -0,0 +1,48 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { CrmClient } from '@odla-ai/crm';
4
+
5
+ /** What each admin section receives. `client` is a CrmClient bound to the
6
+ * signed-in admin's bearer token; `navigate` switches sections. */
7
+ interface AdminSectionContext {
8
+ client: CrmClient;
9
+ getToken: () => Promise<string | null>;
10
+ /** Switch to another section by id (e.g. Overview "jump in" buttons). */
11
+ navigate: (sectionId: string) => void;
12
+ }
13
+ /** One admin-console section: a nav label and a body renderer. */
14
+ interface AdminSection {
15
+ id: string;
16
+ label: string;
17
+ render: (ctx: AdminSectionContext) => ReactNode;
18
+ }
19
+ /** Wordmark shown in the gate and top bar. */
20
+ type Brand = {
21
+ name: string;
22
+ badge?: string;
23
+ };
24
+
25
+ /** Props for {@link ChapterAdmin}. */
26
+ interface ChapterAdminProps {
27
+ /** The admin sections to render (nav label + body). */
28
+ sections: AdminSection[];
29
+ /** Admin mount path. The sign-in redirect + section routing derive from it,
30
+ * so the "bounce to home after sign-in" bug is impossible. Default "/admin". */
31
+ basePath?: string;
32
+ /** Wordmark shown in the gate + top bar. */
33
+ brand?: Brand;
34
+ /** CRM route mount. Default "/api/crm". */
35
+ crmBasePath?: string;
36
+ /** Origin for /api/config and /api/me. Default same origin. */
37
+ apiBase?: string;
38
+ }
39
+ /**
40
+ * The whole admin console for a chapter/hub: fetches the Clerk publishable key
41
+ * (/api/config), gates on sign-in with the post-sign-in redirect derived from
42
+ * `basePath`, checks the odla-db admins allowlist (/api/me), and renders a
43
+ * TopBar shell over the caller's `sections`. The gate/shell/redirect are owned
44
+ * here so no site re-wires (or re-bugs) them.
45
+ */
46
+ declare function ChapterAdmin(props: ChapterAdminProps): react.JSX.Element;
47
+
48
+ export { type AdminSection, type AdminSectionContext, ChapterAdmin, type ChapterAdminProps };
@@ -0,0 +1,217 @@
1
+ // src/ui/admin.tsx
2
+ import { useEffect as useEffect2, useMemo, useState as useState2 } from "react";
3
+ import { ClerkGate, SignedIn, SignedOut, SignIn, useClerkAuth, clerkAppearanceFromTokens } from "@odla-ai/auth-clerk";
4
+ import { CrmClient } from "@odla-ai/crm";
5
+
6
+ // src/ui/chrome.tsx
7
+ import { useCallback, useEffect, useState } from "react";
8
+ import { TopBar, TopBarLink } from "@odla-ai/ui/components";
9
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
10
+ var badgeText = (brand) => brand.badge ?? brand.name.slice(0, 3).toUpperCase();
11
+ var S = {
12
+ gate: {
13
+ minHeight: "100vh",
14
+ display: "grid",
15
+ placeItems: "center",
16
+ padding: 24,
17
+ background: "radial-gradient(900px 480px at 50% -8%, var(--ui-accent-soft), transparent 70%), radial-gradient(700px 500px at 110% 10%, var(--ui-good-soft), transparent 60%)"
18
+ },
19
+ gateInner: { width: "100%", maxWidth: 400, display: "flex", flexDirection: "column", alignItems: "center" },
20
+ brandBox: { textAlign: "center", marginBottom: 22 },
21
+ badge: {
22
+ width: 52,
23
+ height: 52,
24
+ margin: "0 auto 14px",
25
+ display: "grid",
26
+ placeItems: "center",
27
+ fontSize: 15,
28
+ fontWeight: 700,
29
+ color: "var(--ui-on-accent)",
30
+ background: "linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))",
31
+ borderRadius: 14,
32
+ boxShadow: "0 10px 28px var(--ui-accent-soft)"
33
+ },
34
+ badgeSm: {
35
+ width: 26,
36
+ height: 26,
37
+ display: "grid",
38
+ placeItems: "center",
39
+ fontSize: 10,
40
+ fontWeight: 700,
41
+ color: "var(--ui-on-accent)",
42
+ background: "linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))",
43
+ borderRadius: 7,
44
+ marginRight: 9
45
+ },
46
+ h1: { margin: 0, fontSize: 28, letterSpacing: "-0.02em", fontWeight: 700 },
47
+ muted: { color: "var(--ui-text-muted)" },
48
+ role: {
49
+ fontFamily: "var(--ui-font-mono)",
50
+ fontSize: 11,
51
+ textTransform: "uppercase",
52
+ letterSpacing: "0.04em",
53
+ padding: "2px 8px",
54
+ borderRadius: 999,
55
+ background: "var(--ui-accent-soft)",
56
+ border: "1px solid var(--ui-accent)",
57
+ color: "var(--ui-accent-strong)"
58
+ },
59
+ whoami: { display: "flex", alignItems: "center", gap: 8, fontSize: 12 },
60
+ // Sections own their own width/padding (e.g. a .wrap container).
61
+ main: { minHeight: "calc(100vh - 61px)" },
62
+ card: { width: "100%", textAlign: "center" }
63
+ };
64
+ function Gate(props) {
65
+ const { brand, tagline, children } = props;
66
+ return /* @__PURE__ */ jsx("div", { style: S.gate, children: /* @__PURE__ */ jsxs("div", { style: S.gateInner, children: [
67
+ /* @__PURE__ */ jsxs("div", { style: S.brandBox, children: [
68
+ /* @__PURE__ */ jsx("div", { style: S.badge, children: badgeText(brand) }),
69
+ /* @__PURE__ */ jsx("h1", { style: S.h1, children: brand.name }),
70
+ tagline ? /* @__PURE__ */ jsx("p", { style: { ...S.muted, margin: "8px 0 0", fontSize: 14 }, children: tagline }) : null
71
+ ] }),
72
+ children
73
+ ] }) });
74
+ }
75
+ function AdminShell(props) {
76
+ const { sections, basePath, brand, client, getToken, signOut, email } = props;
77
+ const sectionFromPath = useCallback(() => {
78
+ const fallback = sections[0]?.id ?? "";
79
+ if (typeof window === "undefined") return fallback;
80
+ const rest = window.location.pathname.slice(basePath.length).replace(/^\//, "");
81
+ const id = rest.split("/")[0] ?? "";
82
+ return sections.some((s) => s.id === id) ? id : fallback;
83
+ }, [sections, basePath]);
84
+ const [section, setSection] = useState(sectionFromPath);
85
+ const go = useCallback(
86
+ (id) => {
87
+ window.history.pushState(null, "", `${basePath}/${id}`);
88
+ setSection(id);
89
+ },
90
+ [basePath]
91
+ );
92
+ useEffect(() => {
93
+ const onPop = () => setSection(sectionFromPath());
94
+ window.addEventListener("popstate", onPop);
95
+ return () => window.removeEventListener("popstate", onPop);
96
+ }, [sectionFromPath]);
97
+ const active = sections.find((s) => s.id === section) ?? sections[0];
98
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
99
+ /* @__PURE__ */ jsxs(TopBar, { children: [
100
+ /* @__PURE__ */ jsxs("a", { className: "topbar-brand", href: "/", style: { display: "inline-flex", alignItems: "center" }, children: [
101
+ /* @__PURE__ */ jsx("span", { style: S.badgeSm, children: badgeText(brand) }),
102
+ brand.name
103
+ ] }),
104
+ /* @__PURE__ */ jsx("nav", { className: "topbar-nav", "aria-label": "Admin navigation", children: sections.map((s) => /* @__PURE__ */ jsx(TopBarLink, { active: section === s.id, onClick: () => go(s.id), children: s.label }, s.id)) }),
105
+ /* @__PURE__ */ jsx("div", { className: "topbar-spacer" }),
106
+ /* @__PURE__ */ jsxs("div", { className: "topbar-actions", children: [
107
+ /* @__PURE__ */ jsxs("span", { style: S.whoami, children: [
108
+ /* @__PURE__ */ jsx("span", { style: S.role, children: "admin" }),
109
+ email ? /* @__PURE__ */ jsx("span", { style: S.muted, children: email }) : null
110
+ ] }),
111
+ /* @__PURE__ */ jsx("button", { className: "btn secondary mini", onClick: () => signOut(), children: "Sign out" })
112
+ ] })
113
+ ] }),
114
+ /* @__PURE__ */ jsx("main", { className: "shell-main", style: S.main, children: active ? active.render({ client, getToken, navigate: go }) : null })
115
+ ] });
116
+ }
117
+
118
+ // src/ui/admin.tsx
119
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
120
+ function Authed(props) {
121
+ const { sections, basePath, brand, crmBasePath, apiBase } = props;
122
+ const { getToken, signOut } = useClerkAuth();
123
+ const client = useMemo(
124
+ () => new CrmClient({
125
+ basePath: crmBasePath,
126
+ headers: async () => {
127
+ const t = await getToken();
128
+ return t ? { authorization: `Bearer ${t}` } : {};
129
+ }
130
+ }),
131
+ [getToken, crmBasePath]
132
+ );
133
+ const [state, setState] = useState2({
134
+ status: "checking"
135
+ });
136
+ useEffect2(() => {
137
+ let live = true;
138
+ void (async () => {
139
+ try {
140
+ const t = await getToken();
141
+ const res = await fetch(`${apiBase}/api/me`, { headers: t ? { authorization: `Bearer ${t}` } : {} });
142
+ const body = await res.json().catch(() => ({}));
143
+ if (live) setState({ status: body.authorized ? "ok" : "denied", email: body.email ?? null });
144
+ } catch {
145
+ if (live) setState({ status: "denied" });
146
+ }
147
+ })();
148
+ return () => {
149
+ live = false;
150
+ };
151
+ }, [getToken, apiBase]);
152
+ if (state.status === "checking") {
153
+ return /* @__PURE__ */ jsx2(Gate, { brand, children: /* @__PURE__ */ jsx2("p", { style: S.muted, children: "Checking access\u2026" }) });
154
+ }
155
+ if (state.status === "denied") {
156
+ return /* @__PURE__ */ jsx2(Gate, { brand, children: /* @__PURE__ */ jsxs2("div", { className: "card", style: S.card, children: [
157
+ /* @__PURE__ */ jsx2("h2", { style: { marginTop: 0 }, children: "Not authorized" }),
158
+ /* @__PURE__ */ jsxs2("p", { style: S.muted, children: [
159
+ state.email ? `${state.email} isn't` : "This account isn't",
160
+ " on the admin list. Ask an existing admin to add you in odla Studio."
161
+ ] }),
162
+ /* @__PURE__ */ jsx2("button", { className: "btn secondary", onClick: () => signOut(), style: { marginTop: 12 }, children: "Sign out" })
163
+ ] }) });
164
+ }
165
+ return /* @__PURE__ */ jsx2(
166
+ AdminShell,
167
+ {
168
+ sections,
169
+ basePath,
170
+ brand,
171
+ client,
172
+ getToken,
173
+ signOut,
174
+ email: state.email ?? null
175
+ }
176
+ );
177
+ }
178
+ function ChapterAdmin(props) {
179
+ const sections = props.sections;
180
+ const basePath = props.basePath ?? "/admin";
181
+ const brand = props.brand ?? { name: "Admin" };
182
+ const crmBasePath = props.crmBasePath ?? "/api/crm";
183
+ const apiBase = props.apiBase ?? "";
184
+ const [pk, setPk] = useState2(void 0);
185
+ useEffect2(() => {
186
+ let live = true;
187
+ void (async () => {
188
+ try {
189
+ const res = await fetch(`${apiBase}/api/config`);
190
+ const body = await res.json();
191
+ if (live) setPk(body.clerkPublishableKey ?? null);
192
+ } catch {
193
+ if (live) setPk(null);
194
+ }
195
+ })();
196
+ return () => {
197
+ live = false;
198
+ };
199
+ }, [apiBase]);
200
+ if (pk === void 0) {
201
+ return /* @__PURE__ */ jsx2(Gate, { brand, children: /* @__PURE__ */ jsx2("p", { style: S.muted, children: "Loading\u2026" }) });
202
+ }
203
+ if (!pk) {
204
+ return /* @__PURE__ */ jsx2(Gate, { brand, children: /* @__PURE__ */ jsxs2("div", { className: "card", style: S.card, children: [
205
+ /* @__PURE__ */ jsx2("h2", { style: { marginTop: 0 }, children: "Sign-in not configured" }),
206
+ /* @__PURE__ */ jsx2("p", { style: S.muted, children: "No Clerk publishable key is set for this environment yet." })
207
+ ] }) });
208
+ }
209
+ return /* @__PURE__ */ jsxs2(ClerkGate, { publishableKey: pk, appearance: clerkAppearanceFromTokens(), afterSignOutUrl: "/", children: [
210
+ /* @__PURE__ */ jsx2(SignedOut, { children: /* @__PURE__ */ jsx2(Gate, { brand, tagline: "Admin sign-in \u2014 invite only", children: /* @__PURE__ */ jsx2(SignIn, { routing: "hash", forceRedirectUrl: basePath, signUpForceRedirectUrl: basePath }) }) }),
211
+ /* @__PURE__ */ jsx2(SignedIn, { children: /* @__PURE__ */ jsx2(Authed, { sections, basePath, brand, crmBasePath, apiBase }) })
212
+ ] });
213
+ }
214
+ export {
215
+ ChapterAdmin
216
+ };
217
+ //# sourceMappingURL=index.js.map