create-ailk 0.7.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.
Files changed (169) hide show
  1. package/README.md +44 -10
  2. package/component-catalog.json +520 -5
  3. package/component-catalog.md +9 -6
  4. package/dist/cli.js +22 -24
  5. package/dist/lib/extract-module-bundle.js +5 -1
  6. package/dist/lib/module-license-gate.d.ts +79 -0
  7. package/dist/lib/module-license-gate.js +160 -12
  8. package/dist/lib/module-tier.d.ts +45 -16
  9. package/dist/lib/module-tier.js +76 -0
  10. package/dist/lib/pro-callout.js +10 -2
  11. package/dist/parse-args.d.ts +78 -10
  12. package/dist/parse-args.js +204 -14
  13. package/dist/programmatic.d.ts +39 -9
  14. package/dist/programmatic.js +163 -25
  15. package/dist/site-roster.js +11 -6
  16. package/dist/starter-grid.d.ts +216 -0
  17. package/dist/starter-grid.js +449 -0
  18. package/dist/surfaces.d.ts +4 -4
  19. package/dist/surfaces.js +102 -43
  20. package/dist/template-manifest.d.ts +36 -17
  21. package/dist/template-manifest.js +121 -367
  22. package/dist/template-names.d.ts +14 -12
  23. package/dist/template-names.js +13 -17
  24. package/package.json +3 -3
  25. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -1
  26. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  27. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  28. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  29. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  30. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  32. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  33. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  34. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  35. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  36. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  37. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  38. package/site-templates/_base/content/en/UseCase/persona-1.mdx +69 -0
  39. package/site-templates/_base/content/en/UseCase/persona-2.mdx +69 -0
  40. package/site-templates/_base/content/en/UseCase/persona-3.mdx +69 -0
  41. package/site-templates/designs/standard/DESIGN.md +5 -0
  42. package/site-templates/marketing-site/DESIGN.md +5 -0
  43. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +89 -25
  44. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +35 -14
  45. package/site-templates/marketing-site-free/DESIGN.md +5 -0
  46. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +95 -29
  47. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +74 -44
  48. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  49. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  50. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +17 -29
  51. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -1
  52. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  53. package/site-templates/saas-product/DESIGN.md +5 -0
  54. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  55. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +96 -76
  56. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +302 -0
  57. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  58. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  59. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  60. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  61. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  62. package/site-templates/{simple-service → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  63. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  64. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  65. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  66. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  67. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  68. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +362 -0
  69. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +300 -0
  70. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  71. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  72. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  73. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  74. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  75. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  76. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +38 -0
  77. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +38 -0
  78. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +38 -0
  79. package/site-templates/shapes/organization-service/content/en/ServicesList/services.mdx +72 -0
  80. package/site-templates/shapes/organization-service/provenance.json +4 -0
  81. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +369 -0
  82. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +299 -0
  83. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  84. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  85. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  86. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  87. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  88. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +169 -0
  89. package/site-templates/shapes/person-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  90. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  91. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  92. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  93. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  94. package/site-templates/shapes/person-service/provenance.json +4 -0
  95. package/templates/.claude/skills/README.md +3 -2
  96. package/templates/.claude/skills/add-page/references/page-types.md +4 -0
  97. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  98. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  99. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  100. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +19 -28
  101. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  102. package/templates/DESIGN.md +5 -0
  103. package/templates/README.md +46 -38
  104. package/templates/apps/api/CLAUDE.md +3 -2
  105. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  106. package/templates/apps/api/src/config/index.ts +35 -16
  107. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  108. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  109. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  110. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  111. package/templates/apps/api/src/openapi/spec.ts +63 -12
  112. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  113. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  114. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  115. package/templates/apps/api/src/server.ts +7 -0
  116. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  117. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  118. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  119. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  120. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  121. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  122. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  123. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  124. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  125. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  126. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  127. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  128. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  129. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  130. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  131. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  132. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  133. package/templates/apps/web/app/[locale]/layout.tsx +37 -5
  134. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  135. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  136. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  137. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  138. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  139. package/templates/apps/web/app/[locale]/portfolio/page.tsx +49 -0
  140. package/templates/apps/web/app/[locale]/use-cases/[slug]/page.tsx +18 -3
  141. package/templates/apps/web/lib/nav-config.ts +48 -36
  142. package/templates/apps/web/lib/site-identity.ts +14 -0
  143. package/templates/apps/web/messages/en.json +28 -2
  144. package/templates/content/_site.mdx +4 -2
  145. package/templates/content/en/BlogList/blog.mdx +37 -16
  146. package/templates/content/en/ContactPage/contact.mdx +14 -44
  147. package/templates/content/en/HomePage/home.mdx +8 -16
  148. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  149. package/templates/content/en/UseCasesList/use-cases.mdx +6 -6
  150. package/templates/database/CHANGELOG.md +7 -0
  151. package/templates/database/package.json +1 -1
  152. package/versions.json +33 -33
  153. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  154. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  155. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  156. package/site-templates/simple-service/DESIGN.md +0 -162
  157. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  158. package/site-templates/simple-service/ailk-template.json +0 -11
  159. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  160. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  161. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  162. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  163. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  164. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  165. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  166. package/site-templates/simple-service/provenance.json +0 -4
  167. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  168. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  169. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
@@ -553,3 +553,197 @@ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () =>
553
553
  });
554
554
  });
555
555
  });
556
+
557
+ // ─── #6730 D1/D2 — success only for a durable row or an accepted email ───────
558
+
559
+ describe("#6730 — routeLead delivery modes", () => {
560
+ const ENV_KEYS = [
561
+ "DATABASE_URL",
562
+ "NODE_ENV",
563
+ "RESEND_API_KEY",
564
+ "AILK_LEAD_NOTIFICATION_TO",
565
+ "AILK_LEAD_NOTIFICATION_FROM",
566
+ "AILK_INBOX_FALLBACK_DIR",
567
+ "LEAD_CAPTURE_FILE",
568
+ ] as const;
569
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
570
+ let tmpDir: string;
571
+ const listFiles = () => fs.readdir(tmpDir);
572
+
573
+ const EMAIL_ENV = {
574
+ RESEND_API_KEY: "re_test",
575
+ AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
576
+ AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
577
+ };
578
+
579
+ beforeEach(async () => {
580
+ jest.clearAllMocks();
581
+ saved = {};
582
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
583
+ for (const k of ENV_KEYS) delete process.env[k];
584
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
585
+ process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
586
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
587
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
588
+ });
589
+
590
+ afterEach(async () => {
591
+ for (const k of ENV_KEYS) {
592
+ if (saved[k] === undefined) delete process.env[k];
593
+ else process.env[k] = saved[k];
594
+ }
595
+ await fs.rm(tmpDir, { recursive: true, force: true });
596
+ });
597
+
598
+ describe("email delivery (the three email vars, no DATABASE_URL, production)", () => {
599
+ beforeEach(() => {
600
+ Object.assign(process.env, EMAIL_ENV, { NODE_ENV: "production" });
601
+ });
602
+
603
+ it("an accepted email → ok:true persisted:email with an email- id; no Prisma, no file fallback", async () => {
604
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
605
+ RouteLeadResult,
606
+ { ok: true }
607
+ >;
608
+
609
+ expect(result).toMatchObject({
610
+ ok: true,
611
+ persisted: "email",
612
+ email: { ok: true, messageId: "msg-1" },
613
+ });
614
+ expect(result.id).toMatch(/^email-/);
615
+ expect(result.id).toMatch(/^[a-z0-9_-]{8,64}$/i);
616
+ expect(prisma.lead.create).not.toHaveBeenCalled();
617
+ expect(await listFiles()).toEqual([]);
618
+ expect(mockSendLeadNotification).toHaveBeenCalledTimes(1);
619
+ });
620
+
621
+ it("a refused email → ok:false delivery_failed (never reported as sent)", async () => {
622
+ mockSendLeadNotification.mockResolvedValue({
623
+ ok: false,
624
+ error: { kind: "provider_error", message: "rejected" },
625
+ });
626
+
627
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
628
+ RouteLeadResult,
629
+ { ok: false }
630
+ >;
631
+
632
+ expect(result.ok).toBe(false);
633
+ expect(result.error.kind).toBe("delivery_failed");
634
+ expect(prisma.lead.create).not.toHaveBeenCalled();
635
+ expect(await listFiles()).toEqual([]);
636
+ });
637
+
638
+ it("a skipped email (provider reports the key missing) → delivery_failed", async () => {
639
+ mockSendLeadNotification.mockResolvedValue({
640
+ ok: false,
641
+ error: { kind: "config_missing", message: "RESEND_API_KEY not set" },
642
+ });
643
+
644
+ const result = await routeLead(leadInput, sourceMeta);
645
+ expect(result).toMatchObject({ ok: false, error: { kind: "delivery_failed" } });
646
+ });
647
+
648
+ it("a callback is delivered by email too — carrying the name and number to call", async () => {
649
+ const result = await routeLead(
650
+ { ...callbackInput, preferredTime: "mornings" },
651
+ sourceMeta,
652
+ );
653
+ expect(result).toMatchObject({ ok: true, persisted: "email" });
654
+ expect(prisma.callback.create).not.toHaveBeenCalled();
655
+ const arg = mockSendLeadNotification.mock.calls[0][0];
656
+ expect(arg.lead.payload).toMatchObject({
657
+ name: "Alice",
658
+ phone: "+15551234567",
659
+ preferredTime: "mornings",
660
+ notes: "urgent",
661
+ });
662
+ // No visitor address to reply to — never an empty Reply-To.
663
+ expect(arg.replyToOverride).toBe("owner@example.com");
664
+ });
665
+
666
+ it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
667
+ const result = await routeLead(subscribeInput, sourceMeta);
668
+ expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
669
+ expect(prisma.subscriber.create).not.toHaveBeenCalled();
670
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
671
+ });
672
+ });
673
+
674
+ describe("production with neither a database nor the email vars", () => {
675
+ beforeEach(() => {
676
+ process.env.NODE_ENV = "production";
677
+ });
678
+
679
+ it("refuses with config_missing before any write", async () => {
680
+ const result = await routeLead(leadInput, sourceMeta);
681
+
682
+ expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
683
+ expect(prisma.lead.create).not.toHaveBeenCalled();
684
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
685
+ expect(await listFiles()).toEqual([]);
686
+ });
687
+ });
688
+
689
+ describe("inbox delivery in production with the database failing (not a constraint)", () => {
690
+ beforeEach(() => {
691
+ process.env.DATABASE_URL = "postgres://test";
692
+ process.env.NODE_ENV = "production";
693
+ (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
694
+ });
695
+
696
+ it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
697
+ const result = await routeLead(leadInput, sourceMeta);
698
+
699
+ expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
700
+ expect(await listFiles()).toEqual([]);
701
+ });
702
+
703
+ it("email vars set and the send accepted → persisted:email", async () => {
704
+ Object.assign(process.env, EMAIL_ENV);
705
+
706
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
707
+ RouteLeadResult,
708
+ { ok: true }
709
+ >;
710
+
711
+ expect(result).toMatchObject({ ok: true, persisted: "email" });
712
+ expect(result.id).toMatch(/^email-/);
713
+ expect(await listFiles()).toEqual([]);
714
+ });
715
+
716
+ it("a newsletter signup is never emailed — persist_failed", async () => {
717
+ Object.assign(process.env, EMAIL_ENV);
718
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
719
+
720
+ const result = await routeLead(subscribeInput, sourceMeta);
721
+
722
+ expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
723
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
724
+ });
725
+
726
+ it("email vars set but the send refused → persist_failed", async () => {
727
+ Object.assign(process.env, EMAIL_ENV);
728
+ mockSendLeadNotification.mockResolvedValue({
729
+ ok: false,
730
+ error: { kind: "provider_error", message: "rejected" },
731
+ });
732
+
733
+ const result = await routeLead(leadInput, sourceMeta);
734
+ expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
735
+ });
736
+ });
737
+
738
+ describe("inbox delivery outside production keeps today's file fallback", () => {
739
+ it("DB down in test → persisted:file", async () => {
740
+ process.env.DATABASE_URL = "postgres://test";
741
+ process.env.NODE_ENV = "test";
742
+ (prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
743
+
744
+ const result = await routeLead(leadInput, sourceMeta);
745
+ expect(result).toMatchObject({ ok: true, persisted: "file" });
746
+ expect((await listFiles()).length).toBeGreaterThan(0);
747
+ });
748
+ });
749
+ });
@@ -327,3 +327,127 @@ describe("listOwnedLeads", () => {
327
327
  expect(second.value.nextCursor).toBeNull();
328
328
  });
329
329
  });
330
+
331
+ // ─── #6730 D7 — the single-tenant inbox, pinned to the operator ──────────────
332
+
333
+ describe("listOwnedLeads — single-tenant build (#6730 D7)", () => {
334
+ const { DEFAULT_ORG_ID, DEFAULT_WORKSPACE_ID } = jest.requireActual(
335
+ "@working-theory/database/tenancy",
336
+ ) as { DEFAULT_ORG_ID: string; DEFAULT_WORKSPACE_ID: string };
337
+ const OPERATOR_EMAIL = "owner@example.com";
338
+ const prior = {
339
+ modules: process.env.AILK_ENABLED_MODULES,
340
+ pin: process.env.AILK_OPERATOR_EMAIL,
341
+ };
342
+
343
+ /** One workspace — the seeded default — and a caller whose verified email is `email`. */
344
+ function oneWorkspaceDeployment(opts: {
345
+ email: string;
346
+ verified?: boolean;
347
+ workspaceCount?: number;
348
+ role?: string;
349
+ }) {
350
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(opts.workspaceCount ?? 1);
351
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
352
+ id: DEFAULT_WORKSPACE_ID,
353
+ organizationId: DEFAULT_ORG_ID,
354
+ });
355
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({
356
+ id: USER_ID,
357
+ email: opts.email,
358
+ emailVerified: opts.verified ?? true,
359
+ });
360
+ (prisma.membership.create as jest.Mock).mockResolvedValue({});
361
+ (prisma.membership.findUnique as jest.Mock).mockResolvedValue({
362
+ role: opts.role ?? "owner",
363
+ });
364
+ }
365
+
366
+ beforeEach(() => {
367
+ jest.clearAllMocks();
368
+ delete process.env.AILK_ENABLED_MODULES;
369
+ process.env.AILK_OPERATOR_EMAIL = OPERATOR_EMAIL;
370
+ seedLeadTable([
371
+ lead({ id: "lead_own", siteId: DEFAULT_WORKSPACE_ID }),
372
+ lead({ id: "lead_elsewhere", siteId: OTHER_SITE_ID }),
373
+ ]);
374
+ });
375
+
376
+ afterAll(() => {
377
+ for (const [key, value] of [
378
+ ["AILK_ENABLED_MODULES", prior.modules],
379
+ ["AILK_OPERATOR_EMAIL", prior.pin],
380
+ ] as const) {
381
+ if (value === undefined) delete process.env[key];
382
+ else process.env[key] = value;
383
+ }
384
+ });
385
+
386
+ it("the pinned, verified owner sees the default workspace's leads — and only those", async () => {
387
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
388
+
389
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
390
+
391
+ expect(result.ok).toBe(true);
392
+ if (!result.ok) return;
393
+ expect(result.value.items.map((r) => r.id)).toEqual(["lead_own"]);
394
+ });
395
+
396
+ it("the pin unset → empty", async () => {
397
+ delete process.env.AILK_OPERATOR_EMAIL;
398
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
399
+
400
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
401
+
402
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
403
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
404
+ });
405
+
406
+ it("a different verified email → empty", async () => {
407
+ oneWorkspaceDeployment({ email: "stranger@example.com" });
408
+
409
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
410
+
411
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
412
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it("the pinned email, unverified → empty", async () => {
416
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL, verified: false });
417
+
418
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
419
+
420
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
421
+ });
422
+
423
+ it("a second workspace → empty (not unambiguously one-workspace)", async () => {
424
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL, workspaceCount: 2 });
425
+
426
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
427
+
428
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
429
+ });
430
+
431
+ it("a `member` role → empty — the role floor still holds", async () => {
432
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL, role: "member" });
433
+
434
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
435
+
436
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
437
+ });
438
+
439
+ it("with multi-tenant on, resolveOwnerWorkspaces is the path — the default-tenant resolver is not called", async () => {
440
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
441
+ oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
442
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
443
+ const ownerWorkspaces = jest.requireActual("../owner-workspaces.js");
444
+ const spy = jest.spyOn(ownerWorkspaces, "resolveOwnerWorkspaces");
445
+
446
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
447
+
448
+ expect(spy).toHaveBeenCalledWith(USER_ID);
449
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
450
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
451
+ spy.mockRestore();
452
+ });
453
+ });
@@ -2,7 +2,12 @@
2
2
  * @file lead-routing.ts
3
3
  * @description Cross-namespace lead orchestration service (ADR 0009 D3).
4
4
  *
5
- * Pipeline: validate → enrich → persist (DB with file fallback) → email.
5
+ * Pipeline: validate → delivery guard → enrich → persist (DB with file
6
+ * fallback) → email. Where a lead may go is `resolveLeadDelivery()`
7
+ * (`lib/lead-delivery.ts`, #6730 D1): with no database but the email vars set,
8
+ * the owner email IS the delivery; in production with neither, nothing is
9
+ * accepted. A lead is reported sent only for a durable row or an email the
10
+ * provider accepted — never for the non-durable file fallback in production.
6
11
  * This is the first implementation of ADR 0009 D3's service-layer pattern.
7
12
  * Future cross-namespace orchestrators in apps/api/src/services/ should mirror
8
13
  * this shape: type-result returns, mocked-dep tests, non-transactional secondary
@@ -32,6 +37,7 @@ import { leadInputSchema } from "@working-theory/validation";
32
37
 
33
38
  import { writeLeadFallback } from "../lib/file-fallback.js";
34
39
  import { appendLeadRecord } from "../lib/lead-capture-store.js";
40
+ import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
35
41
  import { prisma } from "../lib/prisma.js";
36
42
 
37
43
  import { enrichLead } from "./enrichment-shim.js";
@@ -65,11 +71,14 @@ export type SourceMeta = {
65
71
  receivedAt: string; // ISO
66
72
  };
67
73
 
74
+ /** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
75
+ export type Persisted = "db" | "file" | "email";
76
+
68
77
  export type RouteLeadResult =
69
78
  | {
70
79
  ok: true;
71
80
  id: string;
72
- persisted: "db" | "file";
81
+ persisted: Persisted;
73
82
  email:
74
83
  | { ok: true; messageId: string }
75
84
  | { ok: false; error: string }
@@ -78,7 +87,11 @@ export type RouteLeadResult =
78
87
  | {
79
88
  ok: false;
80
89
  error: {
81
- kind: "invalid_input" | "persist_failed" | "config_missing";
90
+ kind:
91
+ | "invalid_input"
92
+ | "persist_failed"
93
+ | "config_missing"
94
+ | "delivery_failed";
82
95
  message: string;
83
96
  };
84
97
  };
@@ -272,7 +285,7 @@ type EmailResult =
272
285
  async function sendEmail(
273
286
  input: RouteLeadInput,
274
287
  id: string,
275
- persisted: "db" | "file",
288
+ persisted: Persisted,
276
289
  ): Promise<EmailResult> {
277
290
  // Request-and-confirm SUPERSEDES this notification for a callback (#5970
278
291
  // D6). AC-1 requires the tenant to receive exactly one email, and the
@@ -316,7 +329,22 @@ async function sendEmail(
316
329
  siteId: input.siteId,
317
330
  source: input.kind === "callback" ? "callback" : input.source,
318
331
  email: input.kind === "callback" ? "" : input.email,
319
- payload: input.kind === "subscribe" ? {} : input.payload,
332
+ // A callback's contact details are top-level fields, not payload keys
333
+ // (#6730): with no database the email is the only record of the
334
+ // request, so it must carry the name and number to call back.
335
+ payload:
336
+ input.kind === "subscribe"
337
+ ? {}
338
+ : input.kind === "callback"
339
+ ? {
340
+ ...input.payload,
341
+ name: input.name,
342
+ phone: input.phone,
343
+ ...(input.preferredTime !== undefined && {
344
+ preferredTime: input.preferredTime,
345
+ }),
346
+ }
347
+ : input.payload,
320
348
  createdAt: new Date().toISOString(),
321
349
  };
322
350
 
@@ -324,6 +352,9 @@ async function sendEmail(
324
352
  lead: leadForEmail,
325
353
  recipientEmail: to,
326
354
  fromAddress: from,
355
+ // A callback has no visitor address to reply to; an empty Reply-To is
356
+ // not a valid header, so reply to the owner instead.
357
+ ...(input.kind === "callback" && { replyToOverride: to }),
327
358
  });
328
359
 
329
360
  if (emailResult.ok) {
@@ -337,6 +368,47 @@ async function sendEmail(
337
368
  return { ok: false, error: `${kind}: ${message}` };
338
369
  }
339
370
 
371
+ // ─── Email delivery (#6730 D2) ────────────────────────────────────────────────
372
+
373
+ type EmailDelivery =
374
+ | { ok: true; id: string; email: { ok: true; messageId: string } }
375
+ | { ok: false; message: string };
376
+
377
+ /**
378
+ * Deliver a lead as the owner email alone — success only when the provider
379
+ * accepted it. The id matches the file-fallback id format
380
+ * (`/^[a-z0-9_-]{8,64}$/i`).
381
+ */
382
+ async function deliverByEmail(input: RouteLeadInput): Promise<EmailDelivery> {
383
+ const id = `email-${Date.now()}-${randomUUID().slice(0, 8)}`;
384
+ const result = await sendEmail(input, id, "email");
385
+ if ("ok" in result && result.ok) return { ok: true, id, email: result };
386
+ return {
387
+ ok: false,
388
+ message: "error" in result ? result.error : `email skipped: ${result.reason}`,
389
+ };
390
+ }
391
+
392
+ // ─── Lead-capture record ─────────────────────────────────────────────────────
393
+
394
+ function captureRecordFor(input: RouteLeadInput, id: string, sourceMeta: SourceMeta) {
395
+ return {
396
+ id,
397
+ createdAt: sourceMeta.receivedAt,
398
+ source: input.kind === "callback" ? "callback" : input.source,
399
+ data:
400
+ input.kind === "callback"
401
+ ? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
402
+ : input.kind === "subscribe"
403
+ ? { email: input.email }
404
+ : { email: input.email, ...(input.payload ?? {}) },
405
+ metadata: {
406
+ ip: sourceMeta.ip,
407
+ userAgent: sourceMeta.userAgent,
408
+ },
409
+ };
410
+ }
411
+
340
412
  // ─── Main export ──────────────────────────────────────────────────────────────
341
413
 
342
414
  export async function routeLead(
@@ -352,13 +424,13 @@ export async function routeLead(
352
424
  };
353
425
  }
354
426
 
355
- // 2. Guard: if file capture is disabled AND no DB is configured, bail early.
356
- // Both paths are unavailable — returning a clear error is better than a
357
- // misleading 201 or a stack trace from Prisma on a missing DATABASE_URL.
358
- if (
359
- process.env.LEAD_CAPTURE_FILE_DISABLED === "true" &&
360
- !process.env.DATABASE_URL
361
- ) {
427
+ // 2. Guard: where can this lead go (#6730 D1)? A kind the configured
428
+ // delivery cannot take — anything in production with neither a database
429
+ // nor the email vars, a newsletter signup with no database — is refused
430
+ // before any write, so no visitor is told a message was sent when it
431
+ // reached nobody.
432
+ const delivery = resolveLeadDelivery();
433
+ if (!acceptsKind(delivery, input.kind)) {
362
434
  return {
363
435
  ok: false,
364
436
  error: {
@@ -374,9 +446,28 @@ export async function routeLead(
374
446
  // the real enrichment call with proper type alignment.
375
447
  await enrichLead(input as unknown as Parameters<typeof enrichLead>[0]);
376
448
 
449
+ // Email delivery — no database: the owner email is the delivery, so skip
450
+ // the DB and the (non-durable) file fallback entirely.
451
+ if (delivery === "email") {
452
+ const emailed = await deliverByEmail(input);
453
+ if (!emailed.ok) {
454
+ return {
455
+ ok: false,
456
+ error: { kind: "delivery_failed", message: emailed.message },
457
+ };
458
+ }
459
+ await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
460
+ return {
461
+ ok: true,
462
+ id: emailed.id,
463
+ persisted: "email",
464
+ email: emailed.email,
465
+ };
466
+ }
467
+
377
468
  // 4. Persist primary (DB), fallback to file on error
378
469
  let id: string;
379
- let persisted: "db" | "file";
470
+ let persisted: Persisted;
380
471
  let skipEmail = false;
381
472
  let skipReason: string | undefined;
382
473
 
@@ -424,7 +515,41 @@ export async function routeLead(
424
515
  };
425
516
  }
426
517
 
427
- // 5. File fallback
518
+ // In production the file fallback is not durable (a serverless instance
519
+ // does not outlive the request), so reporting it as success would lose
520
+ // the lead silently. Deliver by email when that is configured; otherwise
521
+ // fail loudly (#6730 D2). A newsletter signup is never emailed — one
522
+ // owner email per address is not a list (`acceptsKind`).
523
+ if (process.env.NODE_ENV === "production") {
524
+ const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
525
+ if (input.kind === "subscribe") {
526
+ return {
527
+ ok: false,
528
+ error: { kind: "persist_failed", message: `db: ${dbMsg}` },
529
+ };
530
+ }
531
+ const emailed = await deliverByEmail(input);
532
+ if (!emailed.ok) {
533
+ return {
534
+ ok: false,
535
+ error: {
536
+ kind: "persist_failed",
537
+ // Not the provider's text: it can echo an address, and this
538
+ // message is logged (spec D5).
539
+ message: `db: ${dbMsg}; email delivery failed`,
540
+ },
541
+ };
542
+ }
543
+ await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
544
+ return {
545
+ ok: true,
546
+ id: emailed.id,
547
+ persisted: "email",
548
+ email: emailed.email,
549
+ };
550
+ }
551
+
552
+ // 5. File fallback (outside production only)
428
553
  // Include a UUID suffix so concurrent fallbacks produce unique IDs even
429
554
  // within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
430
555
  const fallbackLead = {
@@ -468,22 +593,7 @@ export async function routeLead(
468
593
  // 6. Write to lead-capture JSONL (always-on canonical AX surface).
469
594
  // Best-effort — a write failure does NOT roll back persistence or block email.
470
595
  // The file is the agent-readable surface; DB is the transactional record.
471
- const captureRecord = {
472
- id: id!,
473
- createdAt: sourceMeta.receivedAt,
474
- source: input.kind === "callback" ? "callback" : input.source,
475
- data:
476
- input.kind === "callback"
477
- ? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
478
- : input.kind === "subscribe"
479
- ? { email: input.email }
480
- : { email: input.email, ...(input.payload ?? {}) },
481
- metadata: {
482
- ip: sourceMeta.ip,
483
- userAgent: sourceMeta.userAgent,
484
- },
485
- };
486
- await appendLeadRecord(captureRecord);
596
+ await appendLeadRecord(captureRecordFor(input, id!, sourceMeta));
487
597
 
488
598
  // 7. Email notification (best-effort — never rolls back persistence)
489
599
  let emailResult: EmailResult;
@@ -25,10 +25,14 @@
25
25
  *
26
26
  * Two preconditions, neither of which this file can assert:
27
27
  *
28
- * 1. `resolveOwnerWorkspaces` must yield a workspace. On a build with no
29
- * `multi-tenant` module it yields none — see its own header, and #5677,
30
- * which made that a deliberate scope limit rather than a statement that
31
- * there is nothing to resolve.
28
+ * 1. The caller's scope must resolve to a workspace. With the
29
+ * `multi-tenant` module that is `resolveOwnerWorkspaces`. Without it
30
+ * (#6730 D7) — Free and Pro — that resolver yields none by design (its
31
+ * own header, #5677), so the scope is instead the default workspace,
32
+ * granted ONLY through `resolveDefaultTenantForOperator`
33
+ * (`default-tenant.ts`): `AILK_OPERATOR_EMAIL` set, the caller's
34
+ * VERIFIED email equal to it, and the deployment unambiguously
35
+ * one-workspace. Any other caller gets the empty page, never a wider one.
32
36
  * 2. The `siteId` the leads were WRITTEN under must equal a workspace id,
33
37
  * and there are TWO independent sources of it — both must agree with the
34
38
  * workspace, and neither is `currentSiteId()` on the primary path:
@@ -67,8 +71,10 @@
67
71
  import type { Prisma } from "@working-theory/database";
68
72
  import type { Id } from "@working-theory/ids";
69
73
 
74
+ import { isModuleEnabled } from "../config/modules.js";
70
75
  import { prisma } from "../lib/prisma.js";
71
76
 
77
+ import { resolveDefaultTenantForOperator } from "./default-tenant.js";
72
78
  import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
73
79
 
74
80
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -178,6 +184,40 @@ export function readName(payload: Prisma.JsonValue): string | null {
178
184
  return typeof name === "string" ? name.slice(0, MAX_NAME_LENGTH) : null;
179
185
  }
180
186
 
187
+ // ─── Scope ────────────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * The workspace ids whose leads the caller may read. With `multi-tenant`,
191
+ * every workspace they own or administer (`resolveOwnerWorkspaces`,
192
+ * unchanged). Without it (#6730 D7), the default workspace — only for the
193
+ * pinned, verified operator on a one-workspace deployment; `null` from the
194
+ * resolver means no scope at all.
195
+ */
196
+ async function resolveInboxSiteIds(
197
+ userId: Id<"user">,
198
+ ): Promise<{ ok: true; siteIds: string[] } | { ok: false; message: string }> {
199
+ if (!isModuleEnabled("multi-tenant")) {
200
+ try {
201
+ const tenant = await resolveDefaultTenantForOperator(userId);
202
+ return {
203
+ ok: true,
204
+ siteIds: tenant && LEAD_INBOX_ROLES.has(tenant.role) ? [tenant.workspaceId] : [],
205
+ };
206
+ } catch (err) {
207
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
208
+ }
209
+ }
210
+
211
+ const workspaces = await resolveOwnerWorkspaces(userId);
212
+ if (!workspaces.ok) return { ok: false, message: workspaces.error.message };
213
+ return {
214
+ ok: true,
215
+ siteIds: workspaces.value
216
+ .filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
217
+ .map((workspace) => workspace.id),
218
+ };
219
+ }
220
+
181
221
  // ─── Main export ──────────────────────────────────────────────────────────────
182
222
 
183
223
  /**
@@ -199,17 +239,14 @@ export async function listOwnedLeads(params: {
199
239
  cursor?: string | undefined;
200
240
  limit: number;
201
241
  }): Promise<ListLeadsResult> {
202
- const workspaces = await resolveOwnerWorkspaces(params.userId);
203
- if (!workspaces.ok) {
242
+ const scope = await resolveInboxSiteIds(params.userId);
243
+ if (!scope.ok) {
204
244
  return {
205
245
  ok: false,
206
- error: { kind: "internal_error", message: workspaces.error.message },
246
+ error: { kind: "internal_error", message: scope.message },
207
247
  };
208
248
  }
209
-
210
- const siteIds = workspaces.value
211
- .filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
212
- .map((workspace) => workspace.id);
249
+ const siteIds = scope.siteIds;
213
250
 
214
251
  // No workspace the caller owns or administers means no lead can match, so
215
252
  // there is nothing to query. An empty page is the honest answer — the same