@open-mercato/onboarding 0.7.1-develop.7150.1.c1941e0c22 → 0.7.1-develop.7151.1.00d0391847

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.
@@ -1,2 +1,2 @@
1
- [build:onboarding] found 38 entry points
1
+ [build:onboarding] found 39 entry points
2
2
  [build:onboarding] built successfully
@@ -0,0 +1,119 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ import { getTokenScope } from "@open-mercato/core/helpers/integration/generalFixtures";
4
+ import { withClient } from "@open-mercato/core/helpers/integration/dbFixtures";
5
+ import { emailHashLookupValues } from "@open-mercato/core/modules/auth/lib/emailHash";
6
+ import {
7
+ clearCapturedSystemEmails,
8
+ isChannelSeedingAvailable,
9
+ seedSystemEmailChannel,
10
+ waitForCapturedSystemEmail
11
+ } from "@open-mercato/core/helpers/integration/communicationChannelsFixtures";
12
+ function emailLookup(email) {
13
+ return { clause: "email_hash = any($1)", values: emailHashLookupValues(email) };
14
+ }
15
+ async function deleteOnboardingRequest(email) {
16
+ const lookup = emailLookup(email);
17
+ await withClient(async (client) => {
18
+ await client.query(`delete from onboarding_requests where ${lookup.clause}`, [lookup.values]);
19
+ }).catch(() => void 0);
20
+ }
21
+ async function markOnboardingReady(email, tenantId, organizationId, userId) {
22
+ const lookup = emailLookup(email);
23
+ await withClient(async (client) => {
24
+ const result = await client.query(
25
+ `update onboarding_requests
26
+ set status = 'completed',
27
+ tenant_id = $2,
28
+ organization_id = $3,
29
+ user_id = $4,
30
+ preparation_completed_at = now(),
31
+ ready_email_sent_at = null
32
+ where ${lookup.clause}`,
33
+ [lookup.values, tenantId, organizationId, userId]
34
+ );
35
+ expect(result.rowCount, `markOnboardingReady should match the onboarding request for ${email}`).toBeGreaterThan(0);
36
+ });
37
+ }
38
+ test.describe("TC-ONBOARDING-EMAIL-001: Onboarding emails use system channel", () => {
39
+ test("start, ready, and demo feedback emails dispatch through Communications Hub", async ({ request }) => {
40
+ test.slow();
41
+ const token = await getAuthToken(request, "admin");
42
+ const scope = getTokenScope(token);
43
+ const seedingAvailable = await isChannelSeedingAvailable(request, token);
44
+ test.skip(!seedingAvailable, "OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled.");
45
+ const stamp = Date.now();
46
+ const onboardingEmail = `qa-onboarding-email-${stamp}@example.test`;
47
+ const feedbackEmail = `qa-demo-feedback-${stamp}@example.test`;
48
+ const adminEmail = process.env.ADMIN_EMAIL ?? "piotr@catchthetornado.com";
49
+ await seedSystemEmailChannel(request, token);
50
+ await clearCapturedSystemEmails(request, token, { systemRecipient: onboardingEmail });
51
+ await clearCapturedSystemEmails(request, token, { systemRecipient: adminEmail });
52
+ try {
53
+ const start = await request.post("/api/onboarding/onboarding", {
54
+ headers: { "Content-Type": "application/json" },
55
+ data: {
56
+ email: onboardingEmail,
57
+ firstName: "QA",
58
+ lastName: "Onboarding",
59
+ organizationName: `QA Onboarding ${stamp}`,
60
+ password: `Valid1!Pass${stamp}`,
61
+ confirmPassword: `Valid1!Pass${stamp}`,
62
+ termsAccepted: true,
63
+ marketingConsent: false
64
+ }
65
+ });
66
+ expect(start.status()).toBe(200);
67
+ const verificationEmail = await waitForCapturedSystemEmail(
68
+ request,
69
+ token,
70
+ (email) => email.metadata?.to === onboardingEmail && email.metadata?.subject === "Confirm your email to finish onboarding",
71
+ { description: "onboarding verification email", systemRecipient: onboardingEmail }
72
+ );
73
+ expect(verificationEmail.scope.tenantId).toBe("system");
74
+ expect(verificationEmail.content.bodyFormat).toBe("html");
75
+ await waitForCapturedSystemEmail(
76
+ request,
77
+ token,
78
+ (email) => String(email.metadata?.to ?? "").includes("@") && email.metadata?.subject === "New self-service onboarding request",
79
+ { description: "onboarding admin email", systemRecipient: adminEmail }
80
+ );
81
+ await markOnboardingReady(onboardingEmail, scope.tenantId, scope.organizationId, scope.userId);
82
+ await clearCapturedSystemEmails(request, token);
83
+ const status = await request.get(`/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(scope.tenantId)}`, {
84
+ headers: {
85
+ cookie: `om_login_tenant=${encodeURIComponent(scope.tenantId)}`
86
+ }
87
+ });
88
+ expect(status.status()).toBe(200);
89
+ const readyEmail = await waitForCapturedSystemEmail(
90
+ request,
91
+ token,
92
+ (email) => email.metadata?.to === onboardingEmail && email.metadata?.subject === "Your Open Mercato workspace is ready",
93
+ { description: "onboarding ready email" }
94
+ );
95
+ expect(readyEmail.scope.tenantId).toBe(scope.tenantId);
96
+ expect(readyEmail.content.bodyFormat).toBe("html");
97
+ await clearCapturedSystemEmails(request, token);
98
+ const feedback = await request.post("/api/onboarding/demo-feedback", {
99
+ headers: { "Content-Type": "application/json" },
100
+ data: {
101
+ email: feedbackEmail,
102
+ message: "Integration coverage feedback",
103
+ termsAccepted: true,
104
+ marketingConsent: false
105
+ }
106
+ });
107
+ expect(feedback.status()).toBe(200);
108
+ await waitForCapturedSystemEmail(
109
+ request,
110
+ token,
111
+ (email) => String(email.metadata?.to ?? "").includes("@") && email.metadata?.subject === `Demo feedback from ${feedbackEmail}`,
112
+ { description: "demo feedback admin email", systemRecipient: adminEmail }
113
+ );
114
+ } finally {
115
+ await deleteOnboardingRequest(onboardingEmail);
116
+ }
117
+ });
118
+ });
119
+ //# sourceMappingURL=TC-ONBOARDING-EMAIL-001.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/onboarding/__integration__/TC-ONBOARDING-EMAIL-001.spec.ts"],
4
+ "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures'\nimport { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'\nimport {\n clearCapturedSystemEmails,\n isChannelSeedingAvailable,\n seedSystemEmailChannel,\n waitForCapturedSystemEmail,\n} from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'\n\n// `onboarding_requests.email` is encrypted at rest, so a plaintext `where email = $1` matches\n// nothing. `email_hash` is the deterministic lookup key the service itself uses; the candidate\n// list covers key rotation the same way `lookupHashCandidates` does for the application.\nfunction emailLookup(email: string): { clause: string; values: string[] } {\n return { clause: 'email_hash = any($1)', values: emailHashLookupValues(email) }\n}\n\nasync function deleteOnboardingRequest(email: string): Promise<void> {\n const lookup = emailLookup(email)\n await withClient(async (client) => {\n await client.query(`delete from onboarding_requests where ${lookup.clause}`, [lookup.values])\n }).catch(() => undefined)\n}\n\nasync function markOnboardingReady(email: string, tenantId: string, organizationId: string, userId: string): Promise<void> {\n const lookup = emailLookup(email)\n await withClient(async (client) => {\n const result = await client.query(\n `update onboarding_requests\n set status = 'completed',\n tenant_id = $2,\n organization_id = $3,\n user_id = $4,\n preparation_completed_at = now(),\n ready_email_sent_at = null\n where ${lookup.clause}`,\n [lookup.values, tenantId, organizationId, userId],\n )\n // Fail loudly here rather than let the status call 404 three assertions later.\n expect(result.rowCount, `markOnboardingReady should match the onboarding request for ${email}`).toBeGreaterThan(0)\n })\n}\n\ntest.describe('TC-ONBOARDING-EMAIL-001: Onboarding emails use system channel', () => {\n test('start, ready, and demo feedback emails dispatch through Communications Hub', async ({ request }) => {\n test.slow()\n const token = await getAuthToken(request, 'admin')\n const scope = getTokenScope(token)\n const seedingAvailable = await isChannelSeedingAvailable(request, token)\n test.skip(!seedingAvailable, 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled.')\n\n const stamp = Date.now()\n const onboardingEmail = `qa-onboarding-email-${stamp}@example.test`\n const feedbackEmail = `qa-demo-feedback-${stamp}@example.test`\n const adminEmail = process.env.ADMIN_EMAIL ?? 'piotr@catchthetornado.com'\n\n await seedSystemEmailChannel(request, token)\n await clearCapturedSystemEmails(request, token, { systemRecipient: onboardingEmail })\n await clearCapturedSystemEmails(request, token, { systemRecipient: adminEmail })\n\n try {\n const start = await request.post('/api/onboarding/onboarding', {\n headers: { 'Content-Type': 'application/json' },\n data: {\n email: onboardingEmail,\n firstName: 'QA',\n lastName: 'Onboarding',\n organizationName: `QA Onboarding ${stamp}`,\n password: `Valid1!Pass${stamp}`,\n confirmPassword: `Valid1!Pass${stamp}`,\n termsAccepted: true,\n marketingConsent: false,\n },\n })\n expect(start.status()).toBe(200)\n\n const verificationEmail = await waitForCapturedSystemEmail(\n request,\n token,\n (email) => email.metadata?.to === onboardingEmail && email.metadata?.subject === 'Confirm your email to finish onboarding',\n { description: 'onboarding verification email', systemRecipient: onboardingEmail },\n )\n expect(verificationEmail.scope.tenantId).toBe('system')\n expect(verificationEmail.content.bodyFormat).toBe('html')\n\n await waitForCapturedSystemEmail(\n request,\n token,\n (email) => String(email.metadata?.to ?? '').includes('@') && email.metadata?.subject === 'New self-service onboarding request',\n { description: 'onboarding admin email', systemRecipient: adminEmail },\n )\n\n await markOnboardingReady(onboardingEmail, scope.tenantId, scope.organizationId, scope.userId)\n await clearCapturedSystemEmails(request, token)\n\n const status = await request.get(`/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(scope.tenantId)}`, {\n headers: {\n cookie: `om_login_tenant=${encodeURIComponent(scope.tenantId)}`,\n },\n })\n expect(status.status()).toBe(200)\n const readyEmail = await waitForCapturedSystemEmail(\n request,\n token,\n (email) => email.metadata?.to === onboardingEmail && email.metadata?.subject === 'Your Open Mercato workspace is ready',\n { description: 'onboarding ready email' },\n )\n expect(readyEmail.scope.tenantId).toBe(scope.tenantId)\n expect(readyEmail.content.bodyFormat).toBe('html')\n\n await clearCapturedSystemEmails(request, token)\n const feedback = await request.post('/api/onboarding/demo-feedback', {\n headers: { 'Content-Type': 'application/json' },\n data: {\n email: feedbackEmail,\n message: 'Integration coverage feedback',\n termsAccepted: true,\n marketingConsent: false,\n },\n })\n expect(feedback.status()).toBe(200)\n\n await waitForCapturedSystemEmail(\n request,\n token,\n (email) => String(email.metadata?.to ?? '').includes('@') && email.metadata?.subject === `Demo feedback from ${feedbackEmail}`,\n { description: 'demo feedback admin email', systemRecipient: adminEmail },\n )\n } finally {\n await deleteOnboardingRequest(onboardingEmail)\n }\n })\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { sendEmail } from "@open-mercato/shared/lib/email/send";
4
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
4
5
  import FeedbackEmail from "@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail";
5
6
  import { checkAuthRateLimit } from "@open-mercato/core/modules/auth/lib/rateLimitCheck";
6
7
  import { readEndpointRateLimitConfig } from "@open-mercato/shared/lib/ratelimit/config";
@@ -42,6 +43,7 @@ async function POST(req) {
42
43
  }
43
44
  const { email, message, marketingConsent } = parsed.data;
44
45
  const adminEmail = process.env.ADMIN_EMAIL || "piotr@catchthetornado.com";
46
+ await createRequestContainer();
45
47
  const marketingText = marketingConsent ? "Marketing consent: Yes" : "Marketing consent: No";
46
48
  const adminCopy = {
47
49
  preview: `Demo feedback from ${email}`,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/onboarding/api/demo-feedback/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport FeedbackEmail from '@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('onboarding').child({ component: 'demo-feedback' })\n\nconst demoFeedbackIpRateLimitConfig = readEndpointRateLimitConfig('DEMO_FEEDBACK_IP', {\n points: 5, duration: 300, blockDuration: 300, keyPrefix: 'demo-feedback-ip',\n})\n\nexport const metadata = {\n path: '/onboarding/demo-feedback',\n POST: {\n requireAuth: false,\n },\n}\n\nconst feedbackSchema = z.object({\n email: z.string().email(),\n message: z.string().max(5000).optional().default(''),\n termsAccepted: z.literal(true),\n marketingConsent: z.boolean().optional().default(false),\n})\n\nexport async function POST(req: Request) {\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: demoFeedbackIpRateLimitConfig,\n })\n if (rateLimitError) return rateLimitError\n\n let payload: unknown\n try {\n payload = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid payload' }, { status: 400 })\n }\n\n const parsed = feedbackSchema.safeParse(payload)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Please check the form and try again.' }, { status: 400 })\n }\n\n const { email, message, marketingConsent } = parsed.data\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n\n const marketingText = marketingConsent ? 'Marketing consent: Yes' : 'Marketing consent: No'\n\n const adminCopy = {\n preview: `Demo feedback from ${email}`,\n heading: 'New demo feedback',\n body: `${email} submitted a feedback/contact request from the demo environment.`,\n senderEmailLabel: 'From email:',\n senderEmail: email,\n messageLabel: 'Message:',\n message: message || '(no message provided)',\n marketingConsent: marketingText,\n footer: 'Open Mercato \\u00b7 Demo feedback',\n }\n\n try {\n await sendEmail({\n to: adminEmail,\n subject: `Demo feedback from ${email}`,\n react: FeedbackEmail({ copy: adminCopy }),\n })\n } catch (err) {\n logger.error('Admin email failed', { err })\n return NextResponse.json({ ok: false, error: 'Failed to send feedback. Please try again.' }, { status: 502 })\n }\n\n return NextResponse.json({ ok: true })\n}\n\nexport default POST\n\nconst feedbackTag = 'Demo'\n\nconst feedbackPostDoc: OpenApiMethodDoc = {\n summary: 'Submit demo feedback',\n description: 'Sends a feedback/contact request from the demo environment to the configured admin.',\n tags: [feedbackTag],\n requestBody: {\n contentType: 'application/json',\n schema: feedbackSchema,\n description: 'Feedback form payload.',\n },\n responses: [\n { status: 200, description: 'Feedback sent successfully.' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: feedbackTag,\n summary: 'Demo feedback submission',\n methods: {\n POST: feedbackPostDoc,\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,iBAAiB;AAC1B,OAAO,mBAAmB;AAC1B,SAAS,0BAA0B;AACnC,SAAS,mCAAmC;AAE5C,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAE9E,MAAM,gCAAgC,4BAA4B,oBAAoB;AAAA,EACpF,QAAQ;AAAA,EAAG,UAAU;AAAA,EAAK,eAAe;AAAA,EAAK,WAAW;AAC3D,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA,IACJ,aAAa;AAAA,EACf;AACF;AAEA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,OAAO,EAAE,OAAO,EAAE,MAAM;AAAA,EACxB,SAAS,EAAE,OAAO,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,QAAQ,EAAE;AAAA,EACnD,eAAe,EAAE,QAAQ,IAAI;AAAA,EAC7B,kBAAkB,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,KAAK;AACxD,CAAC;AAED,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,OAAO,eAAe,IAAI,MAAM,mBAAmB;AAAA,IACzD;AAAA,IACA,UAAU;AAAA,EACZ,CAAC;AACD,MAAI,eAAgB,QAAO;AAE3B,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,IAAI,KAAK;AAAA,EAC3B,QAAQ;AACN,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnF;AAEA,QAAM,SAAS,eAAe,UAAU,OAAO;AAC/C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,uCAAuC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxG;AAEA,QAAM,EAAE,OAAO,SAAS,iBAAiB,IAAI,OAAO;AACpD,QAAM,aAAa,QAAQ,IAAI,eAAe;AAE9C,QAAM,gBAAgB,mBAAmB,2BAA2B;AAEpE,QAAM,YAAY;AAAA,IAChB,SAAS,sBAAsB,KAAK;AAAA,IACpC,SAAS;AAAA,IACT,MAAM,GAAG,KAAK;AAAA,IACd,kBAAkB;AAAA,IAClB,aAAa;AAAA,IACb,cAAc;AAAA,IACd,SAAS,WAAW;AAAA,IACpB,kBAAkB;AAAA,IAClB,QAAQ;AAAA,EACV;AAEA,MAAI;AACF,UAAM,UAAU;AAAA,MACd,IAAI;AAAA,MACJ,SAAS,sBAAsB,KAAK;AAAA,MACpC,OAAO,cAAc,EAAE,MAAM,UAAU,CAAC;AAAA,IAC1C,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,MAAM,sBAAsB,EAAE,IAAI,CAAC;AAC1C,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,6CAA6C,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9G;AAEA,SAAO,aAAa,KAAK,EAAE,IAAI,KAAK,CAAC;AACvC;AAEA,IAAO,gBAAQ;AAEf,MAAM,cAAc;AAEpB,MAAM,kBAAoC;AAAA,EACxC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,MAAM,CAAC,WAAW;AAAA,EAClB,aAAa;AAAA,IACX,aAAa;AAAA,IACb,QAAQ;AAAA,IACR,aAAa;AAAA,EACf;AAAA,EACA,WAAW;AAAA,IACT,EAAE,QAAQ,KAAK,aAAa,8BAA8B;AAAA,EAC5D;AACF;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,EACR;AACF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport FeedbackEmail from '@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('onboarding').child({ component: 'demo-feedback' })\n\nconst demoFeedbackIpRateLimitConfig = readEndpointRateLimitConfig('DEMO_FEEDBACK_IP', {\n points: 5, duration: 300, blockDuration: 300, keyPrefix: 'demo-feedback-ip',\n})\n\nexport const metadata = {\n path: '/onboarding/demo-feedback',\n POST: {\n requireAuth: false,\n },\n}\n\nconst feedbackSchema = z.object({\n email: z.string().email(),\n message: z.string().max(5000).optional().default(''),\n termsAccepted: z.literal(true),\n marketingConsent: z.boolean().optional().default(false),\n})\n\nexport async function POST(req: Request) {\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: demoFeedbackIpRateLimitConfig,\n })\n if (rateLimitError) return rateLimitError\n\n let payload: unknown\n try {\n payload = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid payload' }, { status: 400 })\n }\n\n const parsed = feedbackSchema.safeParse(payload)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Please check the form and try again.' }, { status: 400 })\n }\n\n const { email, message, marketingConsent } = parsed.data\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n // Load-bearing despite the discarded result: building the request container is what runs\n // `communication_channels`' DI registration, and that is the only caller of\n // `registerEmailTransport`. Without it `sendEmail` below finds no registered transport and\n // throws `EMAIL_TRANSPORT_NOT_CONFIGURED`. This route is the one `sendEmail` call site with no\n // container of its own, so the dependency has to be satisfied explicitly here.\n await createRequestContainer()\n\n const marketingText = marketingConsent ? 'Marketing consent: Yes' : 'Marketing consent: No'\n\n const adminCopy = {\n preview: `Demo feedback from ${email}`,\n heading: 'New demo feedback',\n body: `${email} submitted a feedback/contact request from the demo environment.`,\n senderEmailLabel: 'From email:',\n senderEmail: email,\n messageLabel: 'Message:',\n message: message || '(no message provided)',\n marketingConsent: marketingText,\n footer: 'Open Mercato \\u00b7 Demo feedback',\n }\n\n try {\n await sendEmail({\n to: adminEmail,\n subject: `Demo feedback from ${email}`,\n react: FeedbackEmail({ copy: adminCopy }),\n })\n } catch (err) {\n logger.error('Admin email failed', { err })\n return NextResponse.json({ ok: false, error: 'Failed to send feedback. Please try again.' }, { status: 502 })\n }\n\n return NextResponse.json({ ok: true })\n}\n\nexport default POST\n\nconst feedbackTag = 'Demo'\n\nconst feedbackPostDoc: OpenApiMethodDoc = {\n summary: 'Submit demo feedback',\n description: 'Sends a feedback/contact request from the demo environment to the configured admin.',\n tags: [feedbackTag],\n requestBody: {\n contentType: 'application/json',\n schema: feedbackSchema,\n description: 'Feedback form payload.',\n },\n responses: [\n { status: 200, description: 'Feedback sent successfully.' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: feedbackTag,\n summary: 'Demo feedback submission',\n methods: {\n POST: feedbackPostDoc,\n },\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,iBAAiB;AAC1B,SAAS,8BAA8B;AACvC,OAAO,mBAAmB;AAC1B,SAAS,0BAA0B;AACnC,SAAS,mCAAmC;AAE5C,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;AAE9E,MAAM,gCAAgC,4BAA4B,oBAAoB;AAAA,EACpF,QAAQ;AAAA,EAAG,UAAU;AAAA,EAAK,eAAe;AAAA,EAAK,WAAW;AAC3D,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA,IACJ,aAAa;AAAA,EACf;AACF;AAEA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,OAAO,EAAE,OAAO,EAAE,MAAM;AAAA,EACxB,SAAS,EAAE,OAAO,EAAE,IAAI,GAAI,EAAE,SAAS,EAAE,QAAQ,EAAE;AAAA,EACnD,eAAe,EAAE,QAAQ,IAAI;AAAA,EAC7B,kBAAkB,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,KAAK;AACxD,CAAC;AAED,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,OAAO,eAAe,IAAI,MAAM,mBAAmB;AAAA,IACzD;AAAA,IACA,UAAU;AAAA,EACZ,CAAC;AACD,MAAI,eAAgB,QAAO;AAE3B,MAAI;AACJ,MAAI;AACF,cAAU,MAAM,IAAI,KAAK;AAAA,EAC3B,QAAQ;AACN,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,kBAAkB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACnF;AAEA,QAAM,SAAS,eAAe,UAAU,OAAO;AAC/C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,uCAAuC,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxG;AAEA,QAAM,EAAE,OAAO,SAAS,iBAAiB,IAAI,OAAO;AACpD,QAAM,aAAa,QAAQ,IAAI,eAAe;AAM9C,QAAM,uBAAuB;AAE7B,QAAM,gBAAgB,mBAAmB,2BAA2B;AAEpE,QAAM,YAAY;AAAA,IAChB,SAAS,sBAAsB,KAAK;AAAA,IACpC,SAAS;AAAA,IACT,MAAM,GAAG,KAAK;AAAA,IACd,kBAAkB;AAAA,IAClB,aAAa;AAAA,IACb,cAAc;AAAA,IACd,SAAS,WAAW;AAAA,IACpB,kBAAkB;AAAA,IAClB,QAAQ;AAAA,EACV;AAEA,MAAI;AACF,UAAM,UAAU;AAAA,MACd,IAAI;AAAA,MACJ,SAAS,sBAAsB,KAAK;AAAA,MACpC,OAAO,cAAc,EAAE,MAAM,UAAU,CAAC;AAAA,IAC1C,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,MAAM,sBAAsB,EAAE,IAAI,CAAC;AAC1C,WAAO,aAAa,KAAK,EAAE,IAAI,OAAO,OAAO,6CAA6C,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9G;AAEA,SAAO,aAAa,KAAK,EAAE,IAAI,KAAK,CAAC;AACvC;AAEA,IAAO,gBAAQ;AAEf,MAAM,cAAc;AAEpB,MAAM,kBAAoC;AAAA,EACxC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,MAAM,CAAC,WAAW;AAAA,EAClB,aAAa;AAAA,IACX,aAAa;AAAA,IACb,QAAQ;AAAA,IACR,aAAa;AAAA,EACf;AAAA,EACA,WAAW;AAAA,IACT,EAAE,QAAQ,KAAK,aAAa,8BAA8B;AAAA,EAC5D;AACF;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,EACR;AACF;",
6
6
  "names": []
7
7
  }
@@ -38,7 +38,9 @@ async function sendWorkspaceReadyEmail(args) {
38
38
  await sendEmail({
39
39
  to: request.email,
40
40
  subject,
41
- react: WorkspaceReadyEmail({ loginUrl, copy: emailCopy })
41
+ react: WorkspaceReadyEmail({ loginUrl, copy: emailCopy }),
42
+ tenantId: args.tenantId,
43
+ organizationId: request.organizationId ?? null
42
44
  });
43
45
  await service.markReadyEmailSent(request, /* @__PURE__ */ new Date());
44
46
  return true;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/lib/ready-email.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { loadDictionary } from '@open-mercato/shared/lib/i18n/server'\nimport { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { getSecurityEmailBaseUrl } from '@open-mercato/shared/lib/url'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport WorkspaceReadyEmail from '@open-mercato/onboarding/modules/onboarding/emails/WorkspaceReadyEmail'\n\nfunction resolveLocale(rawLocale: string | null | undefined): Locale {\n if (rawLocale && locales.includes(rawLocale as Locale)) return rawLocale as Locale\n return defaultLocale\n}\n\nexport async function sendWorkspaceReadyEmail(args: {\n requestId: string\n tenantId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n const request = await service.findById(args.requestId)\n if (!request || request.readyEmailSentAt) return false\n const locale = resolveLocale(request.locale)\n const dict = await loadDictionary(locale)\n const translate = createFallbackTranslator(dict)\n const baseUrl = getSecurityEmailBaseUrl()\n const loginUrl = `${baseUrl}/login?tenant=${encodeURIComponent(args.tenantId)}`\n const firstName = request.firstName?.trim() || request.organizationName?.trim() || request.email\n const subject = translate('onboarding.readyEmail.subject', 'Your Open Mercato workspace is ready')\n const emailCopy = {\n preview: translate('onboarding.readyEmail.preview', 'Your workspace is ready. Use your secure login link to sign in.'),\n heading: translate('onboarding.readyEmail.heading', 'Your workspace is ready'),\n greeting: translate('onboarding.readyEmail.greeting', 'Hi {firstName},', { firstName }),\n body: translate(\n 'onboarding.readyEmail.body',\n 'Your Open Mercato workspace for {organizationName} has finished preparing. Use the secure link below to sign in.',\n { organizationName: request.organizationName },\n ),\n cta: translate('onboarding.readyEmail.cta', 'Open login'),\n footer: translate('onboarding.readyEmail.footer', 'Open Mercato \u00B7 Onboarding service'),\n }\n\n await sendEmail({\n to: request.email,\n subject,\n react: WorkspaceReadyEmail({ loginUrl, copy: emailCopy }),\n })\n await service.markReadyEmailSent(request, new Date())\n return true\n}\n"],
5
- "mappings": "AACA,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAC/B,SAAS,eAAe,eAA4B;AACpD,SAAS,gCAAgC;AACzC,SAAS,iBAAiB;AAC1B,SAAS,+BAA+B;AACxC,SAAS,yBAAyB;AAClC,OAAO,yBAAyB;AAEhC,SAAS,cAAc,WAA8C;AACnE,MAAI,aAAa,QAAQ,SAAS,SAAmB,EAAG,QAAO;AAC/D,SAAO;AACT;AAEA,eAAsB,wBAAwB,MAG3C;AACD,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,QAAM,UAAU,IAAI,kBAAkB,EAAE;AACxC,QAAM,UAAU,MAAM,QAAQ,SAAS,KAAK,SAAS;AACrD,MAAI,CAAC,WAAW,QAAQ,iBAAkB,QAAO;AACjD,QAAM,SAAS,cAAc,QAAQ,MAAM;AAC3C,QAAM,OAAO,MAAM,eAAe,MAAM;AACxC,QAAM,YAAY,yBAAyB,IAAI;AAC/C,QAAM,UAAU,wBAAwB;AACxC,QAAM,WAAW,GAAG,OAAO,iBAAiB,mBAAmB,KAAK,QAAQ,CAAC;AAC7E,QAAM,YAAY,QAAQ,WAAW,KAAK,KAAK,QAAQ,kBAAkB,KAAK,KAAK,QAAQ;AAC3F,QAAM,UAAU,UAAU,iCAAiC,sCAAsC;AACjG,QAAM,YAAY;AAAA,IAChB,SAAS,UAAU,iCAAiC,iEAAiE;AAAA,IACrH,SAAS,UAAU,iCAAiC,yBAAyB;AAAA,IAC7E,UAAU,UAAU,kCAAkC,mBAAmB,EAAE,UAAU,CAAC;AAAA,IACtF,MAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,EAAE,kBAAkB,QAAQ,iBAAiB;AAAA,IAC/C;AAAA,IACA,KAAK,UAAU,6BAA6B,YAAY;AAAA,IACxD,QAAQ,UAAU,gCAAgC,sCAAmC;AAAA,EACvF;AAEA,QAAM,UAAU;AAAA,IACd,IAAI,QAAQ;AAAA,IACZ;AAAA,IACA,OAAO,oBAAoB,EAAE,UAAU,MAAM,UAAU,CAAC;AAAA,EAC1D,CAAC;AACD,QAAM,QAAQ,mBAAmB,SAAS,oBAAI,KAAK,CAAC;AACpD,SAAO;AACT;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { loadDictionary } from '@open-mercato/shared/lib/i18n/server'\nimport { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport { getSecurityEmailBaseUrl } from '@open-mercato/shared/lib/url'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport WorkspaceReadyEmail from '@open-mercato/onboarding/modules/onboarding/emails/WorkspaceReadyEmail'\n\nfunction resolveLocale(rawLocale: string | null | undefined): Locale {\n if (rawLocale && locales.includes(rawLocale as Locale)) return rawLocale as Locale\n return defaultLocale\n}\n\nexport async function sendWorkspaceReadyEmail(args: {\n requestId: string\n tenantId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n const request = await service.findById(args.requestId)\n if (!request || request.readyEmailSentAt) return false\n const locale = resolveLocale(request.locale)\n const dict = await loadDictionary(locale)\n const translate = createFallbackTranslator(dict)\n const baseUrl = getSecurityEmailBaseUrl()\n const loginUrl = `${baseUrl}/login?tenant=${encodeURIComponent(args.tenantId)}`\n const firstName = request.firstName?.trim() || request.organizationName?.trim() || request.email\n const subject = translate('onboarding.readyEmail.subject', 'Your Open Mercato workspace is ready')\n const emailCopy = {\n preview: translate('onboarding.readyEmail.preview', 'Your workspace is ready. Use your secure login link to sign in.'),\n heading: translate('onboarding.readyEmail.heading', 'Your workspace is ready'),\n greeting: translate('onboarding.readyEmail.greeting', 'Hi {firstName},', { firstName }),\n body: translate(\n 'onboarding.readyEmail.body',\n 'Your Open Mercato workspace for {organizationName} has finished preparing. Use the secure link below to sign in.',\n { organizationName: request.organizationName },\n ),\n cta: translate('onboarding.readyEmail.cta', 'Open login'),\n footer: translate('onboarding.readyEmail.footer', 'Open Mercato \u00B7 Onboarding service'),\n }\n\n await sendEmail({\n to: request.email,\n subject,\n react: WorkspaceReadyEmail({ loginUrl, copy: emailCopy }),\n tenantId: args.tenantId,\n organizationId: request.organizationId ?? null,\n })\n await service.markReadyEmailSent(request, new Date())\n return true\n}\n"],
5
+ "mappings": "AACA,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAC/B,SAAS,eAAe,eAA4B;AACpD,SAAS,gCAAgC;AACzC,SAAS,iBAAiB;AAC1B,SAAS,+BAA+B;AACxC,SAAS,yBAAyB;AAClC,OAAO,yBAAyB;AAEhC,SAAS,cAAc,WAA8C;AACnE,MAAI,aAAa,QAAQ,SAAS,SAAmB,EAAG,QAAO;AAC/D,SAAO;AACT;AAEA,eAAsB,wBAAwB,MAG3C;AACD,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,QAAM,UAAU,IAAI,kBAAkB,EAAE;AACxC,QAAM,UAAU,MAAM,QAAQ,SAAS,KAAK,SAAS;AACrD,MAAI,CAAC,WAAW,QAAQ,iBAAkB,QAAO;AACjD,QAAM,SAAS,cAAc,QAAQ,MAAM;AAC3C,QAAM,OAAO,MAAM,eAAe,MAAM;AACxC,QAAM,YAAY,yBAAyB,IAAI;AAC/C,QAAM,UAAU,wBAAwB;AACxC,QAAM,WAAW,GAAG,OAAO,iBAAiB,mBAAmB,KAAK,QAAQ,CAAC;AAC7E,QAAM,YAAY,QAAQ,WAAW,KAAK,KAAK,QAAQ,kBAAkB,KAAK,KAAK,QAAQ;AAC3F,QAAM,UAAU,UAAU,iCAAiC,sCAAsC;AACjG,QAAM,YAAY;AAAA,IAChB,SAAS,UAAU,iCAAiC,iEAAiE;AAAA,IACrH,SAAS,UAAU,iCAAiC,yBAAyB;AAAA,IAC7E,UAAU,UAAU,kCAAkC,mBAAmB,EAAE,UAAU,CAAC;AAAA,IACtF,MAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,EAAE,kBAAkB,QAAQ,iBAAiB;AAAA,IAC/C;AAAA,IACA,KAAK,UAAU,6BAA6B,YAAY;AAAA,IACxD,QAAQ,UAAU,gCAAgC,sCAAmC;AAAA,EACvF;AAEA,QAAM,UAAU;AAAA,IACd,IAAI,QAAQ;AAAA,IACZ;AAAA,IACA,OAAO,oBAAoB,EAAE,UAAU,MAAM,UAAU,CAAC;AAAA,IACxD,UAAU,KAAK;AAAA,IACf,gBAAgB,QAAQ,kBAAkB;AAAA,EAC5C,CAAC;AACD,QAAM,QAAQ,mBAAmB,SAAS,oBAAI,KAAK,CAAC;AACpD,SAAO;AACT;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/onboarding",
3
- "version": "0.7.1-develop.7150.1.c1941e0c22",
3
+ "version": "0.7.1-develop.7151.1.00d0391847",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -70,10 +70,10 @@
70
70
  }
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.7.1-develop.7150.1.c1941e0c22"
73
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.7.1-develop.7150.1.c1941e0c22",
76
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.12",
@@ -5,6 +5,10 @@ jest.mock('@open-mercato/shared/lib/email/send', () => ({
5
5
  sendEmail: jest.fn((args: unknown) => sendEmailMock(args)),
6
6
  }))
7
7
 
8
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
9
+ createRequestContainer: jest.fn().mockResolvedValue({}),
10
+ }))
11
+
8
12
  jest.mock('@open-mercato/core/modules/auth/lib/rateLimitCheck', () => ({
9
13
  checkAuthRateLimit: jest.fn((args: unknown) => checkAuthRateLimitMock(args)),
10
14
  }))
@@ -18,8 +22,11 @@ jest.mock('@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail', ()
18
22
  default: jest.fn(() => null),
19
23
  }))
20
24
 
25
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
21
26
  import { POST } from '../modules/onboarding/api/demo-feedback/route'
22
27
 
28
+ const createRequestContainerMock = createRequestContainer as jest.MockedFunction<typeof createRequestContainer>
29
+
23
30
  function makeRequest(sendCopy?: boolean) {
24
31
  return new Request('http://localhost/api/onboarding/demo-feedback', {
25
32
  method: 'POST',
@@ -61,4 +68,21 @@ describe('POST /api/onboarding/demo-feedback', () => {
61
68
  expect.objectContaining({ to: 'unverified@example.com' }),
62
69
  )
63
70
  })
71
+
72
+ it('builds the request container before sending, so the email transport is registered', async () => {
73
+ // The route discards the container; its only purpose is running the DI registration that calls
74
+ // `registerEmailTransport`. Deleting that line would otherwise leave this suite green while
75
+ // every demo-feedback send failed with `EMAIL_TRANSPORT_NOT_CONFIGURED` in production.
76
+ let containerBuiltBeforeSend = false
77
+ createRequestContainerMock.mockImplementation(async () => ({}) as never)
78
+ sendEmailMock.mockImplementation(async () => {
79
+ containerBuiltBeforeSend = createRequestContainerMock.mock.calls.length > 0
80
+ })
81
+
82
+ const response = await POST(makeRequest())
83
+
84
+ expect(response.status).toBe(200)
85
+ expect(createRequestContainerMock).toHaveBeenCalled()
86
+ expect(containerBuiltBeforeSend).toBe(true)
87
+ })
64
88
  })
@@ -47,6 +47,7 @@ function makeReadyRequest(overrides: Record<string, unknown> = {}) {
47
47
  organizationName: 'Acme Corp',
48
48
  locale: 'en',
49
49
  tenantId: 'tenant-uuid',
50
+ organizationId: 'organization-uuid',
50
51
  readyEmailSentAt: null,
51
52
  ...overrides,
52
53
  })
@@ -80,6 +81,10 @@ describe('sendWorkspaceReadyEmail', () => {
80
81
  expect(props.loginUrl).toBe('https://app.openmercato.com/login?tenant=tenant-uuid')
81
82
  expect(props.loginUrl).not.toContain('evil.com')
82
83
  expect(sendEmailMock).toHaveBeenCalledTimes(1)
84
+ expect(sendEmailMock).toHaveBeenCalledWith(expect.objectContaining({
85
+ tenantId: 'tenant-uuid',
86
+ organizationId: 'organization-uuid',
87
+ }))
83
88
  expect(markReadyEmailSentMock).toHaveBeenCalledTimes(1)
84
89
  })
85
90
 
@@ -0,0 +1,135 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures'
4
+ import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures'
5
+ import { emailHashLookupValues } from '@open-mercato/core/modules/auth/lib/emailHash'
6
+ import {
7
+ clearCapturedSystemEmails,
8
+ isChannelSeedingAvailable,
9
+ seedSystemEmailChannel,
10
+ waitForCapturedSystemEmail,
11
+ } from '@open-mercato/core/helpers/integration/communicationChannelsFixtures'
12
+
13
+ // `onboarding_requests.email` is encrypted at rest, so a plaintext `where email = $1` matches
14
+ // nothing. `email_hash` is the deterministic lookup key the service itself uses; the candidate
15
+ // list covers key rotation the same way `lookupHashCandidates` does for the application.
16
+ function emailLookup(email: string): { clause: string; values: string[] } {
17
+ return { clause: 'email_hash = any($1)', values: emailHashLookupValues(email) }
18
+ }
19
+
20
+ async function deleteOnboardingRequest(email: string): Promise<void> {
21
+ const lookup = emailLookup(email)
22
+ await withClient(async (client) => {
23
+ await client.query(`delete from onboarding_requests where ${lookup.clause}`, [lookup.values])
24
+ }).catch(() => undefined)
25
+ }
26
+
27
+ async function markOnboardingReady(email: string, tenantId: string, organizationId: string, userId: string): Promise<void> {
28
+ const lookup = emailLookup(email)
29
+ await withClient(async (client) => {
30
+ const result = await client.query(
31
+ `update onboarding_requests
32
+ set status = 'completed',
33
+ tenant_id = $2,
34
+ organization_id = $3,
35
+ user_id = $4,
36
+ preparation_completed_at = now(),
37
+ ready_email_sent_at = null
38
+ where ${lookup.clause}`,
39
+ [lookup.values, tenantId, organizationId, userId],
40
+ )
41
+ // Fail loudly here rather than let the status call 404 three assertions later.
42
+ expect(result.rowCount, `markOnboardingReady should match the onboarding request for ${email}`).toBeGreaterThan(0)
43
+ })
44
+ }
45
+
46
+ test.describe('TC-ONBOARDING-EMAIL-001: Onboarding emails use system channel', () => {
47
+ test('start, ready, and demo feedback emails dispatch through Communications Hub', async ({ request }) => {
48
+ test.slow()
49
+ const token = await getAuthToken(request, 'admin')
50
+ const scope = getTokenScope(token)
51
+ const seedingAvailable = await isChannelSeedingAvailable(request, token)
52
+ test.skip(!seedingAvailable, 'OM_ENABLE_TEST_CHANNEL_SEEDING is not enabled.')
53
+
54
+ const stamp = Date.now()
55
+ const onboardingEmail = `qa-onboarding-email-${stamp}@example.test`
56
+ const feedbackEmail = `qa-demo-feedback-${stamp}@example.test`
57
+ const adminEmail = process.env.ADMIN_EMAIL ?? 'piotr@catchthetornado.com'
58
+
59
+ await seedSystemEmailChannel(request, token)
60
+ await clearCapturedSystemEmails(request, token, { systemRecipient: onboardingEmail })
61
+ await clearCapturedSystemEmails(request, token, { systemRecipient: adminEmail })
62
+
63
+ try {
64
+ const start = await request.post('/api/onboarding/onboarding', {
65
+ headers: { 'Content-Type': 'application/json' },
66
+ data: {
67
+ email: onboardingEmail,
68
+ firstName: 'QA',
69
+ lastName: 'Onboarding',
70
+ organizationName: `QA Onboarding ${stamp}`,
71
+ password: `Valid1!Pass${stamp}`,
72
+ confirmPassword: `Valid1!Pass${stamp}`,
73
+ termsAccepted: true,
74
+ marketingConsent: false,
75
+ },
76
+ })
77
+ expect(start.status()).toBe(200)
78
+
79
+ const verificationEmail = await waitForCapturedSystemEmail(
80
+ request,
81
+ token,
82
+ (email) => email.metadata?.to === onboardingEmail && email.metadata?.subject === 'Confirm your email to finish onboarding',
83
+ { description: 'onboarding verification email', systemRecipient: onboardingEmail },
84
+ )
85
+ expect(verificationEmail.scope.tenantId).toBe('system')
86
+ expect(verificationEmail.content.bodyFormat).toBe('html')
87
+
88
+ await waitForCapturedSystemEmail(
89
+ request,
90
+ token,
91
+ (email) => String(email.metadata?.to ?? '').includes('@') && email.metadata?.subject === 'New self-service onboarding request',
92
+ { description: 'onboarding admin email', systemRecipient: adminEmail },
93
+ )
94
+
95
+ await markOnboardingReady(onboardingEmail, scope.tenantId, scope.organizationId, scope.userId)
96
+ await clearCapturedSystemEmails(request, token)
97
+
98
+ const status = await request.get(`/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(scope.tenantId)}`, {
99
+ headers: {
100
+ cookie: `om_login_tenant=${encodeURIComponent(scope.tenantId)}`,
101
+ },
102
+ })
103
+ expect(status.status()).toBe(200)
104
+ const readyEmail = await waitForCapturedSystemEmail(
105
+ request,
106
+ token,
107
+ (email) => email.metadata?.to === onboardingEmail && email.metadata?.subject === 'Your Open Mercato workspace is ready',
108
+ { description: 'onboarding ready email' },
109
+ )
110
+ expect(readyEmail.scope.tenantId).toBe(scope.tenantId)
111
+ expect(readyEmail.content.bodyFormat).toBe('html')
112
+
113
+ await clearCapturedSystemEmails(request, token)
114
+ const feedback = await request.post('/api/onboarding/demo-feedback', {
115
+ headers: { 'Content-Type': 'application/json' },
116
+ data: {
117
+ email: feedbackEmail,
118
+ message: 'Integration coverage feedback',
119
+ termsAccepted: true,
120
+ marketingConsent: false,
121
+ },
122
+ })
123
+ expect(feedback.status()).toBe(200)
124
+
125
+ await waitForCapturedSystemEmail(
126
+ request,
127
+ token,
128
+ (email) => String(email.metadata?.to ?? '').includes('@') && email.metadata?.subject === `Demo feedback from ${feedbackEmail}`,
129
+ { description: 'demo feedback admin email', systemRecipient: adminEmail },
130
+ )
131
+ } finally {
132
+ await deleteOnboardingRequest(onboardingEmail)
133
+ }
134
+ })
135
+ })
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { sendEmail } from '@open-mercato/shared/lib/email/send'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
5
  import FeedbackEmail from '@open-mercato/onboarding/modules/onboarding/emails/FeedbackEmail'
5
6
  import { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'
6
7
  import { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'
@@ -48,6 +49,12 @@ export async function POST(req: Request) {
48
49
 
49
50
  const { email, message, marketingConsent } = parsed.data
50
51
  const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'
52
+ // Load-bearing despite the discarded result: building the request container is what runs
53
+ // `communication_channels`' DI registration, and that is the only caller of
54
+ // `registerEmailTransport`. Without it `sendEmail` below finds no registered transport and
55
+ // throws `EMAIL_TRANSPORT_NOT_CONFIGURED`. This route is the one `sendEmail` call site with no
56
+ // container of its own, so the dependency has to be satisfied explicitly here.
57
+ await createRequestContainer()
51
58
 
52
59
  const marketingText = marketingConsent ? 'Marketing consent: Yes' : 'Marketing consent: No'
53
60
 
@@ -46,6 +46,8 @@ export async function sendWorkspaceReadyEmail(args: {
46
46
  to: request.email,
47
47
  subject,
48
48
  react: WorkspaceReadyEmail({ loginUrl, copy: emailCopy }),
49
+ tenantId: args.tenantId,
50
+ organizationId: request.organizationId ?? null,
49
51
  })
50
52
  await service.markReadyEmailSent(request, new Date())
51
53
  return true