create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -33,8 +33,12 @@ import * as fs from "fs/promises";
33
33
  import * as os from "os";
34
34
  import * as path from "path";
35
35
 
36
+ import { logger } from "@working-theory/observability";
37
+
38
+ import { MOCK_PERSON_ID } from "../../lib/__mocks__/prisma.js";
36
39
  import { prisma } from "../../lib/prisma.js";
37
40
  import { routeLead } from "../lead-routing";
41
+ import * as personService from "../person";
38
42
 
39
43
  // ─── Helpers ──────────────────────────────────────────────────────────────────
40
44
  const sourceMeta = {
@@ -747,3 +751,491 @@ describe("#6730 — routeLead delivery modes", () => {
747
751
  });
748
752
  });
749
753
  });
754
+
755
+ // ── #7869 — the Idempotency-Key on submit (spec D3–D5, D10 cases 9–14) ──────
756
+ //
757
+ // Cases 9–13 run on `inbox` delivery (DATABASE_URL set), where the key is
758
+ // honoured; case 14 drops DATABASE_URL to prove a non-inbox delivery ignores
759
+ // it. A keyed create that loses the race throws P2002 and is answered from a
760
+ // re-read of the winner.
761
+ describe("#7869 — routeLead with an idempotency key", () => {
762
+ const KEY = "fixture-retry-0000-0000-0001";
763
+ const WINNER_ID = "lead_00000000-0000-7000-8000-00000000f001";
764
+ const ENV_KEYS = [
765
+ "DATABASE_URL",
766
+ "NODE_ENV",
767
+ "AILK_INBOX_FALLBACK_DIR",
768
+ "LEAD_CAPTURE_FILE",
769
+ "AILK_LEAD_NOTIFICATION_TO",
770
+ "AILK_LEAD_NOTIFICATION_FROM",
771
+ ] as const;
772
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
773
+ let tmpDir: string;
774
+
775
+ const p2002 = () =>
776
+ Object.assign(
777
+ new Error(
778
+ "Unique constraint failed on the fields: (`workspace_id`,`idempotency_key`)",
779
+ ),
780
+ { code: "P2002" },
781
+ );
782
+ const keyWhere = (workspaceId: string) => ({
783
+ where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey: KEY } },
784
+ select: { id: true, email: true, source: true, payload: true },
785
+ });
786
+ const createData = () =>
787
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
788
+ string,
789
+ unknown
790
+ >;
791
+
792
+ beforeEach(async () => {
793
+ jest.clearAllMocks();
794
+ saved = {};
795
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
796
+ for (const k of ENV_KEYS) delete process.env[k];
797
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-idem-test-"));
798
+ process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
799
+ process.env.DATABASE_URL = "postgres://stub";
800
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
801
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
802
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
803
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
804
+ mockSendLeadNotification.mockResolvedValue({
805
+ ok: true,
806
+ messageId: "msg-1",
807
+ });
808
+ });
809
+
810
+ afterEach(async () => {
811
+ for (const k of ENV_KEYS) {
812
+ if (saved[k] === undefined) delete process.env[k];
813
+ else process.env[k] = saved[k];
814
+ }
815
+ await fs.rm(tmpDir, { recursive: true, force: true });
816
+ });
817
+
818
+ it("case 9 — a P2002 race whose winner matches answers the winner's id: no fallback, no persist_failed, no notification, no capture record", async () => {
819
+ // Positive control first: a keyed create with no race writes exactly one
820
+ // line to the capture file, so the negative assertion below is load-bearing.
821
+ const controlFile = path.join(tmpDir, "control.jsonl");
822
+ process.env.LEAD_CAPTURE_FILE = controlFile;
823
+ const control = await routeLead(leadInput, sourceMeta, {
824
+ idempotencyKey: KEY,
825
+ });
826
+ expect(control).toMatchObject({
827
+ ok: true,
828
+ id: "lead_db_new",
829
+ persisted: "db",
830
+ });
831
+ expect(
832
+ (await fs.readFile(controlFile, "utf8")).trim().split("\n"),
833
+ ).toHaveLength(1);
834
+ expect(createData()).toMatchObject({ idempotencyKey: KEY });
835
+
836
+ jest.clearAllMocks();
837
+ const raceFile = path.join(tmpDir, "race.jsonl");
838
+ process.env.LEAD_CAPTURE_FILE = raceFile;
839
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
840
+ (prisma.lead.findUnique as jest.Mock)
841
+ .mockResolvedValueOnce(null) // the pre-write lookup misses
842
+ .mockResolvedValueOnce({
843
+ id: WINNER_ID,
844
+ email: leadInput.email,
845
+ source: leadInput.source,
846
+ payload: { ...leadInput.payload },
847
+ });
848
+ const before = await fs.readdir(tmpDir);
849
+
850
+ const result = await routeLead(leadInput, sourceMeta, {
851
+ idempotencyKey: KEY,
852
+ });
853
+
854
+ expect(result).toEqual({
855
+ ok: true,
856
+ replayed: true,
857
+ id: WINNER_ID,
858
+ persisted: "db",
859
+ email: { skipped: true, reason: "idempotent_replay" },
860
+ });
861
+ expect(prisma.lead.findUnique).toHaveBeenNthCalledWith(
862
+ 2,
863
+ keyWhere(leadInput.siteId),
864
+ );
865
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
866
+ await expect(fs.access(raceFile)).rejects.toThrow();
867
+ expect(await fs.readdir(tmpDir)).toEqual(before); // no file fallback
868
+ });
869
+
870
+ it("case 10 — a P2002 race whose winner carries a different email is idempotency_conflict", async () => {
871
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
872
+ (prisma.lead.findUnique as jest.Mock)
873
+ .mockResolvedValueOnce(null)
874
+ .mockResolvedValueOnce({
875
+ id: WINNER_ID,
876
+ email: "someone-else@example.com",
877
+ source: leadInput.source,
878
+ payload: leadInput.payload,
879
+ });
880
+
881
+ const result = await routeLead(leadInput, sourceMeta, {
882
+ idempotencyKey: KEY,
883
+ });
884
+
885
+ expect(result).toEqual({
886
+ ok: false,
887
+ error: {
888
+ kind: "idempotency_conflict",
889
+ message: "idempotency key reused",
890
+ },
891
+ });
892
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
893
+ });
894
+
895
+ it("case 11 — a P2002 race whose re-read finds nothing takes the existing persist_failed path", async () => {
896
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
897
+
898
+ const result = (await routeLead(leadInput, sourceMeta, {
899
+ idempotencyKey: KEY,
900
+ })) as Extract<RouteLeadResult, { ok: false }>;
901
+
902
+ expect(result.ok).toBe(false);
903
+ expect(result.error.kind).toBe("persist_failed");
904
+ expect(prisma.lead.findUnique).toHaveBeenCalledTimes(2);
905
+ });
906
+
907
+ it("case 12 — a P2002 with no key passed is persist_failed, as today, with no key lookup", async () => {
908
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
909
+
910
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
911
+ RouteLeadResult,
912
+ { ok: false }
913
+ >;
914
+
915
+ expect(result.ok).toBe(false);
916
+ expect(result.error.kind).toBe("persist_failed");
917
+ expect(prisma.lead.findUnique).not.toHaveBeenCalled();
918
+ expect(createData()).not.toHaveProperty("idempotencyKey");
919
+ });
920
+
921
+ it("case 13 — a lookup that throws still reaches the create (the existing path)", async () => {
922
+ (prisma.lead.findUnique as jest.Mock).mockRejectedValue(
923
+ new Error("connection reset"),
924
+ );
925
+
926
+ const result = await routeLead(leadInput, sourceMeta, {
927
+ idempotencyKey: KEY,
928
+ });
929
+
930
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
931
+ expect(result).toMatchObject({
932
+ ok: true,
933
+ id: "lead_db_new",
934
+ persisted: "db",
935
+ });
936
+ });
937
+
938
+ it("case 14 — a non-inbox delivery ignores the key: no lookup, no key on the create, the existing file path", async () => {
939
+ delete process.env.DATABASE_URL; // `local` delivery outside production
940
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
941
+ new Error("connection refused"),
942
+ );
943
+
944
+ const result = await routeLead(leadInput, sourceMeta, {
945
+ idempotencyKey: KEY,
946
+ });
947
+
948
+ expect(prisma.lead.findUnique).not.toHaveBeenCalled();
949
+ expect(createData()).not.toHaveProperty("idempotencyKey");
950
+ expect(result).toMatchObject({ ok: true, persisted: "file" });
951
+ });
952
+ });
953
+
954
+ // ─── #852 D6 — every submitted lead carries its Person ──────────────────────
955
+ //
956
+ // The Person step runs through the shared mock's `person` delegate, whose
957
+ // `findFirst` default resolves one fixed Person (`MOCK_PERSON_ID`). Lead
958
+ // capture is a never-gate surface: an unstorable address or a failed step
959
+ // writes the lead with `personId: null` and changes nothing else.
960
+ describe("#852 — the lead's Person (AC-3)", () => {
961
+ const ENV_KEYS = [
962
+ "DATABASE_URL",
963
+ "NODE_ENV",
964
+ "AILK_LEAD_NOTIFICATION_TO",
965
+ "AILK_LEAD_NOTIFICATION_FROM",
966
+ ] as const;
967
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
968
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000000852";
969
+ const createData = () =>
970
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<string, unknown>;
971
+ const personDelegate = () => ({
972
+ findFirst: jest.mocked(prisma.person.findFirst),
973
+ createMany: jest.mocked(prisma.person.createMany),
974
+ updateMany: jest.mocked(prisma.person.updateMany),
975
+ });
976
+ const personCalls = () =>
977
+ Object.values(personDelegate()).reduce((n, fn) => n + fn.mock.calls.length, 0);
978
+ let warn: jest.SpyInstance;
979
+ let error: jest.SpyInstance;
980
+
981
+ beforeEach(() => {
982
+ jest.clearAllMocks();
983
+ saved = {};
984
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
985
+ process.env.DATABASE_URL = "postgres://stub";
986
+ delete process.env.NODE_ENV;
987
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
988
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
989
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
990
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-852" });
991
+ warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined as never);
992
+ error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
993
+ });
994
+
995
+ afterEach(() => {
996
+ for (const k of ENV_KEYS) {
997
+ if (saved[k] === undefined) delete process.env[k];
998
+ else process.env[k] = saved[k];
999
+ }
1000
+ warn.mockRestore();
1001
+ error.mockRestore();
1002
+ });
1003
+
1004
+ it("a submit writes the Person findOrCreatePerson returned for (siteId, email)", async () => {
1005
+ const result = await routeLead(
1006
+ { ...leadInput, email: "Test@Example.com", payload: { name: "Tess", message: "hi" } },
1007
+ sourceMeta,
1008
+ );
1009
+
1010
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1011
+ expect(personDelegate()["findFirst"]).toHaveBeenCalledWith(
1012
+ expect.objectContaining({ where: { workspaceId: "site-abc", email: "test@example.com" } }),
1013
+ );
1014
+ expect(createData()).toEqual(expect.objectContaining({ personId: MOCK_PERSON_ID }));
1015
+ });
1016
+
1017
+ it("subscribe and callback make no person call", async () => {
1018
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({ id: "subscr_x" });
1019
+ (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_x" });
1020
+ await routeLead(subscribeInput, sourceMeta);
1021
+ await routeLead(callbackInput, sourceMeta);
1022
+ expect(personCalls()).toBe(0);
1023
+ });
1024
+
1025
+ it("an invalid_email Person result writes the lead with personId: null and one person-skipped warn with no email", async () => {
1026
+ // `leadInputSchema`'s email check refuses most unstorable addresses before
1027
+ // this step, so the Person result is stubbed to reach the branch.
1028
+ jest
1029
+ .spyOn(personService, "findOrCreatePerson")
1030
+ .mockResolvedValueOnce({ ok: false, error: { kind: "invalid_email" } });
1031
+ const result = await routeLead(leadInput, sourceMeta);
1032
+
1033
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1034
+ expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1035
+ const skipped = warn.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-skipped");
1036
+ expect(skipped).toHaveLength(1);
1037
+ expect(skipped[0]![0]).toEqual({ event: "person-skipped", kind: "invalid_email", siteId: "site-abc" });
1038
+ expect(JSON.stringify(warn.mock.calls)).not.toContain("example.com");
1039
+ });
1040
+
1041
+ it("an internal_error Person result is the same never-gate path as a throwing delegate", async () => {
1042
+ jest
1043
+ .spyOn(personService, "findOrCreatePerson")
1044
+ .mockResolvedValueOnce({ ok: false, error: { kind: "internal_error" } });
1045
+ const result = await routeLead(leadInput, sourceMeta);
1046
+
1047
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1048
+ expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1049
+ expect(error.mock.calls.map(([fields]) => fields)).toContainEqual({
1050
+ event: "person-failed",
1051
+ siteId: "site-abc",
1052
+ });
1053
+ });
1054
+
1055
+ it("a Person step that fails writes the lead with personId: null, logs person-failed with no email, and answers like a successful capture", async () => {
1056
+ const ok = await routeLead(leadInput, sourceMeta);
1057
+ jest.mocked(prisma.lead.create).mockClear();
1058
+ mockSendLeadNotification.mockClear();
1059
+ personDelegate()["findFirst"]!.mockRejectedValueOnce(new Error("relation \"people\" does not exist"));
1060
+
1061
+ const failed = await routeLead(leadInput, sourceMeta);
1062
+
1063
+ expect(failed).toEqual(ok);
1064
+ expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db", email: { ok: true } });
1065
+ expect(createData()).toEqual(expect.objectContaining({ personId: null }));
1066
+ const lines = error.mock.calls.filter(([fields]) => (fields as { event?: string }).event === "person-failed");
1067
+ expect(lines).toHaveLength(1);
1068
+ expect(lines[0]![0]).toEqual({ event: "person-failed", siteId: "site-abc" });
1069
+ expect(JSON.stringify(error.mock.calls)).not.toContain("test@example.com");
1070
+ });
1071
+ });
1072
+
1073
+ // ─── #7877 D10 — a spoke's checkout lead sets the Person's plan (AC-5) ──────
1074
+ //
1075
+ // The plan step runs only after this request's own `lead.create`, for a LIVE,
1076
+ // paid checkout lead a registered spoke's server posted with the session id
1077
+ // as its key. Every other lead makes no plan call; a failing plan step never
1078
+ // changes the lead's result.
1079
+ describe("#7877 — the checkout lead sets the plan (AC-5)", () => {
1080
+ const ENV_KEYS = [
1081
+ "DATABASE_URL",
1082
+ "NODE_ENV",
1083
+ "AILK_LEAD_NOTIFICATION_TO",
1084
+ "AILK_LEAD_NOTIFICATION_FROM",
1085
+ ] as const;
1086
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
1087
+ const LEAD_ID = "lead_00000000-0000-7000-8000-000000007877";
1088
+ const WS = "wksp_00000000-0000-7000-8000-000000007877";
1089
+ const KEY = "cs_live_a1B2c3"; // gitleaks:allow -- a synthetic Checkout Session id shape (#7877 AC-5), not a credential
1090
+ const spokeMeta = { ...sourceMeta, viaSpoke: true };
1091
+ const checkoutLead = {
1092
+ kind: "submit" as const,
1093
+ siteId: WS,
1094
+ source: "web",
1095
+ email: "cleo@x.io",
1096
+ payload: { site: "wendi", page: "checkout", offering: "wendi_retainer" } as Record<string, unknown>,
1097
+ };
1098
+ let setPlan: jest.SpyInstance;
1099
+ let info: jest.SpyInstance;
1100
+ let error: jest.SpyInstance;
1101
+
1102
+ beforeEach(() => {
1103
+ jest.clearAllMocks();
1104
+ saved = {};
1105
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
1106
+ process.env.DATABASE_URL = "postgres://stub";
1107
+ delete process.env.NODE_ENV;
1108
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1109
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1110
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: LEAD_ID });
1111
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1112
+ mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-7877" });
1113
+ setPlan = jest.spyOn(personService, "setPersonPlanOffering").mockResolvedValue("set");
1114
+ info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
1115
+ error = jest.spyOn(logger, "error").mockImplementation(() => undefined as never);
1116
+ });
1117
+
1118
+ afterEach(() => {
1119
+ for (const k of ENV_KEYS) {
1120
+ if (saved[k] === undefined) delete process.env[k];
1121
+ else process.env[k] = saved[k];
1122
+ }
1123
+ setPlan.mockRestore();
1124
+ info.mockRestore();
1125
+ error.mockRestore();
1126
+ });
1127
+
1128
+ const planLines = () =>
1129
+ [...info.mock.calls, ...error.mock.calls]
1130
+ .map(([fields]) => fields as Record<string, unknown>)
1131
+ .filter((fields) => fields.event === "checkout-plan");
1132
+
1133
+ it("a live spoke checkout lead sets the plan to the offering minus the <site>_ prefix", async () => {
1134
+ const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1135
+ expect(result).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1136
+ expect(setPlan).toHaveBeenCalledTimes(1);
1137
+ expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
1138
+ expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "set", siteId: WS }]);
1139
+ });
1140
+
1141
+ it("a bare slug offering is taken as it is", async () => {
1142
+ await routeLead(
1143
+ { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "retainer" } },
1144
+ spokeMeta,
1145
+ { idempotencyKey: KEY },
1146
+ );
1147
+ expect(setPlan).toHaveBeenCalledWith(WS, MOCK_PERSON_ID, "retainer");
1148
+ });
1149
+
1150
+ it("a positive integer amountTotal still qualifies", async () => {
1151
+ await routeLead(
1152
+ { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 4900 } },
1153
+ spokeMeta,
1154
+ { idempotencyKey: KEY },
1155
+ );
1156
+ expect(setPlan).toHaveBeenCalledTimes(1);
1157
+ });
1158
+
1159
+ it.each([
1160
+ ["an anonymous lead (viaSpoke false)", checkoutLead, sourceMeta, KEY],
1161
+ ["a test-mode session key", checkoutLead, spokeMeta, "cs_test_a1B2c3"],
1162
+ [
1163
+ "amountTotal 0",
1164
+ { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: 0 } },
1165
+ spokeMeta,
1166
+ KEY,
1167
+ ],
1168
+ [
1169
+ "a negative amountTotal",
1170
+ { ...checkoutLead, payload: { ...checkoutLead.payload, amountTotal: -5 } },
1171
+ spokeMeta,
1172
+ KEY,
1173
+ ],
1174
+ ["a key that is not a session id", checkoutLead, spokeMeta, "abcdefghijklmnop"],
1175
+ ["no key", checkoutLead, spokeMeta, undefined],
1176
+ ["source contact", { ...checkoutLead, source: "contact" }, spokeMeta, KEY],
1177
+ [
1178
+ "page contact",
1179
+ { ...checkoutLead, payload: { ...checkoutLead.payload, page: "contact" } },
1180
+ spokeMeta,
1181
+ KEY,
1182
+ ],
1183
+ [
1184
+ "a numeric offering",
1185
+ { ...checkoutLead, payload: { ...checkoutLead.payload, offering: 42 } },
1186
+ spokeMeta,
1187
+ KEY,
1188
+ ],
1189
+ ] as const)("no plan call for %s", async (_label, input, meta, key) => {
1190
+ const result = await routeLead(
1191
+ input as typeof checkoutLead,
1192
+ meta,
1193
+ key === undefined ? {} : { idempotencyKey: key },
1194
+ );
1195
+ expect(result).toMatchObject({ ok: true, persisted: "db" });
1196
+ expect(setPlan).not.toHaveBeenCalled();
1197
+ expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
1198
+ expect(planLines()).toEqual([]);
1199
+ });
1200
+
1201
+ it("an offering that is not a key after the strip logs skipped and makes no plan call", async () => {
1202
+ await routeLead(
1203
+ { ...checkoutLead, payload: { ...checkoutLead.payload, offering: "Wendi_Retainer!" } },
1204
+ spokeMeta,
1205
+ { idempotencyKey: KEY },
1206
+ );
1207
+ expect(setPlan).not.toHaveBeenCalled();
1208
+ expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "skipped", siteId: WS }]);
1209
+ });
1210
+
1211
+ it("a replayed key writes nothing and sets nothing", async () => {
1212
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1213
+ id: LEAD_ID,
1214
+ email: checkoutLead.email,
1215
+ source: checkoutLead.source,
1216
+ payload: checkoutLead.payload,
1217
+ });
1218
+ const result = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1219
+ expect(result).toMatchObject({ ok: true, replayed: true, id: LEAD_ID });
1220
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1221
+ expect(setPlan).not.toHaveBeenCalled();
1222
+ });
1223
+
1224
+ it.each([
1225
+ ["throws", () => setPlan.mockRejectedValueOnce(new Error("down"))],
1226
+ ["answers not_found", () => setPlan.mockResolvedValueOnce("not_found")],
1227
+ ])("a plan step that %s answers exactly like a success, with one failed line naming no offering, email or key", async (_label, arrange) => {
1228
+ const ok = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1229
+ info.mockClear();
1230
+ error.mockClear();
1231
+ arrange();
1232
+ const failed = await routeLead(checkoutLead, spokeMeta, { idempotencyKey: KEY });
1233
+ expect(failed).toEqual(ok);
1234
+ expect(failed).toMatchObject({ ok: true, id: LEAD_ID, persisted: "db" });
1235
+ expect(planLines()).toEqual([{ event: "checkout-plan", outcome: "failed", siteId: WS }]);
1236
+ const logged = JSON.stringify([...info.mock.calls, ...error.mock.calls]);
1237
+ expect(logged).not.toContain("retainer");
1238
+ expect(logged).not.toContain("cleo@x.io");
1239
+ expect(logged).not.toContain(KEY);
1240
+ });
1241
+ });