@open-mercato/onboarding 0.6.5-develop.4964.1.ae0edca575 → 0.6.5-develop.5048.1.fd82f4ae17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/onboarding/api/get/onboarding/status.js +15 -5
- package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
- package/dist/modules/onboarding/api/get/onboarding/verify.js +20 -5
- package/dist/modules/onboarding/api/get/onboarding/verify.js.map +2 -2
- package/dist/modules/onboarding/api/post/onboarding.js +12 -2
- package/dist/modules/onboarding/api/post/onboarding.js.map +2 -2
- package/dist/modules/onboarding/lib/ready-email.js +3 -1
- package/dist/modules/onboarding/lib/ready-email.js.map +2 -2
- package/package.json +3 -3
- package/src/__tests__/ready-email.test.ts +114 -0
- package/src/modules/onboarding/api/get/onboarding/status.ts +14 -4
- package/src/modules/onboarding/api/get/onboarding/verify.ts +21 -6
- package/src/modules/onboarding/api/post/onboarding.ts +12 -2
- package/src/modules/onboarding/lib/ready-email.ts +3 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
|
-
import {
|
|
4
|
+
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from "@open-mercato/shared/lib/url";
|
|
5
5
|
import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/lib/service";
|
|
6
6
|
import { sendWorkspaceReadyEmail } from "@open-mercato/onboarding/modules/onboarding/lib/ready-email";
|
|
7
7
|
const metadata = {
|
|
@@ -20,7 +20,17 @@ async function GET(req) {
|
|
|
20
20
|
if (!parsed.success) {
|
|
21
21
|
return NextResponse.json({ ok: false, error: "Invalid tenant id." }, { status: 400 });
|
|
22
22
|
}
|
|
23
|
-
|
|
23
|
+
let baseUrl;
|
|
24
|
+
try {
|
|
25
|
+
baseUrl = getSecurityEmailBaseUrl(req);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
28
|
+
scope: "onboarding.status",
|
|
29
|
+
configMessage: "Onboarding status is not configured."
|
|
30
|
+
});
|
|
31
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status });
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
24
34
|
const container = await createRequestContainer();
|
|
25
35
|
const em = container.resolve("em");
|
|
26
36
|
const service = new OnboardingService(em);
|
|
@@ -35,7 +45,6 @@ async function GET(req) {
|
|
|
35
45
|
try {
|
|
36
46
|
emailSent = await sendWorkspaceReadyEmail({
|
|
37
47
|
requestId: request.id,
|
|
38
|
-
baseUrl,
|
|
39
48
|
tenantId: request.tenantId
|
|
40
49
|
});
|
|
41
50
|
} catch (error) {
|
|
@@ -78,8 +87,9 @@ const onboardingStatusDoc = {
|
|
|
78
87
|
{ status: 200, description: "Onboarding status resolved.", schema: onboardingStatusSuccessSchema }
|
|
79
88
|
],
|
|
80
89
|
errors: [
|
|
81
|
-
{ status: 400, description: "Invalid tenant id.", schema: onboardingStatusErrorSchema },
|
|
82
|
-
{ status: 404, description: "Onboarding request not found.", schema: onboardingStatusErrorSchema }
|
|
90
|
+
{ status: 400, description: "Invalid tenant id or request origin.", schema: onboardingStatusErrorSchema },
|
|
91
|
+
{ status: 404, description: "Onboarding request not found.", schema: onboardingStatusErrorSchema },
|
|
92
|
+
{ status: 500, description: "Onboarding status is not configured.", schema: onboardingStatusErrorSchema }
|
|
83
93
|
]
|
|
84
94
|
};
|
|
85
95
|
const openApi = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/onboarding/api/get/onboarding/status.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nexport const metadata = {\n path: '/onboarding/onboarding/status',\n GET: {\n requireAuth: false,\n },\n}\n\nconst onboardingStatusQuerySchema = z.object({\n tenantId: z.string().uuid(),\n})\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const tenantId = url.searchParams.get('tenantId') || url.searchParams.get('tenant') || ''\n const parsed = onboardingStatusQuerySchema.safeParse({ tenantId })\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Invalid tenant id.' }, { status: 400 })\n }\n\n let baseUrl: string\n try {\n baseUrl = getSecurityEmailBaseUrl(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'onboarding.status',\n configMessage: 'Onboarding status is not configured.',\n })\n if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })\n throw error\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.findLatestByTenantId(parsed.data.tenantId)\n if (!request) {\n return NextResponse.json({ ok: false, error: 'Onboarding request not found.' }, { status: 404 })\n }\n\n let emailSent = Boolean(request.readyEmailSentAt)\n const ready = request.status === 'completed' && Boolean(request.preparationCompletedAt)\n const loginUrl = ready && request.tenantId ? `${baseUrl}/login?tenant=${encodeURIComponent(request.tenantId)}` : null\n\n if (ready && request.tenantId && !request.readyEmailSentAt) {\n try {\n emailSent = await sendWorkspaceReadyEmail({\n requestId: request.id,\n tenantId: request.tenantId,\n })\n } catch (error) {\n console.error('[onboarding.status] ready email retry failed', {\n requestId: request.id,\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n error,\n })\n }\n }\n\n return NextResponse.json({\n ok: true,\n status: request.status,\n ready,\n emailSent,\n tenantId: request.tenantId ?? parsed.data.tenantId,\n loginUrl,\n })\n}\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingStatusSuccessSchema = z.object({\n ok: z.literal(true),\n status: z.enum(['pending', 'processing', 'completed', 'expired']),\n ready: z.boolean(),\n emailSent: z.boolean(),\n tenantId: z.string().uuid(),\n loginUrl: z.string().nullable(),\n})\n\nconst onboardingStatusErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n})\n\nconst onboardingStatusDoc: OpenApiMethodDoc = {\n summary: 'Get onboarding preparation status',\n description: 'Resolves whether a tenant workspace finished deferred onboarding preparation and can be opened.',\n tags: [onboardingTag],\n query: onboardingStatusQuerySchema,\n responses: [\n { status: 200, description: 'Onboarding status resolved.', schema: onboardingStatusSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid tenant id or request origin.', schema: onboardingStatusErrorSchema },\n { status: 404, description: 'Onboarding request not found.', schema: onboardingStatusErrorSchema },\n { status: 500, description: 'Onboarding status is not configured.', schema: onboardingStatusErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Onboarding preparation status',\n methods: {\n GET: onboardingStatusDoc,\n },\n}\n\nexport default GET\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { after, NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AppOriginConfigurationError,
|
|
6
|
+
AppOriginRejectedError,
|
|
7
|
+
getSecurityEmailBaseUrl
|
|
8
|
+
} from "@open-mercato/shared/lib/url";
|
|
5
9
|
import { onboardingVerifySchema } from "@open-mercato/onboarding/modules/onboarding/data/validators";
|
|
6
10
|
import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/lib/service";
|
|
7
11
|
import { sendWorkspaceReadyEmail } from "@open-mercato/onboarding/modules/onboarding/lib/ready-email";
|
|
@@ -21,6 +25,20 @@ const metadata = {
|
|
|
21
25
|
requireAuth: false
|
|
22
26
|
}
|
|
23
27
|
};
|
|
28
|
+
function resolveTrustedBaseUrl(req) {
|
|
29
|
+
try {
|
|
30
|
+
return getSecurityEmailBaseUrl(req);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error instanceof AppOriginRejectedError || error instanceof AppOriginConfigurationError) {
|
|
33
|
+
console.error("[onboarding.verify] rejected request origin for redirect base", {
|
|
34
|
+
requestUrl: req.url,
|
|
35
|
+
reason: error.message
|
|
36
|
+
});
|
|
37
|
+
return new URL(req.url).origin;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
24
42
|
function clearAuthCookies(response) {
|
|
25
43
|
response.cookies.set("auth_token", "", { path: "/", maxAge: 0 });
|
|
26
44
|
response.cookies.set("session_token", "", { path: "/", maxAge: 0 });
|
|
@@ -215,7 +233,6 @@ async function runDeferredProvisioning(args) {
|
|
|
215
233
|
});
|
|
216
234
|
await sendWorkspaceReadyEmail({
|
|
217
235
|
requestId: args.requestId,
|
|
218
|
-
baseUrl: args.baseUrl,
|
|
219
236
|
tenantId: args.tenantId
|
|
220
237
|
}).catch((error) => {
|
|
221
238
|
console.error("[onboarding.verify] ready email failed", {
|
|
@@ -245,7 +262,7 @@ async function runDeferredProvisioning(args) {
|
|
|
245
262
|
}
|
|
246
263
|
async function GET(req) {
|
|
247
264
|
const url = new URL(req.url);
|
|
248
|
-
const baseUrl =
|
|
265
|
+
const baseUrl = resolveTrustedBaseUrl(req);
|
|
249
266
|
const token = url.searchParams.get("token") ?? "";
|
|
250
267
|
const parsed = onboardingVerifySchema.safeParse({ token });
|
|
251
268
|
if (!parsed.success) {
|
|
@@ -269,7 +286,6 @@ async function GET(req) {
|
|
|
269
286
|
after(async () => {
|
|
270
287
|
await sendWorkspaceReadyEmail({
|
|
271
288
|
requestId: request.id,
|
|
272
|
-
baseUrl,
|
|
273
289
|
tenantId: request.tenantId
|
|
274
290
|
}).catch((error) => {
|
|
275
291
|
console.error("[onboarding.verify] retry ready email failed", {
|
|
@@ -385,7 +401,6 @@ async function GET(req) {
|
|
|
385
401
|
after(async () => {
|
|
386
402
|
await runDeferredProvisioning({
|
|
387
403
|
requestId: request.id,
|
|
388
|
-
baseUrl,
|
|
389
404
|
tenantId: resolvedTenantId,
|
|
390
405
|
organizationId: resolvedOrganizationId
|
|
391
406
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/onboarding/api/get/onboarding/verify.ts"],
|
|
4
|
-
"sourcesContent": ["import { after, NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SearchIndexer } from '@open-mercato/search'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAppBaseUrl } from '@open-mercato/shared/lib/url'\nimport { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport { setupInitialTenant } from '@open-mercato/core/modules/auth/lib/setup-app'\nimport { UserConsent } from '@open-mercato/core/modules/auth/data/entities'\nimport { computeConsentIntegrityHash } from '@open-mercato/core/modules/auth/lib/consentIntegrity'\nimport { getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'\nimport { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'\nimport { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'\nimport { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nexport const metadata = {\n path: '/onboarding/onboarding/verify',\n GET: {\n requireAuth: false,\n },\n}\n\nfunction clearAuthCookies(response: NextResponse) {\n response.cookies.set('auth_token', '', { path: '/', maxAge: 0 })\n response.cookies.set('session_token', '', { path: '/', maxAge: 0 })\n response.cookies.set('om_login_tenant', '', { path: '/', maxAge: 0 })\n}\n\nfunction redirectWithStatus(baseUrl: string, status: string) {\n const response = NextResponse.redirect(`${baseUrl}/onboarding?status=${encodeURIComponent(status)}`)\n clearAuthCookies(response)\n return response\n}\n\nfunction redirectToPreparing(baseUrl: string, tenantId: string | null) {\n const tenantParam = tenantId ? `?tenant=${encodeURIComponent(tenantId)}` : ''\n const response = NextResponse.redirect(`${baseUrl}/onboarding/preparing${tenantParam}`)\n clearAuthCookies(response)\n if (tenantId) {\n response.cookies.set('om_login_tenant', tenantId, {\n httpOnly: false,\n sameSite: 'lax',\n secure: process.env.NODE_ENV === 'production',\n path: '/',\n maxAge: 60 * 60 * 24 * 14,\n })\n }\n return response\n}\n\nfunction redirectToLogin(baseUrl: string, tenantId: string | null) {\n const tenantParam = tenantId ? `?tenant=${encodeURIComponent(tenantId)}` : ''\n const response = NextResponse.redirect(`${baseUrl}/login${tenantParam}`)\n clearAuthCookies(response)\n if (tenantId) {\n response.cookies.set('om_login_tenant', tenantId, {\n httpOnly: false,\n sameSite: 'lax',\n secure: process.env.NODE_ENV === 'production',\n path: '/',\n maxAge: 60 * 60 * 24 * 14,\n })\n }\n return response\n}\n\nconst VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000\nconst SEED_DEFAULTS_TIMEOUT_MS = 15_000\nconst SEED_EXAMPLES_TIMEOUT_MS = 15_000\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedDefaults' | 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n console.info('[onboarding.verify] module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n console.info('[onboarding.verify] module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n console.error('[onboarding.verify] module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n error,\n })\n throw error\n }\n}\n\nasync function markWorkspaceReady(args: {\n requestId: 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.preparationCompletedAt) return\n await service.markPreparationCompleted(request, new Date())\n}\n\nasync function enqueueVectorReindex(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n organizationId: string\n}) {\n let searchIndexer: SearchIndexer | null = null\n try {\n searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchIndexer = null\n }\n if (!searchIndexer) return\n\n await Promise.race([\n searchIndexer.reindexAllToVector({\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n purgeFirst: true,\n useQueue: true,\n }),\n createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),\n ])\n}\n\nasync function rebuildTenantQueryIndexes(args: {\n em: EntityManager\n tenantId: string\n organizationId: string\n}) {\n const coverageRefreshKeys = new Set<string>()\n try {\n const allEntities = getEntityIds()\n const entityIds = flattenSystemEntityIds(allEntities)\n for (const entityType of entityIds) {\n try {\n await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId })\n } catch (error) {\n console.error('[onboarding.verify] failed to purge query index scope', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n try {\n await reindexEntity(args.em, {\n entityType,\n tenantId: args.tenantId,\n force: true,\n emitVectorizeEvents: false,\n vectorService: null,\n })\n } catch (error) {\n console.error('[onboarding.verify] failed to reindex entity', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`)\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`)\n }\n } catch (error) {\n console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId: args.tenantId, error })\n }\n\n if (!coverageRefreshKeys.size) return\n\n for (const entry of coverageRefreshKeys) {\n const [entityType, tenantKey, orgKey] = entry.split('|')\n const orgScope = orgKey === '__null__' ? null : orgKey\n try {\n await refreshCoverageSnapshot(\n args.em,\n {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\n withDeleted: false,\n },\n )\n } catch (error) {\n console.error('[onboarding.verify] failed to refresh coverage snapshot', {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\n error,\n })\n }\n }\n}\n\nasync function runDeferredProvisioning(args: {\n requestId: string\n baseUrl: string\n tenantId: string\n organizationId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n try {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedExamples',\n timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,\n run: () => mod.setup!.seedExamples!({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n container,\n }),\n })\n } catch (error) {\n console.error('[onboarding.verify] deferred seedExamples failed', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n }\n }\n\n await markWorkspaceReady({\n requestId: args.requestId,\n })\n\n await sendWorkspaceReadyEmail({\n requestId: args.requestId,\n baseUrl: args.baseUrl,\n tenantId: args.tenantId,\n }).catch((error) => {\n console.error('[onboarding.verify] ready email failed', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n throw error\n })\n\n await rebuildTenantQueryIndexes({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n\n await enqueueVectorReindex({\n container,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n }).catch((error) => {\n console.warn('[onboarding.verify] vector reindex enqueue did not complete promptly', {\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n reason: error instanceof Error ? error.message : String(error),\n })\n })\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrl = getAppBaseUrl(req)\n const token = url.searchParams.get('token') ?? ''\n const parsed = onboardingVerifySchema.safeParse({ token })\n if (!parsed.success) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\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.findByToken(parsed.data.token)\n if (!request) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.expiresAt <= new Date() && request.status !== 'completed') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.status === 'completed' && request.tenantId) {\n if (!request.preparationCompletedAt) {\n return redirectToPreparing(baseUrl, request.tenantId)\n }\n if (!request.readyEmailSentAt) {\n after(async () => {\n await sendWorkspaceReadyEmail({\n requestId: request.id,\n baseUrl,\n tenantId: request.tenantId!,\n }).catch((error) => {\n console.error('[onboarding.verify] retry ready email failed', {\n requestId: request.id,\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n error,\n })\n })\n })\n }\n return redirectToLogin(baseUrl, request.tenantId)\n }\n const lockWindowMs = 15 * 60 * 1000\n const processingStartedAt = request.processingStartedAt?.getTime() ?? 0\n const processingFresh = request.status === 'processing' && processingStartedAt > Date.now() - lockWindowMs\n if (processingFresh) {\n return redirectToPreparing(baseUrl, request.tenantId ?? null)\n }\n if (request.status === 'processing' && !processingFresh) {\n await service.resetProcessing(request)\n }\n if (request.status !== 'pending') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n await service.startProcessing(request, new Date())\n if (!request.passwordHash) {\n console.error('[onboarding.verify] missing password hash for request', request.id)\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n\n let tenantId: string | null = null\n let organizationId: string | null = null\n let userId: string | null = null\n\n try {\n const setupResult = await setupInitialTenant(em, {\n orgName: request.organizationName,\n includeDerivedUsers: false,\n failIfUserExists: true,\n primaryUserRoles: ['admin'],\n includeSuperadminRole: false,\n primaryUser: {\n email: request.email,\n firstName: request.firstName,\n lastName: request.lastName,\n displayName: `${request.firstName} ${request.lastName}`.trim(),\n hashedPassword: request.passwordHash,\n confirm: true,\n },\n modules: getModules(),\n })\n\n const resolvedTenantId = String(setupResult.tenantId)\n const resolvedOrganizationId = String(setupResult.organizationId)\n tenantId = resolvedTenantId\n organizationId = resolvedOrganizationId\n\n const mainUserSnapshot = setupResult.users.find((entry) => entry.user.email === request.email)\n if (!mainUserSnapshot) throw new Error('USER_NOT_CREATED')\n const user = mainUserSnapshot.user\n const resolvedUserId = String(user.id)\n userId = resolvedUserId\n await service.updateProvisioningIds(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n\n if (request.marketingConsent) {\n const now = new Date()\n const clientIp = getClientIp(req, 1) ?? null\n const integrityHash = computeConsentIntegrityHash({\n userId: resolvedUserId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n ipAddress: clientIp,\n source: 'onboarding',\n })\n // Wrap the request-em consent write in a transaction so it commits\n // all-or-nothing. setupInitialTenant already manages its own internal\n // transactions, and the seedDefaults hooks below resolve container\n // services that fork their own EM (and may enqueue work), so neither can\n // join this transaction \u2014 only the direct request-em writes are wrapped.\n await em.transactional(async (txEm) => {\n txEm.create(UserConsent, {\n userId: resolvedUserId,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n source: 'onboarding',\n ipAddress: clientIp,\n integrityHash,\n createdAt: now,\n })\n })\n }\n\n // Call module seedDefaults + seedExamples hooks\n const modules = getModules()\n for (const mod of modules) {\n if (mod.setup?.seedDefaults) {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedDefaults',\n timeoutMs: SEED_DEFAULTS_TIMEOUT_MS,\n run: () => mod.setup!.seedDefaults!({\n em,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n container,\n }),\n })\n }\n }\n await service.markCompleted(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n // TODO: Move deferred provisioning into a durable job keyed by request id so process restarts can resume\n // seedExamples/index rebuild/email dispatch instead of leaving completed requests stuck on preparing.\n after(async () => {\n await runDeferredProvisioning({\n requestId: request.id,\n baseUrl,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n })\n })\n return redirectToPreparing(baseUrl, resolvedTenantId)\n } catch (error) {\n if (error instanceof Error && error.message === 'USER_EXISTS') {\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'already_exists')\n }\n console.error('[onboarding.verify] failed', error)\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n}\n\nexport default GET\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingVerifyQuerySchema = z.object({\n token: onboardingVerifySchema.shape.token,\n})\n\nconst onboardingVerifyDoc: OpenApiMethodDoc = {\n summary: 'Verify onboarding token',\n description: 'Validates the onboarding token, provisions the tenant, seeds demo data, and redirects the user to the login screen.',\n tags: [onboardingTag],\n query: onboardingVerifyQuerySchema,\n responses: [\n { status: 302, description: 'Redirect to onboarding UI or login' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Onboarding verification redirect',\n methods: {\n GET: onboardingVerifyDoc,\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,OAAO,oBAAoB;AACpC,SAAS,SAAS;AAGlB,SAAS,8BAA8B;AACvC,
|
|
4
|
+
"sourcesContent": ["import { after, NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SearchIndexer } from '@open-mercato/search'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n AppOriginConfigurationError,\n AppOriginRejectedError,\n getSecurityEmailBaseUrl,\n} from '@open-mercato/shared/lib/url'\nimport { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport { setupInitialTenant } from '@open-mercato/core/modules/auth/lib/setup-app'\nimport { UserConsent } from '@open-mercato/core/modules/auth/data/entities'\nimport { computeConsentIntegrityHash } from '@open-mercato/core/modules/auth/lib/consentIntegrity'\nimport { getClientIp } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'\nimport { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'\nimport { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'\nimport { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\n\nexport const metadata = {\n path: '/onboarding/onboarding/verify',\n GET: {\n requireAuth: false,\n },\n}\n\nfunction resolveTrustedBaseUrl(req: Request): string {\n try {\n return getSecurityEmailBaseUrl(req)\n } catch (error) {\n if (error instanceof AppOriginRejectedError || error instanceof AppOriginConfigurationError) {\n console.error('[onboarding.verify] rejected request origin for redirect base', {\n requestUrl: req.url,\n reason: error.message,\n })\n return new URL(req.url).origin\n }\n throw error\n }\n}\n\nfunction clearAuthCookies(response: NextResponse) {\n response.cookies.set('auth_token', '', { path: '/', maxAge: 0 })\n response.cookies.set('session_token', '', { path: '/', maxAge: 0 })\n response.cookies.set('om_login_tenant', '', { path: '/', maxAge: 0 })\n}\n\nfunction redirectWithStatus(baseUrl: string, status: string) {\n const response = NextResponse.redirect(`${baseUrl}/onboarding?status=${encodeURIComponent(status)}`)\n clearAuthCookies(response)\n return response\n}\n\nfunction redirectToPreparing(baseUrl: string, tenantId: string | null) {\n const tenantParam = tenantId ? `?tenant=${encodeURIComponent(tenantId)}` : ''\n const response = NextResponse.redirect(`${baseUrl}/onboarding/preparing${tenantParam}`)\n clearAuthCookies(response)\n if (tenantId) {\n response.cookies.set('om_login_tenant', tenantId, {\n httpOnly: false,\n sameSite: 'lax',\n secure: process.env.NODE_ENV === 'production',\n path: '/',\n maxAge: 60 * 60 * 24 * 14,\n })\n }\n return response\n}\n\nfunction redirectToLogin(baseUrl: string, tenantId: string | null) {\n const tenantParam = tenantId ? `?tenant=${encodeURIComponent(tenantId)}` : ''\n const response = NextResponse.redirect(`${baseUrl}/login${tenantParam}`)\n clearAuthCookies(response)\n if (tenantId) {\n response.cookies.set('om_login_tenant', tenantId, {\n httpOnly: false,\n sameSite: 'lax',\n secure: process.env.NODE_ENV === 'production',\n path: '/',\n maxAge: 60 * 60 * 24 * 14,\n })\n }\n return response\n}\n\nconst VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000\nconst SEED_DEFAULTS_TIMEOUT_MS = 15_000\nconst SEED_EXAMPLES_TIMEOUT_MS = 15_000\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedDefaults' | 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n console.info('[onboarding.verify] module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n console.info('[onboarding.verify] module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n console.error('[onboarding.verify] module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n error,\n })\n throw error\n }\n}\n\nasync function markWorkspaceReady(args: {\n requestId: 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.preparationCompletedAt) return\n await service.markPreparationCompleted(request, new Date())\n}\n\nasync function enqueueVectorReindex(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n organizationId: string\n}) {\n let searchIndexer: SearchIndexer | null = null\n try {\n searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchIndexer = null\n }\n if (!searchIndexer) return\n\n await Promise.race([\n searchIndexer.reindexAllToVector({\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n purgeFirst: true,\n useQueue: true,\n }),\n createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),\n ])\n}\n\nasync function rebuildTenantQueryIndexes(args: {\n em: EntityManager\n tenantId: string\n organizationId: string\n}) {\n const coverageRefreshKeys = new Set<string>()\n try {\n const allEntities = getEntityIds()\n const entityIds = flattenSystemEntityIds(allEntities)\n for (const entityType of entityIds) {\n try {\n await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId })\n } catch (error) {\n console.error('[onboarding.verify] failed to purge query index scope', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n try {\n await reindexEntity(args.em, {\n entityType,\n tenantId: args.tenantId,\n force: true,\n emitVectorizeEvents: false,\n vectorService: null,\n })\n } catch (error) {\n console.error('[onboarding.verify] failed to reindex entity', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`)\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`)\n }\n } catch (error) {\n console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId: args.tenantId, error })\n }\n\n if (!coverageRefreshKeys.size) return\n\n for (const entry of coverageRefreshKeys) {\n const [entityType, tenantKey, orgKey] = entry.split('|')\n const orgScope = orgKey === '__null__' ? null : orgKey\n try {\n await refreshCoverageSnapshot(\n args.em,\n {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\n withDeleted: false,\n },\n )\n } catch (error) {\n console.error('[onboarding.verify] failed to refresh coverage snapshot', {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\n error,\n })\n }\n }\n}\n\nasync function runDeferredProvisioning(args: {\n requestId: string\n tenantId: string\n organizationId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n try {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedExamples',\n timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,\n run: () => mod.setup!.seedExamples!({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n container,\n }),\n })\n } catch (error) {\n console.error('[onboarding.verify] deferred seedExamples failed', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n }\n }\n\n await markWorkspaceReady({\n requestId: args.requestId,\n })\n\n await sendWorkspaceReadyEmail({\n requestId: args.requestId,\n tenantId: args.tenantId,\n }).catch((error) => {\n console.error('[onboarding.verify] ready email failed', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n throw error\n })\n\n await rebuildTenantQueryIndexes({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n\n await enqueueVectorReindex({\n container,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n }).catch((error) => {\n console.warn('[onboarding.verify] vector reindex enqueue did not complete promptly', {\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n reason: error instanceof Error ? error.message : String(error),\n })\n })\n}\n\nexport async function GET(req: Request) {\n const url = new URL(req.url)\n const baseUrl = resolveTrustedBaseUrl(req)\n const token = url.searchParams.get('token') ?? ''\n const parsed = onboardingVerifySchema.safeParse({ token })\n if (!parsed.success) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\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.findByToken(parsed.data.token)\n if (!request) {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.expiresAt <= new Date() && request.status !== 'completed') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n if (request.status === 'completed' && request.tenantId) {\n if (!request.preparationCompletedAt) {\n return redirectToPreparing(baseUrl, request.tenantId)\n }\n if (!request.readyEmailSentAt) {\n after(async () => {\n await sendWorkspaceReadyEmail({\n requestId: request.id,\n tenantId: request.tenantId!,\n }).catch((error) => {\n console.error('[onboarding.verify] retry ready email failed', {\n requestId: request.id,\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n error,\n })\n })\n })\n }\n return redirectToLogin(baseUrl, request.tenantId)\n }\n const lockWindowMs = 15 * 60 * 1000\n const processingStartedAt = request.processingStartedAt?.getTime() ?? 0\n const processingFresh = request.status === 'processing' && processingStartedAt > Date.now() - lockWindowMs\n if (processingFresh) {\n return redirectToPreparing(baseUrl, request.tenantId ?? null)\n }\n if (request.status === 'processing' && !processingFresh) {\n await service.resetProcessing(request)\n }\n if (request.status !== 'pending') {\n return redirectWithStatus(baseUrl, 'invalid')\n }\n await service.startProcessing(request, new Date())\n if (!request.passwordHash) {\n console.error('[onboarding.verify] missing password hash for request', request.id)\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n\n let tenantId: string | null = null\n let organizationId: string | null = null\n let userId: string | null = null\n\n try {\n const setupResult = await setupInitialTenant(em, {\n orgName: request.organizationName,\n includeDerivedUsers: false,\n failIfUserExists: true,\n primaryUserRoles: ['admin'],\n includeSuperadminRole: false,\n primaryUser: {\n email: request.email,\n firstName: request.firstName,\n lastName: request.lastName,\n displayName: `${request.firstName} ${request.lastName}`.trim(),\n hashedPassword: request.passwordHash,\n confirm: true,\n },\n modules: getModules(),\n })\n\n const resolvedTenantId = String(setupResult.tenantId)\n const resolvedOrganizationId = String(setupResult.organizationId)\n tenantId = resolvedTenantId\n organizationId = resolvedOrganizationId\n\n const mainUserSnapshot = setupResult.users.find((entry) => entry.user.email === request.email)\n if (!mainUserSnapshot) throw new Error('USER_NOT_CREATED')\n const user = mainUserSnapshot.user\n const resolvedUserId = String(user.id)\n userId = resolvedUserId\n await service.updateProvisioningIds(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n\n if (request.marketingConsent) {\n const now = new Date()\n const clientIp = getClientIp(req, 1) ?? null\n const integrityHash = computeConsentIntegrityHash({\n userId: resolvedUserId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n ipAddress: clientIp,\n source: 'onboarding',\n })\n // Wrap the request-em consent write in a transaction so it commits\n // all-or-nothing. setupInitialTenant already manages its own internal\n // transactions, and the seedDefaults hooks below resolve container\n // services that fork their own EM (and may enqueue work), so neither can\n // join this transaction \u2014 only the direct request-em writes are wrapped.\n await em.transactional(async (txEm) => {\n txEm.create(UserConsent, {\n userId: resolvedUserId,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n consentType: 'marketing_email',\n isGranted: true,\n grantedAt: now,\n source: 'onboarding',\n ipAddress: clientIp,\n integrityHash,\n createdAt: now,\n })\n })\n }\n\n // Call module seedDefaults + seedExamples hooks\n const modules = getModules()\n for (const mod of modules) {\n if (mod.setup?.seedDefaults) {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedDefaults',\n timeoutMs: SEED_DEFAULTS_TIMEOUT_MS,\n run: () => mod.setup!.seedDefaults!({\n em,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n container,\n }),\n })\n }\n }\n await service.markCompleted(request, {\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n userId: resolvedUserId,\n })\n // TODO: Move deferred provisioning into a durable job keyed by request id so process restarts can resume\n // seedExamples/index rebuild/email dispatch instead of leaving completed requests stuck on preparing.\n after(async () => {\n await runDeferredProvisioning({\n requestId: request.id,\n tenantId: resolvedTenantId,\n organizationId: resolvedOrganizationId,\n })\n })\n return redirectToPreparing(baseUrl, resolvedTenantId)\n } catch (error) {\n if (error instanceof Error && error.message === 'USER_EXISTS') {\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'already_exists')\n }\n console.error('[onboarding.verify] failed', error)\n await service.resetProcessing(request)\n return redirectWithStatus(baseUrl, 'error')\n }\n}\n\nexport default GET\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingVerifyQuerySchema = z.object({\n token: onboardingVerifySchema.shape.token,\n})\n\nconst onboardingVerifyDoc: OpenApiMethodDoc = {\n summary: 'Verify onboarding token',\n description: 'Validates the onboarding token, provisions the tenant, seeds demo data, and redirects the user to the login screen.',\n tags: [onboardingTag],\n query: onboardingVerifyQuerySchema,\n responses: [\n { status: 302, description: 'Redirect to onboarding UI or login' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Onboarding verification redirect',\n methods: {\n GET: onboardingVerifyDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
|
-
import {
|
|
4
|
+
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from "@open-mercato/shared/lib/url";
|
|
5
5
|
import { loadDictionary } from "@open-mercato/shared/lib/i18n/server";
|
|
6
6
|
import { defaultLocale, locales } from "@open-mercato/shared/lib/i18n/config";
|
|
7
7
|
import { createFallbackTranslator } from "@open-mercato/shared/lib/i18n/translate";
|
|
@@ -110,7 +110,17 @@ async function POST(req) {
|
|
|
110
110
|
}
|
|
111
111
|
throw err;
|
|
112
112
|
}
|
|
113
|
-
|
|
113
|
+
let baseUrl;
|
|
114
|
+
try {
|
|
115
|
+
baseUrl = getSecurityEmailBaseUrl(req);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
118
|
+
scope: "onboarding.start",
|
|
119
|
+
configMessage: "Self-service onboarding is not configured."
|
|
120
|
+
});
|
|
121
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
114
124
|
const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`;
|
|
115
125
|
const firstName = request.firstName || parsed.data.firstName;
|
|
116
126
|
const hasMarketingConsent = request.marketingConsent === true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/onboarding/api/post/onboarding.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAppBaseUrl } from '@open-mercato/shared/lib/url'\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 { onboardingStartSchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport VerificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail'\nimport AdminNotificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\n\nexport const metadata = {\n path: '/onboarding/onboarding',\n POST: {\n requireAuth: false,\n },\n}\n\nexport async function POST(req: Request) {\n if (parseBooleanToken(process.env.SELF_SERVICE_ONBOARDING_ENABLED ?? '') !== true) {\n return NextResponse.json({ ok: false, error: 'Self-service onboarding is disabled.' }, { status: 404 })\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 rawLocale =\n payload && typeof payload === 'object' && 'locale' in payload && typeof (payload as any).locale === 'string'\n ? (payload as any).locale as string\n : null\n const locale: Locale = rawLocale && locales.includes(rawLocale as Locale)\n ? (rawLocale as Locale)\n : defaultLocale\n const dict = await loadDictionary(locale)\n const translate = createFallbackTranslator(dict)\n const passwordRequirements = formatPasswordRequirements(\n getPasswordPolicy(),\n translate,\n 'onboarding.password.requirements',\n )\n\n const parsed = onboardingStartSchema.safeParse(payload)\n if (!parsed.success) {\n const fieldErrors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (!path) continue\n switch (path) {\n case 'email':\n fieldErrors.email = translate('onboarding.errors.emailInvalid', 'Enter a valid work email.')\n break\n case 'firstName':\n fieldErrors.firstName = translate('onboarding.errors.firstNameRequired', 'First name is required.')\n break\n case 'lastName':\n fieldErrors.lastName = translate('onboarding.errors.lastNameRequired', 'Last name is required.')\n break\n case 'organizationName':\n fieldErrors.organizationName = translate('onboarding.errors.organizationNameRequired', 'Organization name is required.')\n break\n case 'password':\n fieldErrors.password = translate(\n 'onboarding.errors.passwordRequired',\n 'Password must meet the requirements: {requirements}.',\n { requirements: passwordRequirements },\n )\n break\n case 'confirmPassword':\n fieldErrors.confirmPassword = translate('onboarding.errors.passwordMismatch', 'Passwords must match.')\n break\n case 'termsAccepted':\n fieldErrors.termsAccepted = translate('onboarding.form.termsRequired', 'Please accept the terms to continue.')\n break\n default:\n break\n }\n }\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Please check the form and try again.'),\n fieldErrors,\n }, { status: 400 })\n }\n\n try {\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n\n const existingUser = await em.findOne(User, { email: parsed.data.email })\n if (existingUser) {\n const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n\n const service = new OnboardingService(em)\n let request, token\n try {\n const result = await service.createOrUpdateRequest(parsed.data)\n request = result.request\n token = result.token\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('PENDING_REQUEST:')) {\n const minutes = Number(err.message.split(':')[1] || '10')\n const message = translate('onboarding.errors.pendingRequest', 'We already have a pending verification. Please try again in about {minutes} minutes or contact the administrator.', { minutes })\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n throw err\n }\n\n const baseUrl = getAppBaseUrl(req)\n const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`\n\n const firstName = request.firstName || parsed.data.firstName\n const hasMarketingConsent = request.marketingConsent === true\n const marketingConsentText = hasMarketingConsent\n ? translate('onboarding.email.marketingConsentYes', 'Marketing consent: Yes')\n : translate('onboarding.email.marketingConsentNo', 'Marketing consent: No')\n const subject = translate('onboarding.email.subject', 'Confirm your email to finish onboarding')\n const emailCopy = {\n preview: translate('onboarding.email.preview', 'Confirm your email to activate your Open Mercato workspace'),\n heading: translate('onboarding.email.heading', 'Welcome to Open Mercato'),\n greeting: translate('onboarding.email.greeting', 'Hi {firstName},', { firstName }),\n body: translate(\n 'onboarding.email.body',\n 'We just need to confirm your email address to finish setting up the organization {organizationName}.',\n { organizationName: request.organizationName },\n ),\n cta: translate('onboarding.email.cta', 'Confirm email & activate workspace'),\n expiry: translate(\n 'onboarding.email.expiry',\n \"The link will expire in 24 hours. If you didn't request this, you can safely ignore this message.\",\n ),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.footer', 'Open Mercato \u00B7 Onboarding service'),\n }\n const emailReact = VerificationEmail({ verifyUrl, copy: emailCopy })\n try {\n await sendEmail({ to: request.email, subject, react: emailReact })\n } catch (err) {\n request.lastEmailSentAt = null\n await em.flush()\n console.error('[onboarding.start] verification email failed', err)\n return NextResponse.json({\n ok: false,\n error: translate(\n 'onboarding.errors.emailSendFailed',\n 'We could not send the verification email. Please try again or contact support.',\n ),\n }, { status: 502 })\n }\n\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n const adminSubject = translate('onboarding.email.adminSubject', 'New self-service onboarding request')\n const adminCopy = {\n preview: translate('onboarding.email.adminPreview', 'New onboarding request submitted'),\n heading: translate('onboarding.email.adminHeading', 'New onboarding request'),\n body: translate('onboarding.email.adminBody', '{firstName} {lastName} ({email}) submitted an onboarding request for {organizationName}.', {\n firstName: request.firstName,\n lastName: request.lastName,\n email: request.email,\n organizationName: request.organizationName,\n }),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.adminFooter', 'You can review the tenant after verification is complete.'),\n }\n try {\n await sendEmail({\n to: adminEmail,\n subject: adminSubject,\n react: AdminNotificationEmail({ copy: adminCopy }),\n })\n } catch (err) {\n console.error('[onboarding.start] admin email failed', err)\n }\n\n return NextResponse.json({ ok: true, email: request.email })\n } catch (error) {\n console.error('[onboarding.start] failed', error)\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Something went wrong. Please try again later.'),\n }, { status: 500 })\n }\n}\n\nexport default POST\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingSuccessSchema = z.object({\n ok: z.literal(true),\n email: z.string().email(),\n})\n\nconst onboardingErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n fieldErrors: z.record(z.string(), z.string()).optional(),\n})\n\nconst onboardingPostDoc: OpenApiMethodDoc = {\n summary: 'Submit onboarding request',\n description: 'Accepts a self-service onboarding form submission and triggers email verification.',\n tags: [onboardingTag],\n requestBody: {\n contentType: 'application/json',\n schema: onboardingStartSchema,\n description: 'Onboarding form payload with contact and organization information.',\n },\n responses: [\n { status: 200, description: 'Onboarding request accepted.', schema: onboardingSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: onboardingErrorSchema },\n { status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },\n { status: 409, description: 'Existing account or pending request', schema: onboardingErrorSchema },\n { status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Self-service onboarding submission',\n methods: {\n POST: onboardingPostDoc,\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\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 { onboardingStartSchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport VerificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/VerificationEmail'\nimport AdminNotificationEmail from '@open-mercato/onboarding/modules/onboarding/emails/AdminNotificationEmail'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { formatPasswordRequirements, getPasswordPolicy } from '@open-mercato/shared/lib/auth/passwordPolicy'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\n\nexport const metadata = {\n path: '/onboarding/onboarding',\n POST: {\n requireAuth: false,\n },\n}\n\nexport async function POST(req: Request) {\n if (parseBooleanToken(process.env.SELF_SERVICE_ONBOARDING_ENABLED ?? '') !== true) {\n return NextResponse.json({ ok: false, error: 'Self-service onboarding is disabled.' }, { status: 404 })\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 rawLocale =\n payload && typeof payload === 'object' && 'locale' in payload && typeof (payload as any).locale === 'string'\n ? (payload as any).locale as string\n : null\n const locale: Locale = rawLocale && locales.includes(rawLocale as Locale)\n ? (rawLocale as Locale)\n : defaultLocale\n const dict = await loadDictionary(locale)\n const translate = createFallbackTranslator(dict)\n const passwordRequirements = formatPasswordRequirements(\n getPasswordPolicy(),\n translate,\n 'onboarding.password.requirements',\n )\n\n const parsed = onboardingStartSchema.safeParse(payload)\n if (!parsed.success) {\n const fieldErrors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (!path) continue\n switch (path) {\n case 'email':\n fieldErrors.email = translate('onboarding.errors.emailInvalid', 'Enter a valid work email.')\n break\n case 'firstName':\n fieldErrors.firstName = translate('onboarding.errors.firstNameRequired', 'First name is required.')\n break\n case 'lastName':\n fieldErrors.lastName = translate('onboarding.errors.lastNameRequired', 'Last name is required.')\n break\n case 'organizationName':\n fieldErrors.organizationName = translate('onboarding.errors.organizationNameRequired', 'Organization name is required.')\n break\n case 'password':\n fieldErrors.password = translate(\n 'onboarding.errors.passwordRequired',\n 'Password must meet the requirements: {requirements}.',\n { requirements: passwordRequirements },\n )\n break\n case 'confirmPassword':\n fieldErrors.confirmPassword = translate('onboarding.errors.passwordMismatch', 'Passwords must match.')\n break\n case 'termsAccepted':\n fieldErrors.termsAccepted = translate('onboarding.form.termsRequired', 'Please accept the terms to continue.')\n break\n default:\n break\n }\n }\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Please check the form and try again.'),\n fieldErrors,\n }, { status: 400 })\n }\n\n try {\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager)\n\n const existingUser = await em.findOne(User, { email: parsed.data.email })\n if (existingUser) {\n const message = translate('onboarding.errors.emailExists', 'We already have an account with this email. Try signing in or resetting your password.')\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n\n const service = new OnboardingService(em)\n let request, token\n try {\n const result = await service.createOrUpdateRequest(parsed.data)\n request = result.request\n token = result.token\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('PENDING_REQUEST:')) {\n const minutes = Number(err.message.split(':')[1] || '10')\n const message = translate('onboarding.errors.pendingRequest', 'We already have a pending verification. Please try again in about {minutes} minutes or contact the administrator.', { minutes })\n return NextResponse.json({\n ok: false,\n error: message,\n fieldErrors: { email: message },\n }, { status: 409 })\n }\n throw err\n }\n\n let baseUrl: string\n try {\n baseUrl = getSecurityEmailBaseUrl(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'onboarding.start',\n configMessage: 'Self-service onboarding is not configured.',\n })\n if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })\n throw error\n }\n const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`\n\n const firstName = request.firstName || parsed.data.firstName\n const hasMarketingConsent = request.marketingConsent === true\n const marketingConsentText = hasMarketingConsent\n ? translate('onboarding.email.marketingConsentYes', 'Marketing consent: Yes')\n : translate('onboarding.email.marketingConsentNo', 'Marketing consent: No')\n const subject = translate('onboarding.email.subject', 'Confirm your email to finish onboarding')\n const emailCopy = {\n preview: translate('onboarding.email.preview', 'Confirm your email to activate your Open Mercato workspace'),\n heading: translate('onboarding.email.heading', 'Welcome to Open Mercato'),\n greeting: translate('onboarding.email.greeting', 'Hi {firstName},', { firstName }),\n body: translate(\n 'onboarding.email.body',\n 'We just need to confirm your email address to finish setting up the organization {organizationName}.',\n { organizationName: request.organizationName },\n ),\n cta: translate('onboarding.email.cta', 'Confirm email & activate workspace'),\n expiry: translate(\n 'onboarding.email.expiry',\n \"The link will expire in 24 hours. If you didn't request this, you can safely ignore this message.\",\n ),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.footer', 'Open Mercato \u00B7 Onboarding service'),\n }\n const emailReact = VerificationEmail({ verifyUrl, copy: emailCopy })\n try {\n await sendEmail({ to: request.email, subject, react: emailReact })\n } catch (err) {\n request.lastEmailSentAt = null\n await em.flush()\n console.error('[onboarding.start] verification email failed', err)\n return NextResponse.json({\n ok: false,\n error: translate(\n 'onboarding.errors.emailSendFailed',\n 'We could not send the verification email. Please try again or contact support.',\n ),\n }, { status: 502 })\n }\n\n const adminEmail = process.env.ADMIN_EMAIL || 'piotr@catchthetornado.com'\n const adminSubject = translate('onboarding.email.adminSubject', 'New self-service onboarding request')\n const adminCopy = {\n preview: translate('onboarding.email.adminPreview', 'New onboarding request submitted'),\n heading: translate('onboarding.email.adminHeading', 'New onboarding request'),\n body: translate('onboarding.email.adminBody', '{firstName} {lastName} ({email}) submitted an onboarding request for {organizationName}.', {\n firstName: request.firstName,\n lastName: request.lastName,\n email: request.email,\n organizationName: request.organizationName,\n }),\n marketingConsent: marketingConsentText,\n footer: translate('onboarding.email.adminFooter', 'You can review the tenant after verification is complete.'),\n }\n try {\n await sendEmail({\n to: adminEmail,\n subject: adminSubject,\n react: AdminNotificationEmail({ copy: adminCopy }),\n })\n } catch (err) {\n console.error('[onboarding.start] admin email failed', err)\n }\n\n return NextResponse.json({ ok: true, email: request.email })\n } catch (error) {\n console.error('[onboarding.start] failed', error)\n return NextResponse.json({\n ok: false,\n error: translate('onboarding.form.genericError', 'Something went wrong. Please try again later.'),\n }, { status: 500 })\n }\n}\n\nexport default POST\n\nconst onboardingTag = 'Onboarding'\n\nconst onboardingSuccessSchema = z.object({\n ok: z.literal(true),\n email: z.string().email(),\n})\n\nconst onboardingErrorSchema = z.object({\n ok: z.literal(false),\n error: z.string(),\n fieldErrors: z.record(z.string(), z.string()).optional(),\n})\n\nconst onboardingPostDoc: OpenApiMethodDoc = {\n summary: 'Submit onboarding request',\n description: 'Accepts a self-service onboarding form submission and triggers email verification.',\n tags: [onboardingTag],\n requestBody: {\n contentType: 'application/json',\n schema: onboardingStartSchema,\n description: 'Onboarding form payload with contact and organization information.',\n },\n responses: [\n { status: 200, description: 'Onboarding request accepted.', schema: onboardingSuccessSchema },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: onboardingErrorSchema },\n { status: 404, description: 'Self-service onboarding disabled', schema: onboardingErrorSchema },\n { status: 409, description: 'Existing account or pending request', schema: onboardingErrorSchema },\n { status: 500, description: 'Unexpected server error', schema: onboardingErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: onboardingTag,\n summary: 'Self-service onboarding submission',\n methods: {\n POST: onboardingPostDoc,\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,6 +3,7 @@ import { loadDictionary } from "@open-mercato/shared/lib/i18n/server";
|
|
|
3
3
|
import { defaultLocale, locales } from "@open-mercato/shared/lib/i18n/config";
|
|
4
4
|
import { createFallbackTranslator } from "@open-mercato/shared/lib/i18n/translate";
|
|
5
5
|
import { sendEmail } from "@open-mercato/shared/lib/email/send";
|
|
6
|
+
import { getSecurityEmailBaseUrl } from "@open-mercato/shared/lib/url";
|
|
6
7
|
import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/lib/service";
|
|
7
8
|
import WorkspaceReadyEmail from "@open-mercato/onboarding/modules/onboarding/emails/WorkspaceReadyEmail";
|
|
8
9
|
function resolveLocale(rawLocale) {
|
|
@@ -18,7 +19,8 @@ async function sendWorkspaceReadyEmail(args) {
|
|
|
18
19
|
const locale = resolveLocale(request.locale);
|
|
19
20
|
const dict = await loadDictionary(locale);
|
|
20
21
|
const translate = createFallbackTranslator(dict);
|
|
21
|
-
const
|
|
22
|
+
const baseUrl = getSecurityEmailBaseUrl();
|
|
23
|
+
const loginUrl = `${baseUrl}/login?tenant=${encodeURIComponent(args.tenantId)}`;
|
|
22
24
|
const firstName = request.firstName?.trim() || request.organizationName?.trim() || request.email;
|
|
23
25
|
const subject = translate("onboarding.readyEmail.subject", "Your Open Mercato workspace is ready");
|
|
24
26
|
const emailCopy = {
|
|
@@ -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 { 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
|
|
5
|
-
"mappings": "AACA,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAC/B,SAAS,eAAe,eAA4B;AACpD,SAAS,gCAAgC;AACzC,SAAS,iBAAiB;AAC1B,SAAS,yBAAyB;AAClC,OAAO,yBAAyB;AAEhC,SAAS,cAAc,WAA8C;AACnE,MAAI,aAAa,QAAQ,SAAS,SAAmB,EAAG,QAAO;AAC/D,SAAO;AACT;AAEA,eAAsB,wBAAwB,
|
|
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;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/onboarding",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5048.1.fd82f4ae17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -69,10 +69,10 @@
|
|
|
69
69
|
}
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
72
|
+
"@open-mercato/shared": "0.6.5-develop.5048.1.fd82f4ae17"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
75
|
+
"@open-mercato/shared": "0.6.5-develop.5048.1.fd82f4ae17",
|
|
76
76
|
"@types/jest": "^30.0.0",
|
|
77
77
|
"jest": "^30.4.2",
|
|
78
78
|
"ts-jest": "^29.4.11"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { OnboardingRequest } from '../modules/onboarding/data/entities'
|
|
2
|
+
|
|
3
|
+
const sendEmailMock = jest.fn().mockResolvedValue(undefined)
|
|
4
|
+
const findByIdMock = jest.fn()
|
|
5
|
+
const markReadyEmailSentMock = jest.fn().mockResolvedValue(undefined)
|
|
6
|
+
const workspaceReadyEmailMock = jest.fn().mockReturnValue(null)
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
9
|
+
createRequestContainer: jest.fn().mockResolvedValue({
|
|
10
|
+
resolve: () => ({}),
|
|
11
|
+
}),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('@open-mercato/shared/lib/email/send', () => ({
|
|
15
|
+
sendEmail: (...args: unknown[]) => sendEmailMock(...args),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
19
|
+
loadDictionary: jest.fn().mockResolvedValue({}),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
jest.mock('@open-mercato/shared/lib/i18n/translate', () => ({
|
|
23
|
+
createFallbackTranslator: () => (_key: string, fallback: string) => fallback,
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
|
|
27
|
+
OnboardingService: jest.fn().mockImplementation(() => ({
|
|
28
|
+
findById: (...args: unknown[]) => findByIdMock(...args),
|
|
29
|
+
markReadyEmailSent: (...args: unknown[]) => markReadyEmailSentMock(...args),
|
|
30
|
+
})),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
jest.mock('@open-mercato/onboarding/modules/onboarding/emails/WorkspaceReadyEmail', () => ({
|
|
34
|
+
__esModule: true,
|
|
35
|
+
default: (props: { loginUrl: string }) => workspaceReadyEmailMock(props),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
import { sendWorkspaceReadyEmail } from '../modules/onboarding/lib/ready-email'
|
|
39
|
+
|
|
40
|
+
function makeReadyRequest(overrides: Record<string, unknown> = {}) {
|
|
41
|
+
return Object.assign(new OnboardingRequest(), {
|
|
42
|
+
id: 'req-1',
|
|
43
|
+
email: 'owner@example.com',
|
|
44
|
+
status: 'completed',
|
|
45
|
+
firstName: 'Jane',
|
|
46
|
+
lastName: 'Doe',
|
|
47
|
+
organizationName: 'Acme Corp',
|
|
48
|
+
locale: 'en',
|
|
49
|
+
tenantId: 'tenant-uuid',
|
|
50
|
+
readyEmailSentAt: null,
|
|
51
|
+
...overrides,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('sendWorkspaceReadyEmail', () => {
|
|
56
|
+
const originalEnv = { ...process.env }
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
jest.clearAllMocks()
|
|
60
|
+
process.env = { ...originalEnv }
|
|
61
|
+
delete process.env.NEXT_PUBLIC_APP_URL
|
|
62
|
+
delete process.env.APP_URL
|
|
63
|
+
delete process.env.APP_ALLOWED_ORIGINS
|
|
64
|
+
process.env.NODE_ENV = 'test'
|
|
65
|
+
findByIdMock.mockResolvedValue(makeReadyRequest())
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
process.env = originalEnv
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('builds the login link from the configured APP_URL, not a request header', async () => {
|
|
73
|
+
process.env.APP_URL = 'https://app.openmercato.com'
|
|
74
|
+
|
|
75
|
+
const sent = await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })
|
|
76
|
+
|
|
77
|
+
expect(sent).toBe(true)
|
|
78
|
+
expect(workspaceReadyEmailMock).toHaveBeenCalledTimes(1)
|
|
79
|
+
const props = workspaceReadyEmailMock.mock.calls[0][0]
|
|
80
|
+
expect(props.loginUrl).toBe('https://app.openmercato.com/login?tenant=tenant-uuid')
|
|
81
|
+
expect(props.loginUrl).not.toContain('evil.com')
|
|
82
|
+
expect(sendEmailMock).toHaveBeenCalledTimes(1)
|
|
83
|
+
expect(markReadyEmailSentMock).toHaveBeenCalledTimes(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('falls back to the dev origin outside production when APP_URL is unset', async () => {
|
|
87
|
+
const sent = await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })
|
|
88
|
+
|
|
89
|
+
expect(sent).toBe(true)
|
|
90
|
+
const props = workspaceReadyEmailMock.mock.calls[0][0]
|
|
91
|
+
expect(props.loginUrl).toBe('http://localhost:3000/login?tenant=tenant-uuid')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('throws in production when APP_URL is not configured instead of trusting a host', async () => {
|
|
95
|
+
process.env.NODE_ENV = 'production'
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' }),
|
|
99
|
+
).rejects.toThrow(/APP_URL/)
|
|
100
|
+
|
|
101
|
+
expect(sendEmailMock).not.toHaveBeenCalled()
|
|
102
|
+
expect(markReadyEmailSentMock).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('skips when the request is missing or already notified', async () => {
|
|
106
|
+
findByIdMock.mockResolvedValueOnce(null)
|
|
107
|
+
expect(await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })).toBe(false)
|
|
108
|
+
|
|
109
|
+
findByIdMock.mockResolvedValueOnce(makeReadyRequest({ readyEmailSentAt: new Date() }))
|
|
110
|
+
expect(await sendWorkspaceReadyEmail({ requestId: 'req-1', tenantId: 'tenant-uuid' })).toBe(false)
|
|
111
|
+
|
|
112
|
+
expect(sendEmailMock).not.toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
-
import {
|
|
5
|
+
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
6
6
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
7
7
|
import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
|
|
8
8
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -26,7 +26,17 @@ export async function GET(req: Request) {
|
|
|
26
26
|
return NextResponse.json({ ok: false, error: 'Invalid tenant id.' }, { status: 400 })
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
let baseUrl: string
|
|
30
|
+
try {
|
|
31
|
+
baseUrl = getSecurityEmailBaseUrl(req)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
34
|
+
scope: 'onboarding.status',
|
|
35
|
+
configMessage: 'Onboarding status is not configured.',
|
|
36
|
+
})
|
|
37
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
30
40
|
const container = await createRequestContainer()
|
|
31
41
|
const em = container.resolve('em') as EntityManager
|
|
32
42
|
const service = new OnboardingService(em)
|
|
@@ -43,7 +53,6 @@ export async function GET(req: Request) {
|
|
|
43
53
|
try {
|
|
44
54
|
emailSent = await sendWorkspaceReadyEmail({
|
|
45
55
|
requestId: request.id,
|
|
46
|
-
baseUrl,
|
|
47
56
|
tenantId: request.tenantId,
|
|
48
57
|
})
|
|
49
58
|
} catch (error) {
|
|
@@ -91,8 +100,9 @@ const onboardingStatusDoc: OpenApiMethodDoc = {
|
|
|
91
100
|
{ status: 200, description: 'Onboarding status resolved.', schema: onboardingStatusSuccessSchema },
|
|
92
101
|
],
|
|
93
102
|
errors: [
|
|
94
|
-
{ status: 400, description: 'Invalid tenant id.', schema: onboardingStatusErrorSchema },
|
|
103
|
+
{ status: 400, description: 'Invalid tenant id or request origin.', schema: onboardingStatusErrorSchema },
|
|
95
104
|
{ status: 404, description: 'Onboarding request not found.', schema: onboardingStatusErrorSchema },
|
|
105
|
+
{ status: 500, description: 'Onboarding status is not configured.', schema: onboardingStatusErrorSchema },
|
|
96
106
|
],
|
|
97
107
|
}
|
|
98
108
|
|
|
@@ -3,7 +3,11 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import type { SearchIndexer } from '@open-mercato/search'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AppOriginConfigurationError,
|
|
8
|
+
AppOriginRejectedError,
|
|
9
|
+
getSecurityEmailBaseUrl,
|
|
10
|
+
} from '@open-mercato/shared/lib/url'
|
|
7
11
|
import { onboardingVerifySchema } from '@open-mercato/onboarding/modules/onboarding/data/validators'
|
|
8
12
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
9
13
|
import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
|
|
@@ -26,6 +30,21 @@ export const metadata = {
|
|
|
26
30
|
},
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
function resolveTrustedBaseUrl(req: Request): string {
|
|
34
|
+
try {
|
|
35
|
+
return getSecurityEmailBaseUrl(req)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof AppOriginRejectedError || error instanceof AppOriginConfigurationError) {
|
|
38
|
+
console.error('[onboarding.verify] rejected request origin for redirect base', {
|
|
39
|
+
requestUrl: req.url,
|
|
40
|
+
reason: error.message,
|
|
41
|
+
})
|
|
42
|
+
return new URL(req.url).origin
|
|
43
|
+
}
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
function clearAuthCookies(response: NextResponse) {
|
|
30
49
|
response.cookies.set('auth_token', '', { path: '/', maxAge: 0 })
|
|
31
50
|
response.cookies.set('session_token', '', { path: '/', maxAge: 0 })
|
|
@@ -218,7 +237,6 @@ async function rebuildTenantQueryIndexes(args: {
|
|
|
218
237
|
|
|
219
238
|
async function runDeferredProvisioning(args: {
|
|
220
239
|
requestId: string
|
|
221
|
-
baseUrl: string
|
|
222
240
|
tenantId: string
|
|
223
241
|
organizationId: string
|
|
224
242
|
}) {
|
|
@@ -256,7 +274,6 @@ async function runDeferredProvisioning(args: {
|
|
|
256
274
|
|
|
257
275
|
await sendWorkspaceReadyEmail({
|
|
258
276
|
requestId: args.requestId,
|
|
259
|
-
baseUrl: args.baseUrl,
|
|
260
277
|
tenantId: args.tenantId,
|
|
261
278
|
}).catch((error) => {
|
|
262
279
|
console.error('[onboarding.verify] ready email failed', {
|
|
@@ -289,7 +306,7 @@ async function runDeferredProvisioning(args: {
|
|
|
289
306
|
|
|
290
307
|
export async function GET(req: Request) {
|
|
291
308
|
const url = new URL(req.url)
|
|
292
|
-
const baseUrl =
|
|
309
|
+
const baseUrl = resolveTrustedBaseUrl(req)
|
|
293
310
|
const token = url.searchParams.get('token') ?? ''
|
|
294
311
|
const parsed = onboardingVerifySchema.safeParse({ token })
|
|
295
312
|
if (!parsed.success) {
|
|
@@ -314,7 +331,6 @@ export async function GET(req: Request) {
|
|
|
314
331
|
after(async () => {
|
|
315
332
|
await sendWorkspaceReadyEmail({
|
|
316
333
|
requestId: request.id,
|
|
317
|
-
baseUrl,
|
|
318
334
|
tenantId: request.tenantId!,
|
|
319
335
|
}).catch((error) => {
|
|
320
336
|
console.error('[onboarding.verify] retry ready email failed', {
|
|
@@ -444,7 +460,6 @@ export async function GET(req: Request) {
|
|
|
444
460
|
after(async () => {
|
|
445
461
|
await runDeferredProvisioning({
|
|
446
462
|
requestId: request.id,
|
|
447
|
-
baseUrl,
|
|
448
463
|
tenantId: resolvedTenantId,
|
|
449
464
|
organizationId: resolvedOrganizationId,
|
|
450
465
|
})
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
-
import {
|
|
5
|
+
import { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'
|
|
6
6
|
import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
|
|
7
7
|
import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
8
8
|
import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'
|
|
@@ -125,7 +125,17 @@ export async function POST(req: Request) {
|
|
|
125
125
|
throw err
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
let baseUrl: string
|
|
129
|
+
try {
|
|
130
|
+
baseUrl = getSecurityEmailBaseUrl(req)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const mapped = mapSecurityEmailUrlError(error, {
|
|
133
|
+
scope: 'onboarding.start',
|
|
134
|
+
configMessage: 'Self-service onboarding is not configured.',
|
|
135
|
+
})
|
|
136
|
+
if (mapped) return NextResponse.json({ ok: false, error: mapped.body.error }, { status: mapped.status })
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
129
139
|
const verifyUrl = `${baseUrl}/api/onboarding/onboarding/verify?token=${token}`
|
|
130
140
|
|
|
131
141
|
const firstName = request.firstName || parsed.data.firstName
|
|
@@ -4,6 +4,7 @@ import { loadDictionary } from '@open-mercato/shared/lib/i18n/server'
|
|
|
4
4
|
import { defaultLocale, locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
5
5
|
import { createFallbackTranslator } from '@open-mercato/shared/lib/i18n/translate'
|
|
6
6
|
import { sendEmail } from '@open-mercato/shared/lib/email/send'
|
|
7
|
+
import { getSecurityEmailBaseUrl } from '@open-mercato/shared/lib/url'
|
|
7
8
|
import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
|
|
8
9
|
import WorkspaceReadyEmail from '@open-mercato/onboarding/modules/onboarding/emails/WorkspaceReadyEmail'
|
|
9
10
|
|
|
@@ -14,7 +15,6 @@ function resolveLocale(rawLocale: string | null | undefined): Locale {
|
|
|
14
15
|
|
|
15
16
|
export async function sendWorkspaceReadyEmail(args: {
|
|
16
17
|
requestId: string
|
|
17
|
-
baseUrl: string
|
|
18
18
|
tenantId: string
|
|
19
19
|
}) {
|
|
20
20
|
const container = await createRequestContainer()
|
|
@@ -25,7 +25,8 @@ export async function sendWorkspaceReadyEmail(args: {
|
|
|
25
25
|
const locale = resolveLocale(request.locale)
|
|
26
26
|
const dict = await loadDictionary(locale)
|
|
27
27
|
const translate = createFallbackTranslator(dict)
|
|
28
|
-
const
|
|
28
|
+
const baseUrl = getSecurityEmailBaseUrl()
|
|
29
|
+
const loginUrl = `${baseUrl}/login?tenant=${encodeURIComponent(args.tenantId)}`
|
|
29
30
|
const firstName = request.firstName?.trim() || request.organizationName?.trim() || request.email
|
|
30
31
|
const subject = translate('onboarding.readyEmail.subject', 'Your Open Mercato workspace is ready')
|
|
31
32
|
const emailCopy = {
|