@open-mercato/core 0.8.1-develop.7448.1.94eec0a2d6 → 0.8.1-develop.7450.1.77a915add3

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,4 +1,4 @@
1
- [build:core] found 5526 entry points
1
+ [build:core] found 5527 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 248 entry points
4
4
  [build:core:generated] built successfully
@@ -4,6 +4,8 @@ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
5
  import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
6
6
  import { toAbsoluteUrl } from "@open-mercato/shared/lib/url";
7
+ import { createLogger } from "@open-mercato/shared/lib/logger";
8
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
7
9
  import { getChannelAdapter } from "../../../../../lib/adapter-registry-singleton.js";
8
10
  import { resolveOAuthClientCredentials } from "../../../../../lib/oauth-client-config.js";
9
11
  import {
@@ -15,6 +17,7 @@ import {
15
17
  normalizeOAuthReturnUrl,
16
18
  OAuthStateError
17
19
  } from "../../../../../lib/oauth-state.js";
20
+ const logger = createLogger("communication_channels").child({ component: "oauth-initiate" });
18
21
  const metadata = {
19
22
  path: "/communication_channels/oauth/[provider]/initiate",
20
23
  POST: {
@@ -34,6 +37,20 @@ const initiateBodySchema = z.object({
34
37
  function defaultRedirectUri(req, providerKey) {
35
38
  return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`);
36
39
  }
40
+ function oauthStateErrorResponse(err) {
41
+ if (err instanceof OAuthStateError) {
42
+ if (err.code === "missing_secret") {
43
+ logger.error("OAuth state secret is not configured", { err });
44
+ getTelemetryRuntime()?.reportError(err, {
45
+ module: "communication_channels",
46
+ code: "communication_channels.missing_secret"
47
+ });
48
+ return NextResponse.json({ error: "OAuth is not available", code: err.code }, { status: 500 });
49
+ }
50
+ return NextResponse.json({ error: err.message, code: err.code }, { status: 500 });
51
+ }
52
+ return null;
53
+ }
37
54
  async function POST(req, context) {
38
55
  const { provider } = await context.params;
39
56
  if (!/^[a-z0-9_-]+$/i.test(provider)) {
@@ -88,13 +105,20 @@ async function POST(req, context) {
88
105
  );
89
106
  }
90
107
  const redirectUri = defaultRedirectUri(req, provider);
91
- const stateEnvelope = createOAuthState({
92
- userId: auth.sub,
93
- tenantId: auth.tenantId,
94
- organizationId: auth.orgId ?? null,
95
- providerKey: provider,
96
- returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL)
97
- });
108
+ let stateEnvelope;
109
+ try {
110
+ stateEnvelope = createOAuthState({
111
+ userId: auth.sub,
112
+ tenantId: auth.tenantId,
113
+ organizationId: auth.orgId ?? null,
114
+ providerKey: provider,
115
+ returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL)
116
+ });
117
+ } catch (err) {
118
+ const response2 = oauthStateErrorResponse(err);
119
+ if (response2) return response2;
120
+ throw err;
121
+ }
98
122
  let result;
99
123
  try {
100
124
  result = await adapter.buildOAuthAuthorizeUrl({
@@ -115,10 +139,21 @@ async function POST(req, context) {
115
139
  const message = err instanceof Error ? err.message : "Failed to build OAuth authorize URL";
116
140
  return NextResponse.json({ error: message }, { status: 502 });
117
141
  }
118
- const finalCookie = result.extra ? (await import("../../../../../lib/oauth-state.js")).encryptOAuthState({
119
- ...stateEnvelope.payload,
120
- extra: { ...stateEnvelope.payload.extra ?? {}, ...result.extra }
121
- }) : stateEnvelope.cookie;
142
+ let finalCookie;
143
+ if (result.extra) {
144
+ try {
145
+ finalCookie = (await import("../../../../../lib/oauth-state.js")).encryptOAuthState({
146
+ ...stateEnvelope.payload,
147
+ extra: { ...stateEnvelope.payload.extra ?? {}, ...result.extra }
148
+ });
149
+ } catch (err) {
150
+ const response2 = oauthStateErrorResponse(err);
151
+ if (response2) return response2;
152
+ throw err;
153
+ }
154
+ } else {
155
+ finalCookie = stateEnvelope.cookie;
156
+ }
122
157
  const response = NextResponse.json({ authorizeUrl: result.authorizeUrl });
123
158
  response.cookies.set({
124
159
  name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,
@@ -142,6 +177,7 @@ const openApi = {
142
177
  { status: 400, description: "Invalid provider or unsupported (no OAuth)" },
143
178
  { status: 401, description: "Unauthorized" },
144
179
  { status: 422, description: "Invalid request body" },
180
+ { status: 500, description: "OAuth state secret missing or state encryption failed" },
145
181
  { status: 502, description: "Adapter failed to build authorize URL" }
146
182
  ]
147
183
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/communication_channels/api/post/oauth/%5Bprovider%5D/initiate/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'\nimport {\n COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n createOAuthState,\n DEFAULT_OAUTH_RETURN_URL,\n isSafeOAuthReturnUrl,\n normalizeOAuthReturnUrl,\n OAuthStateError,\n} from '../../../../../lib/oauth-state'\n\nexport const metadata = {\n path: '/communication_channels/oauth/[provider]/initiate',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst initiateBodySchema = z.object({\n channelType: z.literal('email').optional(),\n /** Where to send the user after the callback succeeds. Defaults to the profile page. */\n returnUrl: z.string().min(1).max(2048).refine(isSafeOAuthReturnUrl, {\n message: 'returnUrl must be a same-origin path',\n }).optional(),\n /** Optional pre-filled email \u2014 Google `login_hint`. */\n loginHint: z.string().email().optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ provider: string }> | { provider: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string },\n ) => Promise<Record<string, unknown> | null>\n}\n\n/**\n * Default profile redirect \u2014 matches the per-user channels page registered by\n * slice 3d (the `/backend/profile/communication-channels` route).\n */\nfunction defaultRedirectUri(req: Request, providerKey: string): string {\n // Derive the origin from the configured app URL / forwarded headers rather\n // than the raw request origin, so the redirect_uri matches the value the\n // provider has registered even when the app sits behind a reverse proxy.\n return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { provider } = await context.params\n if (!/^[a-z0-9_-]+$/i.test(provider)) {\n return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })\n }\n\n const adapter = getChannelAdapter(provider)\n if (!adapter) {\n return NextResponse.json(\n { error: `No ChannelAdapter for provider: ${provider}` },\n { status: 404 },\n )\n }\n if (typeof adapter.buildOAuthAuthorizeUrl !== 'function') {\n return NextResponse.json(\n { error: `Provider '${provider}' does not support OAuth (no buildOAuthAuthorizeUrl)` },\n { status: 400 },\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof initiateBodySchema>\n try {\n const json = await readJsonSafe(req, {})\n body = initiateBodySchema.parse(json ?? {})\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const credentialsService = (() => {\n try {\n return container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n return null\n }\n })()\n // OAuth client credentials are tenant-level \u2014 the admin configures them under\n // the `channel_<provider>` integration in the Integrations UI (stored at\n // userId = null). A missing row means the provider has not been set up yet;\n // we surface that as an actionable error instead of handing an empty object to\n // the adapter (which would throw a cryptic Zod \"expected string\" message).\n const credentials = await resolveOAuthClientCredentials(credentialsService, provider, {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n })\n if (!credentials) {\n return NextResponse.json(\n {\n error:\n `${provider} is not configured for this workspace yet. An administrator must add the OAuth Client ID and Secret under Integrations before mailboxes can be connected.`,\n code: 'oauth_client_not_configured',\n },\n { status: 409 },\n )\n }\n\n const redirectUri = defaultRedirectUri(req, provider)\n const stateEnvelope = createOAuthState({\n userId: auth.sub as string,\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n providerKey: provider,\n returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),\n })\n\n let result\n try {\n result = await adapter.buildOAuthAuthorizeUrl({\n state: stateEnvelope.stateParam,\n nonce: stateEnvelope.payload.nonce,\n redirectUri,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId:\n (auth as { orgId?: string | null }).orgId ?? (auth.tenantId as string),\n },\n loginHint: body.loginHint,\n })\n } catch (err) {\n if (err instanceof OAuthStateError) {\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n const message = err instanceof Error ? err.message : 'Failed to build OAuth authorize URL'\n return NextResponse.json({ error: message }, { status: 502 })\n }\n\n // If the adapter packed extras (PKCE verifier, scopes), bake them into the\n // state cookie now so the callback handler can pass them to exchangeOAuthCode.\n const finalCookie = result.extra\n ? (await import('../../../../../lib/oauth-state')).encryptOAuthState({\n ...stateEnvelope.payload,\n extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },\n })\n : stateEnvelope.cookie\n\n const response = NextResponse.json({ authorizeUrl: result.authorizeUrl })\n response.cookies.set({\n name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n value: finalCookie,\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: Math.floor(COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS / 1000),\n })\n return response\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Start a per-user channel OAuth flow',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Authorize URL + state cookie set' },\n { status: 400, description: 'Invalid provider or unsupported (no OAuth)' },\n { status: 401, description: 'Unauthorized' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Adapter failed to build authorize URL' },\n ],\n },\n },\n}\nexport default POST\n"],
5
- "mappings": "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",
6
- "names": []
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { toAbsoluteUrl } from '@open-mercato/shared/lib/url'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'\nimport {\n COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n createOAuthState,\n DEFAULT_OAUTH_RETURN_URL,\n isSafeOAuthReturnUrl,\n normalizeOAuthReturnUrl,\n OAuthStateError,\n} from '../../../../../lib/oauth-state'\n\nconst logger = createLogger('communication_channels').child({ component: 'oauth-initiate' })\n\nexport const metadata = {\n path: '/communication_channels/oauth/[provider]/initiate',\n POST: {\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\nconst initiateBodySchema = z.object({\n channelType: z.literal('email').optional(),\n /** Where to send the user after the callback succeeds. Defaults to the profile page. */\n returnUrl: z.string().min(1).max(2048).refine(isSafeOAuthReturnUrl, {\n message: 'returnUrl must be a same-origin path',\n }).optional(),\n /** Optional pre-filled email \u2014 Google `login_hint`. */\n loginHint: z.string().email().optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ provider: string }> | { provider: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string },\n ) => Promise<Record<string, unknown> | null>\n}\n\n/**\n * Default profile redirect \u2014 matches the per-user channels page registered by\n * slice 3d (the `/backend/profile/communication-channels` route).\n */\nfunction defaultRedirectUri(req: Request, providerKey: string): string {\n // Derive the origin from the configured app URL / forwarded headers rather\n // than the raw request origin, so the redirect_uri matches the value the\n // provider has registered even when the app sits behind a reverse proxy.\n return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)\n}\n\nfunction oauthStateErrorResponse(err: unknown): Response | null {\n if (err instanceof OAuthStateError) {\n if (err.code === 'missing_secret') {\n logger.error('OAuth state secret is not configured', { err })\n getTelemetryRuntime()?.reportError(err, {\n module: 'communication_channels',\n code: 'communication_channels.missing_secret',\n })\n return NextResponse.json({ error: 'OAuth is not available', code: err.code }, { status: 500 })\n }\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n return null\n}\n\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { provider } = await context.params\n if (!/^[a-z0-9_-]+$/i.test(provider)) {\n return NextResponse.json({ error: 'Invalid provider' }, { status: 400 })\n }\n\n const adapter = getChannelAdapter(provider)\n if (!adapter) {\n return NextResponse.json(\n { error: `No ChannelAdapter for provider: ${provider}` },\n { status: 404 },\n )\n }\n if (typeof adapter.buildOAuthAuthorizeUrl !== 'function') {\n return NextResponse.json(\n { error: `Provider '${provider}' does not support OAuth (no buildOAuthAuthorizeUrl)` },\n { status: 400 },\n )\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof initiateBodySchema>\n try {\n const json = await readJsonSafe(req, {})\n body = initiateBodySchema.parse(json ?? {})\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const credentialsService = (() => {\n try {\n return container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n return null\n }\n })()\n // OAuth client credentials are tenant-level \u2014 the admin configures them under\n // the `channel_<provider>` integration in the Integrations UI (stored at\n // userId = null). A missing row means the provider has not been set up yet;\n // we surface that as an actionable error instead of handing an empty object to\n // the adapter (which would throw a cryptic Zod \"expected string\" message).\n const credentials = await resolveOAuthClientCredentials(credentialsService, provider, {\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n })\n if (!credentials) {\n return NextResponse.json(\n {\n error:\n `${provider} is not configured for this workspace yet. An administrator must add the OAuth Client ID and Secret under Integrations before mailboxes can be connected.`,\n code: 'oauth_client_not_configured',\n },\n { status: 409 },\n )\n }\n\n const redirectUri = defaultRedirectUri(req, provider)\n let stateEnvelope: ReturnType<typeof createOAuthState>\n try {\n stateEnvelope = createOAuthState({\n userId: auth.sub as string,\n tenantId: auth.tenantId as string,\n organizationId: (auth as { orgId?: string | null }).orgId ?? null,\n providerKey: provider,\n returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),\n })\n } catch (err) {\n const response = oauthStateErrorResponse(err)\n if (response) return response\n throw err\n }\n\n let result\n try {\n result = await adapter.buildOAuthAuthorizeUrl({\n state: stateEnvelope.stateParam,\n nonce: stateEnvelope.payload.nonce,\n redirectUri,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId:\n (auth as { orgId?: string | null }).orgId ?? (auth.tenantId as string),\n },\n loginHint: body.loginHint,\n })\n } catch (err) {\n if (err instanceof OAuthStateError) {\n return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })\n }\n const message = err instanceof Error ? err.message : 'Failed to build OAuth authorize URL'\n return NextResponse.json({ error: message }, { status: 502 })\n }\n\n // If the adapter packed extras (PKCE verifier, scopes), bake them into the\n // state cookie now so the callback handler can pass them to exchangeOAuthCode.\n let finalCookie: string\n if (result.extra) {\n try {\n finalCookie = (await import('../../../../../lib/oauth-state')).encryptOAuthState({\n ...stateEnvelope.payload,\n extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },\n })\n } catch (err) {\n const response = oauthStateErrorResponse(err)\n if (response) return response\n throw err\n }\n } else {\n finalCookie = stateEnvelope.cookie\n }\n\n const response = NextResponse.json({ authorizeUrl: result.authorizeUrl })\n response.cookies.set({\n name: COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME,\n value: finalCookie,\n httpOnly: true,\n secure: process.env.NODE_ENV === 'production',\n sameSite: 'lax',\n path: '/',\n maxAge: Math.floor(COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS / 1000),\n })\n return response\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Start a per-user channel OAuth flow',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Authorize URL + state cookie set' },\n { status: 400, description: 'Invalid provider or unsupported (no OAuth)' },\n { status: 401, description: 'Unauthorized' },\n { status: 422, description: 'Invalid request body' },\n { status: 500, description: 'OAuth state secret missing or state encryption failed' },\n { status: 502, description: 'Adapter failed to build authorize URL' },\n ],\n },\n },\n}\nexport default POST\n"],
5
+ "mappings": "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",
6
+ "names": ["response"]
7
7
  }
@@ -40,7 +40,10 @@ function getSecret() {
40
40
  const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY;
41
41
  if (dedicated) return dedicated;
42
42
  if (process.env.NODE_ENV === "production") {
43
- throw new Error("[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production");
43
+ throw new OAuthStateError(
44
+ "[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production",
45
+ "missing_secret"
46
+ );
44
47
  }
45
48
  const fallback = process.env.JWT_SECRET;
46
49
  if (!fallback) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/oauth-state.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst oauthStateLogger = createLogger('communication_channels').child({ component: 'oauth-state' })\n\n/**\n * OAuth state-cookie helper for the communication_channels hub.\n *\n * **Ported (re-implemented locally), NOT imported, from `packages/enterprise/src/modules/sso/lib/state-cookie.ts`.**\n * Root `AGENTS.md` rule: `@open-mercato/core` MUST NOT import from `@open-mercato/enterprise`.\n *\n * Design (per email integration spec \u00A7 OSS Independence + \u00A7 Hub Deltas \u2192 Delta 7):\n * - AES-256-GCM payload encryption.\n * - HKDF (SHA-256) key derivation from `OM_HUB_OAUTH_STATE_KEY` (falling back to\n * `KMS_MASTER_KEY`). In production those dedicated keys are required; only in\n * dev/test do we fall back to `JWT_SECRET` so envs that configure one secret\n * still work. Production refuses the `JWT_SECRET` fallback so a session-secret\n * leak cannot also forge OAuth-state cookies.\n * - 5-minute TTL \u2014 short window to bound replay surface.\n * - Payload binds the initiating `userId` so the callback rejects state cookies\n * used by a different session.\n * - Single-use consume-on-read via a short-TTL cache marker keyed by\n * `tenantId:state` (see {@link consumeOAuthStateOnce}) so a captured cookie\n * cannot re-drive the callback within the TTL. **This guarantee only holds\n * across all replicas when `CACHE_STRATEGY=redis`.** With\n * `CACHE_STRATEGY=memory` (the default) the marker is process-local \u2014 a\n * replayed callback that reaches a different replica succeeds within the TTL.\n * Multi-replica deployments MUST use a shared cache (`CACHE_STRATEGY=redis`).\n *\n * The output is a base64url string that we set on an HttpOnly + SameSite=Lax cookie.\n * Forgery requires the encryption key (KMS-managed in production).\n */\n\nconst ALGORITHM = 'aes-256-gcm'\nconst IV_LENGTH = 12\nconst TAG_LENGTH = 16\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS = 5 * 60 * 1000\nconst HKDF_SALT = Buffer.from('open-mercato-channels-oauth-state-v1')\nconst HKDF_INFO = Buffer.from('communication_channels-oauth-state-cookie')\n\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME =\n 'om_cc_oauth_state'\n\nexport const DEFAULT_OAUTH_RETURN_URL = '/backend/profile/communication-channels'\n\n/** Cache tag for OAuth-state consume markers (invalidation / diagnostics). */\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG =\n 'communication_channels:oauth-state'\n\n/** Errors thrown by the helpers. Stable for tests + route mapping. */\nexport class OAuthStateError extends Error {\n override name = 'OAuthStateError'\n constructor(\n message: string,\n readonly code:\n | 'missing_secret'\n | 'invalid_cookie'\n | 'expired'\n | 'user_mismatch'\n | 'decrypt_failed'\n | 'replay',\n ) {\n super(message)\n }\n}\n\n/**\n * Canonical state-cookie payload \u2014 provider-agnostic. Each OAuth provider\n * adapter (Gmail, \u2026) packs its own per-flow nonce / verifier into\n * the `extra` field rather than extending this shape.\n */\nexport interface OAuthStatePayload {\n /** Nonce-like opaque value mirrored into the OAuth `state` query parameter. */\n state: string\n /** Per-flow CSRF nonce, returned alongside the OAuth response. */\n nonce: string\n /** Tenant-scoped user that initiated the flow. Validated on callback. */\n userId: string\n /** Tenant scope so the callback can pin the channel to the same tenant. */\n tenantId: string\n /** Optional organization id for multi-org tenants. */\n organizationId?: string | null\n /** Provider key (e.g. `gmail`) \u2014 routes the callback. */\n providerKey: string\n /** Where to redirect on success. Defaults to the profile page in the route. */\n returnUrl?: string\n /** Wall-clock expiry (ms since epoch). */\n expiresAt: number\n /** Provider-specific extras (PKCE code_verifier, scopes, login_hint, \u2026). */\n extra?: Record<string, unknown>\n}\n\n/**\n * Minimal cache surface used by {@link consumeOAuthStateOnce}. Matches the\n * `has` / `set` subset of `@open-mercato/cache`'s `CacheStrategy` so callers\n * can pass `container.resolve('cache')` directly.\n */\nexport type OAuthStateConsumeStore = {\n has(key: string): Promise<boolean>\n set(\n key: string,\n value: unknown,\n options?: { ttl?: number; tags?: string[] },\n ): Promise<void>\n}\n\nexport function isSafeOAuthReturnUrl(value: string | null | undefined): value is string {\n if (typeof value !== 'string') return false\n if (value.length === 0 || value.length > 2048) return false\n if (!value.startsWith('/') || value.startsWith('//')) return false\n if (value.includes('\\\\')) return false\n try {\n const base = new URL('https://open-mercato.local')\n const parsed = new URL(value, base)\n return parsed.origin === base.origin && parsed.pathname.startsWith('/')\n } catch {\n return false\n }\n}\n\nexport function normalizeOAuthReturnUrl(\n value: string | null | undefined,\n fallback: string = DEFAULT_OAUTH_RETURN_URL,\n): string {\n return isSafeOAuthReturnUrl(value) ? value : fallback\n}\n\nfunction deriveKey(secret: string): Buffer {\n return Buffer.from(crypto.hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32))\n}\n\nfunction getSecret(): string {\n const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY\n if (dedicated) return dedicated\n // No dedicated key configured. Fail closed in production rather than deriving\n // the state-cookie key from JWT_SECRET (the platform session-signing secret) \u2014\n // sharing that key means a JWT_SECRET leak also lets an attacker forge OAuth\n // state cookies, bypassing the userId/tenant binding. In non-production we fall\n // back to JWT_SECRET so dev/test envs that only configure one secret still work.\n if (process.env.NODE_ENV === 'production') {\n throw new Error('[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production')\n }\n const fallback = process.env.JWT_SECRET\n if (!fallback) {\n throw new OAuthStateError(\n 'OM_HUB_OAUTH_STATE_KEY (or fallback KMS_MASTER_KEY / JWT_SECRET) must be set',\n 'missing_secret',\n )\n }\n return fallback\n}\n\n/** Cache key for a consumed OAuth `state` nonce. Exported for tests. */\nexport function oauthStateConsumedCacheKey(tenantId: string, state: string): string {\n return `communication_channels:oauth-state:used:${tenantId}:${state}`\n}\n\n/** Encrypt + sign a state payload. Output is a base64url string suitable for a cookie. */\nexport function encryptOAuthState(payload: OAuthStatePayload): string {\n const key = deriveKey(getSecret())\n const iv = crypto.randomBytes(IV_LENGTH)\n const json = JSON.stringify(payload)\n\n const cipher = crypto.createCipheriv(ALGORITHM, key, iv)\n const ciphertext = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n\n return Buffer.concat([iv, tag, ciphertext]).toString('base64url')\n}\n\n/**\n * Decrypt + verify a state cookie. Returns the payload or `null` if the cookie\n * is malformed / tampered. Returns the payload (NOT null) when the cookie has\n * expired \u2014 callers should check `expiresAt` themselves with the verification\n * helper below for stable status codes.\n */\nexport function decryptOAuthState(cookie: string): OAuthStatePayload | null {\n try {\n const key = deriveKey(getSecret())\n const combined = Buffer.from(cookie, 'base64url')\n if (combined.length < IV_LENGTH + TAG_LENGTH) return null\n\n const iv = combined.subarray(0, IV_LENGTH)\n const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)\n const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH)\n\n const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)\n decipher.setAuthTag(tag)\n const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n\n return JSON.parse(decrypted) as OAuthStatePayload\n } catch {\n return null\n }\n}\n\n/**\n * Verify a state cookie against the current session.\n *\n * Throws an {@link OAuthStateError} with a stable `code` field on any check\n * failure so route handlers can map to consistent HTTP responses + redirect\n * flash codes.\n *\n * Crypto + binding checks only \u2014 callers MUST also call\n * {@link consumeOAuthStateOnce} so the state cannot be replayed within the TTL.\n */\nexport function verifyOAuthState(input: {\n cookie: string | null | undefined\n expectedUserId: string\n expectedProviderKey?: string\n expectedState?: string\n now?: number\n}): OAuthStatePayload {\n if (!input.cookie) {\n throw new OAuthStateError('Missing state cookie', 'invalid_cookie')\n }\n const payload = decryptOAuthState(input.cookie)\n if (!payload) {\n throw new OAuthStateError('Invalid state cookie', 'decrypt_failed')\n }\n const now = input.now ?? Date.now()\n if (payload.expiresAt < now) {\n throw new OAuthStateError('State cookie expired', 'expired')\n }\n if (payload.userId !== input.expectedUserId) {\n throw new OAuthStateError('State cookie userId mismatch', 'user_mismatch')\n }\n if (input.expectedProviderKey && payload.providerKey !== input.expectedProviderKey) {\n throw new OAuthStateError('State cookie providerKey mismatch', 'invalid_cookie')\n }\n if (input.expectedState && payload.state !== input.expectedState) {\n throw new OAuthStateError('State cookie state nonce mismatch', 'invalid_cookie')\n }\n return payload\n}\n\n/**\n * Mark a verified OAuth `state` as consumed in a short-TTL store.\n *\n * First successful call records a used-marker until `expiresAt` (or 1s minimum).\n * A second call with the same `state` throws {@link OAuthStateError} `replay`.\n *\n * Call immediately after {@link verifyOAuthState} on the callback path so a\n * captured valid cookie cannot re-drive the flow within the cookie TTL.\n *\n * Note: the `has`\u2192`set` pattern is not atomic. Two concurrent callbacks racing\n * on the same `state` both pass the `has` check and both proceed \u2014 the\n * single-use guarantee requires an atomic `setNx` which `CacheStrategy` does\n * not currently expose. The race window is milliseconds; document it rather\n * than silently claiming it is closed.\n */\nexport async function consumeOAuthStateOnce(\n store: OAuthStateConsumeStore,\n payload: Pick<OAuthStatePayload, 'state' | 'expiresAt' | 'tenantId'>,\n now: number = Date.now(),\n): Promise<void> {\n const key = oauthStateConsumedCacheKey(payload.tenantId, payload.state)\n if (await store.has(key)) {\n throw new OAuthStateError('State cookie already used', 'replay')\n }\n const ttl = Math.max(payload.expiresAt - now, 1_000)\n await store.set(key, 1, {\n ttl,\n tags: [`${COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG}:${payload.tenantId}`],\n })\n}\n\n/**\n * Create a fresh state payload + matching `state` query parameter. PKCE\n * verifiers are NOT generated here \u2014 the provider adapter decides whether it\n * needs PKCE and packs the verifier into `extra` itself.\n */\nexport function createOAuthState(params: {\n userId: string\n tenantId: string\n organizationId?: string | null\n providerKey: string\n returnUrl?: string\n extra?: Record<string, unknown>\n}): { payload: OAuthStatePayload; cookie: string; stateParam: string } {\n const state = crypto.randomBytes(32).toString('base64url')\n const nonce = crypto.randomBytes(16).toString('base64url')\n const payload: OAuthStatePayload = {\n state,\n nonce,\n userId: params.userId,\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n providerKey: params.providerKey,\n returnUrl: params.returnUrl,\n extra: params.extra,\n expiresAt: Date.now() + COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n }\n const cookie = encryptOAuthState(payload)\n return { payload, cookie, stateParam: state }\n}\n\nlet memoryCacheStartupWarningEmitted = false\n\n/**\n * Emit a one-time startup warning unless the cache strategy is redis. Every other\n * strategy (memory, sqlite, jsonfile) keeps replay markers per process or per node,\n * so the single-use guarantee only holds across replicas on redis.\n */\nexport function emitOAuthStateMemoryCacheStartupWarningIfNeeded(\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (memoryCacheStartupWarningEmitted) return\n const strategy = (env.CACHE_STRATEGY ?? 'memory').trim().toLowerCase()\n if (strategy === 'redis') return\n memoryCacheStartupWarningEmitted = true\n oauthStateLogger.warn(\n `OAuth state single-use protection needs a store shared across replicas. CACHE_STRATEGY=${strategy} is per-process or per-node; multi-replica deployments MUST set CACHE_STRATEGY=redis.`,\n { context: 'startup', startup: true, cacheStrategy: strategy },\n )\n}\n\n/** Test helper \u2014 reset the one-time startup warning latch. */\nexport function resetOAuthStateMemoryCacheStartupWarningForTests(): void {\n memoryCacheStartupWarningEmitted = false\n}\n\nemitOAuthStateMemoryCacheStartupWarningIfNeeded()\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst oauthStateLogger = createLogger('communication_channels').child({ component: 'oauth-state' })\n\n/**\n * OAuth state-cookie helper for the communication_channels hub.\n *\n * **Ported (re-implemented locally), NOT imported, from `packages/enterprise/src/modules/sso/lib/state-cookie.ts`.**\n * Root `AGENTS.md` rule: `@open-mercato/core` MUST NOT import from `@open-mercato/enterprise`.\n *\n * Design (per email integration spec \u00A7 OSS Independence + \u00A7 Hub Deltas \u2192 Delta 7):\n * - AES-256-GCM payload encryption.\n * - HKDF (SHA-256) key derivation from `OM_HUB_OAUTH_STATE_KEY` (falling back to\n * `KMS_MASTER_KEY`). In production those dedicated keys are required; only in\n * dev/test do we fall back to `JWT_SECRET` so envs that configure one secret\n * still work. Production refuses the `JWT_SECRET` fallback so a session-secret\n * leak cannot also forge OAuth-state cookies.\n * - 5-minute TTL \u2014 short window to bound replay surface.\n * - Payload binds the initiating `userId` so the callback rejects state cookies\n * used by a different session.\n * - Single-use consume-on-read via a short-TTL cache marker keyed by\n * `tenantId:state` (see {@link consumeOAuthStateOnce}) so a captured cookie\n * cannot re-drive the callback within the TTL. **This guarantee only holds\n * across all replicas when `CACHE_STRATEGY=redis`.** With\n * `CACHE_STRATEGY=memory` (the default) the marker is process-local \u2014 a\n * replayed callback that reaches a different replica succeeds within the TTL.\n * Multi-replica deployments MUST use a shared cache (`CACHE_STRATEGY=redis`).\n *\n * The output is a base64url string that we set on an HttpOnly + SameSite=Lax cookie.\n * Forgery requires the encryption key (KMS-managed in production).\n */\n\nconst ALGORITHM = 'aes-256-gcm'\nconst IV_LENGTH = 12\nconst TAG_LENGTH = 16\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS = 5 * 60 * 1000\nconst HKDF_SALT = Buffer.from('open-mercato-channels-oauth-state-v1')\nconst HKDF_INFO = Buffer.from('communication_channels-oauth-state-cookie')\n\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_COOKIE_NAME =\n 'om_cc_oauth_state'\n\nexport const DEFAULT_OAUTH_RETURN_URL = '/backend/profile/communication-channels'\n\n/** Cache tag for OAuth-state consume markers (invalidation / diagnostics). */\nexport const COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG =\n 'communication_channels:oauth-state'\n\n/** Errors thrown by the helpers. Stable for tests + route mapping. */\nexport class OAuthStateError extends Error {\n override name = 'OAuthStateError'\n constructor(\n message: string,\n readonly code:\n | 'missing_secret'\n | 'invalid_cookie'\n | 'expired'\n | 'user_mismatch'\n | 'decrypt_failed'\n | 'replay',\n ) {\n super(message)\n }\n}\n\n/**\n * Canonical state-cookie payload \u2014 provider-agnostic. Each OAuth provider\n * adapter (Gmail, \u2026) packs its own per-flow nonce / verifier into\n * the `extra` field rather than extending this shape.\n */\nexport interface OAuthStatePayload {\n /** Nonce-like opaque value mirrored into the OAuth `state` query parameter. */\n state: string\n /** Per-flow CSRF nonce, returned alongside the OAuth response. */\n nonce: string\n /** Tenant-scoped user that initiated the flow. Validated on callback. */\n userId: string\n /** Tenant scope so the callback can pin the channel to the same tenant. */\n tenantId: string\n /** Optional organization id for multi-org tenants. */\n organizationId?: string | null\n /** Provider key (e.g. `gmail`) \u2014 routes the callback. */\n providerKey: string\n /** Where to redirect on success. Defaults to the profile page in the route. */\n returnUrl?: string\n /** Wall-clock expiry (ms since epoch). */\n expiresAt: number\n /** Provider-specific extras (PKCE code_verifier, scopes, login_hint, \u2026). */\n extra?: Record<string, unknown>\n}\n\n/**\n * Minimal cache surface used by {@link consumeOAuthStateOnce}. Matches the\n * `has` / `set` subset of `@open-mercato/cache`'s `CacheStrategy` so callers\n * can pass `container.resolve('cache')` directly.\n */\nexport type OAuthStateConsumeStore = {\n has(key: string): Promise<boolean>\n set(\n key: string,\n value: unknown,\n options?: { ttl?: number; tags?: string[] },\n ): Promise<void>\n}\n\nexport function isSafeOAuthReturnUrl(value: string | null | undefined): value is string {\n if (typeof value !== 'string') return false\n if (value.length === 0 || value.length > 2048) return false\n if (!value.startsWith('/') || value.startsWith('//')) return false\n if (value.includes('\\\\')) return false\n try {\n const base = new URL('https://open-mercato.local')\n const parsed = new URL(value, base)\n return parsed.origin === base.origin && parsed.pathname.startsWith('/')\n } catch {\n return false\n }\n}\n\nexport function normalizeOAuthReturnUrl(\n value: string | null | undefined,\n fallback: string = DEFAULT_OAUTH_RETURN_URL,\n): string {\n return isSafeOAuthReturnUrl(value) ? value : fallback\n}\n\nfunction deriveKey(secret: string): Buffer {\n return Buffer.from(crypto.hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32))\n}\n\nfunction getSecret(): string {\n const dedicated = process.env.OM_HUB_OAUTH_STATE_KEY ?? process.env.KMS_MASTER_KEY\n if (dedicated) return dedicated\n // No dedicated key configured. Fail closed in production rather than deriving\n // the state-cookie key from JWT_SECRET (the platform session-signing secret) \u2014\n // sharing that key means a JWT_SECRET leak also lets an attacker forge OAuth\n // state cookies, bypassing the userId/tenant binding. In non-production we fall\n // back to JWT_SECRET so dev/test envs that only configure one secret still work.\n if (process.env.NODE_ENV === 'production') {\n throw new OAuthStateError(\n '[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production',\n 'missing_secret',\n )\n }\n const fallback = process.env.JWT_SECRET\n if (!fallback) {\n throw new OAuthStateError(\n 'OM_HUB_OAUTH_STATE_KEY (or fallback KMS_MASTER_KEY / JWT_SECRET) must be set',\n 'missing_secret',\n )\n }\n return fallback\n}\n\n/** Cache key for a consumed OAuth `state` nonce. Exported for tests. */\nexport function oauthStateConsumedCacheKey(tenantId: string, state: string): string {\n return `communication_channels:oauth-state:used:${tenantId}:${state}`\n}\n\n/** Encrypt + sign a state payload. Output is a base64url string suitable for a cookie. */\nexport function encryptOAuthState(payload: OAuthStatePayload): string {\n const key = deriveKey(getSecret())\n const iv = crypto.randomBytes(IV_LENGTH)\n const json = JSON.stringify(payload)\n\n const cipher = crypto.createCipheriv(ALGORITHM, key, iv)\n const ciphertext = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n\n return Buffer.concat([iv, tag, ciphertext]).toString('base64url')\n}\n\n/**\n * Decrypt + verify a state cookie. Returns the payload or `null` if the cookie\n * is malformed / tampered. Returns the payload (NOT null) when the cookie has\n * expired \u2014 callers should check `expiresAt` themselves with the verification\n * helper below for stable status codes.\n */\nexport function decryptOAuthState(cookie: string): OAuthStatePayload | null {\n try {\n const key = deriveKey(getSecret())\n const combined = Buffer.from(cookie, 'base64url')\n if (combined.length < IV_LENGTH + TAG_LENGTH) return null\n\n const iv = combined.subarray(0, IV_LENGTH)\n const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)\n const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH)\n\n const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)\n decipher.setAuthTag(tag)\n const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n\n return JSON.parse(decrypted) as OAuthStatePayload\n } catch {\n return null\n }\n}\n\n/**\n * Verify a state cookie against the current session.\n *\n * Throws an {@link OAuthStateError} with a stable `code` field on any check\n * failure so route handlers can map to consistent HTTP responses + redirect\n * flash codes.\n *\n * Crypto + binding checks only \u2014 callers MUST also call\n * {@link consumeOAuthStateOnce} so the state cannot be replayed within the TTL.\n */\nexport function verifyOAuthState(input: {\n cookie: string | null | undefined\n expectedUserId: string\n expectedProviderKey?: string\n expectedState?: string\n now?: number\n}): OAuthStatePayload {\n if (!input.cookie) {\n throw new OAuthStateError('Missing state cookie', 'invalid_cookie')\n }\n const payload = decryptOAuthState(input.cookie)\n if (!payload) {\n throw new OAuthStateError('Invalid state cookie', 'decrypt_failed')\n }\n const now = input.now ?? Date.now()\n if (payload.expiresAt < now) {\n throw new OAuthStateError('State cookie expired', 'expired')\n }\n if (payload.userId !== input.expectedUserId) {\n throw new OAuthStateError('State cookie userId mismatch', 'user_mismatch')\n }\n if (input.expectedProviderKey && payload.providerKey !== input.expectedProviderKey) {\n throw new OAuthStateError('State cookie providerKey mismatch', 'invalid_cookie')\n }\n if (input.expectedState && payload.state !== input.expectedState) {\n throw new OAuthStateError('State cookie state nonce mismatch', 'invalid_cookie')\n }\n return payload\n}\n\n/**\n * Mark a verified OAuth `state` as consumed in a short-TTL store.\n *\n * First successful call records a used-marker until `expiresAt` (or 1s minimum).\n * A second call with the same `state` throws {@link OAuthStateError} `replay`.\n *\n * Call immediately after {@link verifyOAuthState} on the callback path so a\n * captured valid cookie cannot re-drive the flow within the cookie TTL.\n *\n * Note: the `has`\u2192`set` pattern is not atomic. Two concurrent callbacks racing\n * on the same `state` both pass the `has` check and both proceed \u2014 the\n * single-use guarantee requires an atomic `setNx` which `CacheStrategy` does\n * not currently expose. The race window is milliseconds; document it rather\n * than silently claiming it is closed.\n */\nexport async function consumeOAuthStateOnce(\n store: OAuthStateConsumeStore,\n payload: Pick<OAuthStatePayload, 'state' | 'expiresAt' | 'tenantId'>,\n now: number = Date.now(),\n): Promise<void> {\n const key = oauthStateConsumedCacheKey(payload.tenantId, payload.state)\n if (await store.has(key)) {\n throw new OAuthStateError('State cookie already used', 'replay')\n }\n const ttl = Math.max(payload.expiresAt - now, 1_000)\n await store.set(key, 1, {\n ttl,\n tags: [`${COMMUNICATION_CHANNELS_OAUTH_STATE_CACHE_TAG}:${payload.tenantId}`],\n })\n}\n\n/**\n * Create a fresh state payload + matching `state` query parameter. PKCE\n * verifiers are NOT generated here \u2014 the provider adapter decides whether it\n * needs PKCE and packs the verifier into `extra` itself.\n */\nexport function createOAuthState(params: {\n userId: string\n tenantId: string\n organizationId?: string | null\n providerKey: string\n returnUrl?: string\n extra?: Record<string, unknown>\n}): { payload: OAuthStatePayload; cookie: string; stateParam: string } {\n const state = crypto.randomBytes(32).toString('base64url')\n const nonce = crypto.randomBytes(16).toString('base64url')\n const payload: OAuthStatePayload = {\n state,\n nonce,\n userId: params.userId,\n tenantId: params.tenantId,\n organizationId: params.organizationId ?? null,\n providerKey: params.providerKey,\n returnUrl: params.returnUrl,\n extra: params.extra,\n expiresAt: Date.now() + COMMUNICATION_CHANNELS_OAUTH_STATE_TTL_MS,\n }\n const cookie = encryptOAuthState(payload)\n return { payload, cookie, stateParam: state }\n}\n\nlet memoryCacheStartupWarningEmitted = false\n\n/**\n * Emit a one-time startup warning unless the cache strategy is redis. Every other\n * strategy (memory, sqlite, jsonfile) keeps replay markers per process or per node,\n * so the single-use guarantee only holds across replicas on redis.\n */\nexport function emitOAuthStateMemoryCacheStartupWarningIfNeeded(\n env: NodeJS.ProcessEnv = process.env,\n): void {\n if (memoryCacheStartupWarningEmitted) return\n const strategy = (env.CACHE_STRATEGY ?? 'memory').trim().toLowerCase()\n if (strategy === 'redis') return\n memoryCacheStartupWarningEmitted = true\n oauthStateLogger.warn(\n `OAuth state single-use protection needs a store shared across replicas. CACHE_STRATEGY=${strategy} is per-process or per-node; multi-replica deployments MUST set CACHE_STRATEGY=redis.`,\n { context: 'startup', startup: true, cacheStrategy: strategy },\n )\n}\n\n/** Test helper \u2014 reset the one-time startup warning latch. */\nexport function resetOAuthStateMemoryCacheStartupWarningForTests(): void {\n memoryCacheStartupWarningEmitted = false\n}\n\nemitOAuthStateMemoryCacheStartupWarningIfNeeded()\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -32,6 +32,16 @@ function useConnectChannel(options) {
32
32
  });
33
33
  const body = response.result;
34
34
  if (!response.ok || !body?.authorizeUrl) {
35
+ if (body?.code === "missing_secret") {
36
+ flash(
37
+ t(
38
+ "communication_channels.profile.connect.notAvailable",
39
+ "Mailbox connection is not available. Ask an administrator to configure the OAuth state key."
40
+ ),
41
+ "error"
42
+ );
43
+ return;
44
+ }
35
45
  if (body?.code === "oauth_client_not_configured") {
36
46
  flash(
37
47
  t(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/use-connect-channel.ts"],
4
- "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\n\ntype InitiateResponse = { authorizeUrl?: string; error?: string; code?: string }\n\n/**\n * Shared OAuth \"connect\" flow for email channel provider widgets (Gmail and\n * other OAuth providers). Wraps the guarded-mutation contract + `/oauth/<provider>/initiate`\n * call + redirect, so each provider widget only supplies its own button chrome.\n */\nexport function useConnectChannel(options: {\n providerKey: string\n returnUrl?: string\n}): { connect: () => Promise<void>; pending: boolean } {\n const { providerKey } = options\n const returnUrl = options.returnUrl ?? '/backend/profile/communication-channels'\n const t = useT()\n const [pending, setPending] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation({\n contextId: `channel-${providerKey}-connect`,\n blockedMessage: t('communication_channels.profile.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey, retryLastMutation }),\n [providerKey, retryLastMutation],\n )\n\n const connect = React.useCallback(async () => {\n if (pending) return\n setPending(true)\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey },\n operation: () =>\n apiCall<InitiateResponse>(`/api/communication_channels/oauth/${providerKey}/initiate`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ returnUrl }),\n }),\n })\n const body = response.result as InitiateResponse | undefined\n if (!response.ok || !body?.authorizeUrl) {\n if (body?.code === 'oauth_client_not_configured') {\n flash(\n t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n ),\n 'error',\n )\n return\n }\n flash(\n body?.error ??\n t('communication_channels.profile.connect.oauthFailed', 'Could not start OAuth connection.'),\n 'error',\n )\n return\n }\n window.location.assign(body.authorizeUrl)\n } finally {\n setPending(false)\n }\n }, [mutationContext, pending, providerKey, returnUrl, runMutation, t])\n\n return { connect, pending }\n}\n"],
5
- "mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,eAAe;AASjB,SAAS,kBAAkB,SAGqB;AACrD,QAAM,EAAE,YAAY,IAAI;AACxB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,EAAE,aAAa,kBAAkB,IAAI,mBAAmB;AAAA,IAC5D,WAAW,WAAW,WAAW;AAAA,IACjC,gBAAgB,EAAE,kDAAkD,kCAAkC;AAAA,EACxG,CAAC;AACD,QAAM,kBAAkB,MAAM;AAAA,IAC5B,OAAO,EAAE,aAAa,kBAAkB;AAAA,IACxC,CAAC,aAAa,iBAAiB;AAAA,EACjC;AAEA,QAAM,UAAU,MAAM,YAAY,YAAY;AAC5C,QAAI,QAAS;AACb,eAAW,IAAI;AACf,QAAI;AACF,YAAM,WAAW,MAAM,YAAY;AAAA,QACjC,SAAS;AAAA,QACT,iBAAiB,EAAE,YAAY;AAAA,QAC/B,WAAW,MACT,QAA0B,qCAAqC,WAAW,aAAa;AAAA,UACrF,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC;AAAA,QACpC,CAAC;AAAA,MACL,CAAC;AACD,YAAM,OAAO,SAAS;AACtB,UAAI,CAAC,SAAS,MAAM,CAAC,MAAM,cAAc;AACvC,YAAI,MAAM,SAAS,+BAA+B;AAChD;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA;AAAA,UACE,MAAM,SACJ,EAAE,sDAAsD,mCAAmC;AAAA,UAC7F;AAAA,QACF;AACA;AAAA,MACF;AACA,aAAO,SAAS,OAAO,KAAK,YAAY;AAAA,IAC1C,UAAE;AACA,iBAAW,KAAK;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,iBAAiB,SAAS,aAAa,WAAW,aAAa,CAAC,CAAC;AAErE,SAAO,EAAE,SAAS,QAAQ;AAC5B;",
4
+ "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\n\ntype InitiateResponse = { authorizeUrl?: string; error?: string; code?: string }\n\n/**\n * Shared OAuth \"connect\" flow for email channel provider widgets (Gmail and\n * other OAuth providers). Wraps the guarded-mutation contract + `/oauth/<provider>/initiate`\n * call + redirect, so each provider widget only supplies its own button chrome.\n */\nexport function useConnectChannel(options: {\n providerKey: string\n returnUrl?: string\n}): { connect: () => Promise<void>; pending: boolean } {\n const { providerKey } = options\n const returnUrl = options.returnUrl ?? '/backend/profile/communication-channels'\n const t = useT()\n const [pending, setPending] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation({\n contextId: `channel-${providerKey}-connect`,\n blockedMessage: t('communication_channels.profile.connect.blocked', 'Connection blocked by validation'),\n })\n const mutationContext = React.useMemo(\n () => ({ providerKey, retryLastMutation }),\n [providerKey, retryLastMutation],\n )\n\n const connect = React.useCallback(async () => {\n if (pending) return\n setPending(true)\n try {\n const response = await runMutation({\n context: mutationContext,\n mutationPayload: { providerKey },\n operation: () =>\n apiCall<InitiateResponse>(`/api/communication_channels/oauth/${providerKey}/initiate`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ returnUrl }),\n }),\n })\n const body = response.result as InitiateResponse | undefined\n if (!response.ok || !body?.authorizeUrl) {\n if (body?.code === 'missing_secret') {\n flash(\n t(\n 'communication_channels.profile.connect.notAvailable',\n 'Mailbox connection is not available. Ask an administrator to configure the OAuth state key.',\n ),\n 'error',\n )\n return\n }\n if (body?.code === 'oauth_client_not_configured') {\n flash(\n t(\n 'communication_channels.profile.connect.notConfigured',\n 'This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.',\n ),\n 'error',\n )\n return\n }\n flash(\n body?.error ??\n t('communication_channels.profile.connect.oauthFailed', 'Could not start OAuth connection.'),\n 'error',\n )\n return\n }\n window.location.assign(body.authorizeUrl)\n } finally {\n setPending(false)\n }\n }, [mutationContext, pending, providerKey, returnUrl, runMutation, t])\n\n return { connect, pending }\n}\n"],
5
+ "mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,eAAe;AASjB,SAAS,kBAAkB,SAGqB;AACrD,QAAM,EAAE,YAAY,IAAI;AACxB,QAAM,YAAY,QAAQ,aAAa;AACvC,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,EAAE,aAAa,kBAAkB,IAAI,mBAAmB;AAAA,IAC5D,WAAW,WAAW,WAAW;AAAA,IACjC,gBAAgB,EAAE,kDAAkD,kCAAkC;AAAA,EACxG,CAAC;AACD,QAAM,kBAAkB,MAAM;AAAA,IAC5B,OAAO,EAAE,aAAa,kBAAkB;AAAA,IACxC,CAAC,aAAa,iBAAiB;AAAA,EACjC;AAEA,QAAM,UAAU,MAAM,YAAY,YAAY;AAC5C,QAAI,QAAS;AACb,eAAW,IAAI;AACf,QAAI;AACF,YAAM,WAAW,MAAM,YAAY;AAAA,QACjC,SAAS;AAAA,QACT,iBAAiB,EAAE,YAAY;AAAA,QAC/B,WAAW,MACT,QAA0B,qCAAqC,WAAW,aAAa;AAAA,UACrF,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,CAAC;AAAA,QACpC,CAAC;AAAA,MACL,CAAC;AACD,YAAM,OAAO,SAAS;AACtB,UAAI,CAAC,SAAS,MAAM,CAAC,MAAM,cAAc;AACvC,YAAI,MAAM,SAAS,kBAAkB;AACnC;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA,YAAI,MAAM,SAAS,+BAA+B;AAChD;AAAA,YACE;AAAA,cACE;AAAA,cACA;AAAA,YACF;AAAA,YACA;AAAA,UACF;AACA;AAAA,QACF;AACA;AAAA,UACE,MAAM,SACJ,EAAE,sDAAsD,mCAAmC;AAAA,UAC7F;AAAA,QACF;AACA;AAAA,MACF;AACA,aAAO,SAAS,OAAO,KAAK,YAAY;AAAA,IAC1C,UAAE;AACA,iBAAW,KAAK;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,iBAAiB,SAAS,aAAa,WAAW,aAAa,CAAC,CAAC;AAErE,SAAO,EAAE,SAAS,QAAQ;AAC5B;",
6
6
  "names": []
7
7
  }
@@ -567,6 +567,16 @@ async function resolveUpload(em, runId, cursor, scope) {
567
567
  }
568
568
  return null;
569
569
  }
570
+ async function personExists(em, personId, scope) {
571
+ const matches = await em.count(CustomerEntity, {
572
+ id: personId,
573
+ kind: "person",
574
+ organizationId: scope.organizationId,
575
+ tenantId: scope.tenantId,
576
+ deletedAt: null
577
+ });
578
+ return matches > 0;
579
+ }
570
580
  async function resolveExistingPersonId(params) {
571
581
  if (params.externalId) {
572
582
  const mappedLocalId = await params.externalIdMappingService.lookupLocalId(
@@ -575,7 +585,7 @@ async function resolveExistingPersonId(params) {
575
585
  params.externalId,
576
586
  params.scope
577
587
  );
578
- if (mappedLocalId) return mappedLocalId;
588
+ if (mappedLocalId && await personExists(params.em, mappedLocalId, params.scope)) return mappedLocalId;
579
589
  }
580
590
  if (!params.email) return null;
581
591
  return params.emailDedupeIndex.get(params.email) ?? null;
@@ -640,6 +650,7 @@ async function processRow(params) {
640
650
  };
641
651
  }
642
652
  const existingId = await resolveExistingPersonId({
653
+ em: params.em,
643
654
  externalIdMappingService: params.externalIdMappingService,
644
655
  externalId,
645
656
  email: payload.values.primaryEmail,
@@ -657,6 +668,20 @@ async function processRow(params) {
657
668
  }
658
669
  };
659
670
  }
671
+ const rememberPersonIdentity = async (personId) => {
672
+ if (externalId) {
673
+ await params.externalIdMappingService.storeExternalIdMapping(
674
+ "sync_excel",
675
+ "customers.person",
676
+ personId,
677
+ externalId,
678
+ params.scope
679
+ );
680
+ }
681
+ if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
682
+ params.emailDedupeIndex.set(payload.values.primaryEmail, personId);
683
+ }
684
+ };
660
685
  try {
661
686
  if (existingId) {
662
687
  const updateInput = {
@@ -668,6 +693,7 @@ async function processRow(params) {
668
693
  input: updateInput,
669
694
  ctx: params.commandContext
670
695
  });
696
+ await rememberPersonIdentity(existingId);
671
697
  await upsertPrimaryAddress({
672
698
  entityId: existingId,
673
699
  addressValues: payload.addressValues,
@@ -676,18 +702,6 @@ async function processRow(params) {
676
702
  commandContext: params.commandContext,
677
703
  em: params.em
678
704
  });
679
- if (externalId) {
680
- await params.externalIdMappingService.storeExternalIdMapping(
681
- "sync_excel",
682
- "customers.person",
683
- existingId,
684
- externalId,
685
- params.scope
686
- );
687
- }
688
- if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
689
- params.emailDedupeIndex.set(payload.values.primaryEmail, existingId);
690
- }
691
705
  return {
692
706
  externalId: externalId ?? sourceIdentifier,
693
707
  action: "update",
@@ -705,6 +719,7 @@ async function processRow(params) {
705
719
  },
706
720
  ctx: params.commandContext
707
721
  });
722
+ await rememberPersonIdentity(commandResult.result.entityId);
708
723
  await upsertPrimaryAddress({
709
724
  entityId: commandResult.result.entityId,
710
725
  addressValues: payload.addressValues,
@@ -713,18 +728,6 @@ async function processRow(params) {
713
728
  commandContext: params.commandContext,
714
729
  em: params.em
715
730
  });
716
- if (externalId) {
717
- await params.externalIdMappingService.storeExternalIdMapping(
718
- "sync_excel",
719
- "customers.person",
720
- commandResult.result.entityId,
721
- externalId,
722
- params.scope
723
- );
724
- }
725
- if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
726
- params.emailDedupeIndex.set(payload.values.primaryEmail, commandResult.result.entityId);
727
- }
728
731
  return {
729
732
  externalId: externalId ?? sourceIdentifier,
730
733
  action: "create",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/sync_excel/lib/adapters/customers.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'\nimport type {\n DataMapping,\n DataSyncAdapter,\n FieldMapping,\n ImportBatch,\n ImportItem,\n TenantScope,\n} from '../../../data_sync/lib/adapter'\nimport type { ExternalIdMappingService } from '../../../data_sync/lib/id-mapping'\nimport { SyncMapping } from '../../../data_sync/data/entities'\nimport { CustomerAddress, CustomerEntity } from '../../../customers/data/entities'\nimport { Attachment } from '../../../attachments/data/entities'\nimport { CustomFieldDef } from '../../../entities/data/entities'\nimport { SyncExcelUpload } from '../../data/entities'\nimport { parseCsvDocumentBatches, parseCsvStreamMetadata, type CsvPreviewRow } from '../parser'\nimport { createSyncExcelUploadReadStream } from '../upload-storage'\nimport { E } from '#generated/entities.ids.generated'\n\ntype SyncExcelCursor = {\n uploadId: string\n offset: number\n}\n\ntype Container = Awaited<ReturnType<typeof createRequestContainer>>\n\nconst CUSTOMER_IMPORT_COVERAGE_ENTITY_TYPES = [\n E.customers.customer_entity,\n E.customers.customer_person_profile,\n E.customers.customer_address,\n]\n\ntype PersonFieldValues = {\n externalId?: string | null\n firstName?: string | null\n lastName?: string | null\n displayName?: string | null\n primaryEmail?: string | null\n primaryPhone?: string | null\n jobTitle?: string | null\n status?: string | null\n source?: string | null\n description?: string | null\n}\n\ntype AddressFieldValues = {\n name?: string | null\n purpose?: string | null\n companyName?: string | null\n addressLine1?: string | null\n addressLine2?: string | null\n buildingNumber?: string | null\n flatNumber?: string | null\n city?: string | null\n region?: string | null\n postalCode?: string | null\n country?: string | null\n latitude?: number | null\n longitude?: number | null\n}\n\ntype PersonRowValues = {\n values: PersonFieldValues\n customFields: Record<string, unknown>\n addressValues: AddressFieldValues\n}\n\ntype ImportCustomFieldDefinition = {\n key: string\n kind: string\n entityId: string\n organizationId?: string | null\n tenantId?: string | null\n updatedAt?: Date | string | null\n}\n\ntype EmailDedupeIndex = Map<string, string>\n\ntype BuiltPersonPayload = {\n values: PersonFieldValues\n customFields: Record<string, unknown>\n addressValues: AddressFieldValues\n createInput: {\n organizationId: string\n tenantId: string\n firstName: string\n lastName: string\n displayName: string\n primaryEmail?: string\n primaryPhone?: string\n jobTitle?: string\n status?: string\n source?: string\n description?: string\n customFields?: Record<string, unknown>\n } | null\n updateInput: {\n organizationId: string\n tenantId: string\n primaryEmail?: string\n primaryPhone?: string\n jobTitle?: string\n status?: string\n source?: string\n description?: string\n firstName?: string\n lastName?: string\n displayName?: string\n customFields?: Record<string, unknown>\n }\n sourceIdentifier: string | null\n}\n\nfunction normalizeOptionalString(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nfunction normalizeEmail(value: unknown): string | null {\n const normalized = normalizeOptionalString(value)\n return normalized ? normalized.toLowerCase() : null\n}\n\nconst COUNTRY_DIAL_CODES: Record<string, string> = {\n canada: '1',\n england: '44',\n greatbritain: '44',\n poland: '48',\n scotland: '44',\n uk: '44',\n unitedkingdom: '44',\n unitedstates: '1',\n unitedstatesofamerica: '1',\n usa: '1',\n wales: '44',\n}\n\nfunction normalizeCountryKey(value: unknown): string | null {\n const normalized = normalizeOptionalString(value)\n if (!normalized) return null\n return normalized.toLowerCase().replace(/[^a-z]/g, '')\n}\n\nfunction resolveCountryDialCode(row: CsvPreviewRow): string | null {\n const countryCandidates = [\n row.Country,\n row.country,\n row['Country/Region'],\n row['country/region'],\n ]\n\n for (const candidate of countryCandidates) {\n const countryKey = normalizeCountryKey(candidate)\n if (!countryKey) continue\n const dialCode = COUNTRY_DIAL_CODES[countryKey]\n if (dialCode) return dialCode\n }\n\n return null\n}\n\nfunction stripPhoneExtension(value: string): string {\n return value\n .replace(/\\s*(ext\\.?|extension|x)\\s*\\d+$/i, '')\n .replace(/^\\+\\s+/, '+')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nfunction normalizeImportedPhone(value: unknown, row: CsvPreviewRow): string | null {\n const normalized = normalizeOptionalString(value)\n if (!normalized) return null\n\n const directValidation = validatePhoneNumber(normalized)\n if (directValidation.valid) {\n return directValidation.normalized\n }\n\n const sanitized = stripPhoneExtension(normalized)\n const sanitizedValidation = validatePhoneNumber(sanitized)\n if (sanitizedValidation.valid) {\n return sanitizedValidation.normalized\n }\n\n const digits = extractPhoneDigits(sanitized)\n if (!digits) return null\n\n if (digits.length >= 9 && digits.length <= 15 && sanitized.startsWith('00')) {\n const internationalCandidate = `+${digits.slice(2)}`\n const internationalValidation = validatePhoneNumber(internationalCandidate)\n if (internationalValidation.valid) {\n return internationalValidation.normalized\n }\n }\n\n const dialCode = resolveCountryDialCode(row)\n if (!dialCode) return null\n\n let nationalDigits = digits\n if (nationalDigits.startsWith(dialCode)) {\n nationalDigits = nationalDigits.slice(dialCode.length)\n } else {\n nationalDigits = nationalDigits.replace(/^0+/, '')\n }\n\n if (!nationalDigits) return null\n\n const localizedCandidate = `+${dialCode}${nationalDigits}`\n const localizedValidation = validatePhoneNumber(localizedCandidate)\n return localizedValidation.valid ? localizedValidation.normalized : null\n}\n\nfunction normalizeOptionalNumber(value: unknown): number | null {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : null\n }\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (trimmed.length === 0) return null\n const parsed = Number(trimmed)\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction normalizeCustomFieldDate(value: string): string | null {\n const parsed = new Date(value)\n if (Number.isNaN(parsed.getTime())) return null\n return parsed.toISOString().slice(0, 10)\n}\n\nfunction normalizeCustomFieldBoolean(value: string): boolean | null {\n const normalized = value.trim().toLowerCase()\n if (['true', '1', 'yes', 'y', 't', 'tak'].includes(normalized)) return true\n if (['false', '0', 'no', 'n', 'f', 'nie'].includes(normalized)) return false\n return null\n}\n\nfunction coerceCustomFieldValue(\n key: string,\n value: unknown,\n definitions: Map<string, ImportCustomFieldDefinition>,\n): { ok: true; value: unknown } | { ok: false; error: string } {\n if (typeof value === 'string' && value.trim().length === 0) {\n return { ok: true, value: null }\n }\n\n const definition = definitions.get(key)\n if (!definition) {\n return { ok: true, value: typeof value === 'string' ? value.trim() : value }\n }\n\n if (value === null || value === undefined) {\n return { ok: true, value: null }\n }\n\n const text = typeof value === 'string' ? value.trim() : String(value)\n if (text.length === 0) {\n return { ok: true, value: null }\n }\n\n const kind = definition.kind.toLowerCase()\n if (kind === 'boolean') {\n const booleanValue = normalizeCustomFieldBoolean(text)\n if (booleanValue === null) {\n return { ok: false, error: `Custom field \"${key}\" expects a boolean value.` }\n }\n return { ok: true, value: booleanValue }\n }\n\n if (kind === 'integer') {\n const numberValue = Number(text)\n if (!Number.isInteger(numberValue)) {\n return { ok: false, error: `Custom field \"${key}\" expects an integer value.` }\n }\n return { ok: true, value: numberValue }\n }\n\n if (kind === 'float' || kind === 'currency' || kind === 'number') {\n const numberValue = Number(text)\n if (!Number.isFinite(numberValue)) {\n return { ok: false, error: `Custom field \"${key}\" expects a number value.` }\n }\n return { ok: true, value: numberValue }\n }\n\n if (kind === 'date' || kind === 'datetime') {\n const dateValue = normalizeCustomFieldDate(text)\n if (!dateValue) {\n return { ok: false, error: `Custom field \"${key}\" expects a date value.` }\n }\n return { ok: true, value: dateValue }\n }\n\n return { ok: true, value: text }\n}\n\nfunction coerceCustomFields(\n values: Record<string, unknown>,\n definitions: Map<string, ImportCustomFieldDefinition>,\n): { ok: true; values: Record<string, unknown> } | { ok: false; error: string } {\n const coerced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(values)) {\n const result = coerceCustomFieldValue(key, value, definitions)\n if (!result.ok) return result\n coerced[key] = result.value\n }\n return { ok: true, values: coerced }\n}\n\nfunction customFieldDefinitionScopeScore(definition: ImportCustomFieldDefinition): number {\n return (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0)\n}\n\nfunction customFieldDefinitionUpdatedAt(definition: ImportCustomFieldDefinition): number {\n if (!definition.updatedAt) return 0\n const value = definition.updatedAt instanceof Date\n ? definition.updatedAt.getTime()\n : new Date(definition.updatedAt).getTime()\n return Number.isFinite(value) ? value : 0\n}\n\nfunction preferCustomFieldDefinition(\n current: ImportCustomFieldDefinition | undefined,\n candidate: ImportCustomFieldDefinition,\n): boolean {\n if (!current) return true\n const candidateScore = customFieldDefinitionScopeScore(candidate)\n const currentScore = customFieldDefinitionScopeScore(current)\n if (candidateScore !== currentScore) return candidateScore > currentScore\n return customFieldDefinitionUpdatedAt(candidate) >= customFieldDefinitionUpdatedAt(current)\n}\n\nasync function loadImportCustomFieldDefinitions(\n em: EntityManager,\n scope: TenantScope,\n): Promise<Map<string, ImportCustomFieldDefinition>> {\n const definitions = await findWithDecryption(\n em,\n CustomFieldDef,\n {\n entityId: { $in: [E.customers.customer_entity, E.customers.customer_person_profile] as string[] },\n deletedAt: null,\n isActive: true,\n $and: [\n { $or: [{ tenantId: scope.tenantId }, { tenantId: null }] },\n { $or: [{ organizationId: scope.organizationId }, { organizationId: null }] },\n ],\n },\n undefined,\n scope,\n )\n\n const byKey = new Map<string, ImportCustomFieldDefinition>()\n for (const definition of definitions) {\n const candidate: ImportCustomFieldDefinition = {\n key: definition.key,\n kind: definition.kind,\n entityId: definition.entityId,\n organizationId: definition.organizationId ?? null,\n tenantId: definition.tenantId ?? null,\n updatedAt: definition.updatedAt ?? null,\n }\n if (preferCustomFieldDefinition(byKey.get(candidate.key), candidate)) {\n byKey.set(candidate.key, candidate)\n }\n }\n return byKey\n}\n\nfunction buildCommandContext(container: Container, scope: TenantScope): CommandRuntimeContext {\n return {\n container,\n auth: null,\n organizationScope: {\n selectedId: scope.organizationId,\n filterIds: [scope.organizationId],\n allowedIds: [scope.organizationId],\n tenantId: scope.tenantId,\n },\n selectedOrganizationId: scope.organizationId,\n organizationIds: [scope.organizationId],\n }\n}\n\nexport function createCursor(uploadId: string, offset: number): string {\n return JSON.stringify({\n uploadId,\n offset,\n })\n}\n\nexport function parseCursor(value: string | null | undefined): SyncExcelCursor | null {\n if (!value) return null\n try {\n const parsed = JSON.parse(value) as Partial<SyncExcelCursor> | null\n if (!parsed || typeof parsed !== 'object') return null\n if (typeof parsed.uploadId !== 'string' || parsed.uploadId.trim().length === 0) return null\n if (typeof parsed.offset !== 'number' || !Number.isFinite(parsed.offset) || parsed.offset < 0) return null\n return {\n uploadId: parsed.uploadId,\n offset: parsed.offset,\n }\n } catch {\n return null\n }\n}\n\nfunction mapRowValues(row: CsvPreviewRow, fields: FieldMapping[]): PersonRowValues {\n const values: PersonFieldValues = {}\n const customFields: Record<string, unknown> = {}\n const addressValues: AddressFieldValues = {}\n\n for (const field of fields) {\n if (field.mappingKind === 'ignore') continue\n const rawValue = row[field.externalField]\n if (rawValue === undefined || rawValue === null) continue\n if (field.mappingKind === 'custom_field' || field.localField.startsWith('cf:')) {\n const customFieldKey = field.localField.startsWith('cf:') ? field.localField.slice(3) : field.localField\n if (customFieldKey.trim().length > 0) {\n customFields[customFieldKey] = rawValue\n }\n continue\n }\n\n if (field.localField === 'person.externalId') {\n values.externalId = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.firstName') {\n values.firstName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.lastName') {\n values.lastName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.displayName') {\n values.displayName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.primaryEmail') {\n values.primaryEmail = normalizeEmail(rawValue)\n continue\n }\n if (field.localField === 'person.primaryPhone') {\n values.primaryPhone = normalizeImportedPhone(rawValue, row)\n continue\n }\n if (field.localField === 'person.jobTitle') {\n values.jobTitle = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.status') {\n values.status = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.source') {\n values.source = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.description') {\n values.description = normalizeOptionalString(rawValue)\n continue\n }\n\n if (field.localField === 'address.name') {\n addressValues.name = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.purpose') {\n addressValues.purpose = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.companyName') {\n addressValues.companyName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.addressLine1') {\n addressValues.addressLine1 = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.addressLine2') {\n addressValues.addressLine2 = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.buildingNumber') {\n addressValues.buildingNumber = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.flatNumber') {\n addressValues.flatNumber = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.city') {\n addressValues.city = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.region') {\n addressValues.region = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.postalCode') {\n addressValues.postalCode = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.country') {\n addressValues.country = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.latitude') {\n addressValues.latitude = normalizeOptionalNumber(rawValue)\n continue\n }\n if (field.localField === 'address.longitude') {\n addressValues.longitude = normalizeOptionalNumber(rawValue)\n }\n }\n\n return {\n values,\n customFields,\n addressValues,\n }\n}\n\nfunction derivePersonNames(values: PersonFieldValues): { firstName: string; lastName: string; displayName: string } | null {\n const firstName = values.firstName ?? null\n const lastName = values.lastName ?? null\n const explicitDisplayName = values.displayName ?? null\n\n if (firstName && lastName) {\n return {\n firstName,\n lastName,\n displayName: explicitDisplayName ?? `${firstName} ${lastName}`.trim(),\n }\n }\n\n if (explicitDisplayName) {\n const parts = explicitDisplayName.split(/\\s+/).filter((part) => part.length > 0)\n if (parts.length >= 2) {\n return {\n firstName: firstName ?? parts.slice(0, -1).join(' '),\n lastName: lastName ?? parts.at(-1) ?? explicitDisplayName,\n displayName: explicitDisplayName,\n }\n }\n return {\n firstName: firstName ?? explicitDisplayName,\n lastName: lastName ?? explicitDisplayName,\n displayName: explicitDisplayName,\n }\n }\n\n return null\n}\n\nexport function buildPersonPayload(row: CsvPreviewRow, mapping: DataMapping, scope: TenantScope): BuiltPersonPayload {\n const { values, customFields, addressValues } = mapRowValues(row, mapping.fields)\n const derivedNames = derivePersonNames(values)\n const sourceIdentifier = values.externalId ?? values.primaryEmail ?? values.displayName ?? null\n\n const updateInput: BuiltPersonPayload['updateInput'] = {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n\n if (values.primaryEmail) updateInput.primaryEmail = values.primaryEmail\n if (values.primaryPhone) updateInput.primaryPhone = values.primaryPhone\n if (values.jobTitle) updateInput.jobTitle = values.jobTitle\n if (values.status) updateInput.status = values.status\n if (values.source) updateInput.source = values.source\n if (values.description) updateInput.description = values.description\n if (derivedNames?.firstName) updateInput.firstName = derivedNames.firstName\n if (derivedNames?.lastName) updateInput.lastName = derivedNames.lastName\n if (derivedNames?.displayName) updateInput.displayName = derivedNames.displayName\n\n if (!derivedNames) {\n return {\n values,\n customFields,\n addressValues,\n createInput: null,\n updateInput,\n sourceIdentifier,\n }\n }\n\n return {\n values,\n customFields,\n addressValues,\n createInput: {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n firstName: derivedNames.firstName,\n lastName: derivedNames.lastName,\n displayName: derivedNames.displayName,\n ...(values.primaryEmail ? { primaryEmail: values.primaryEmail } : {}),\n ...(values.primaryPhone ? { primaryPhone: values.primaryPhone } : {}),\n ...(values.jobTitle ? { jobTitle: values.jobTitle } : {}),\n ...(values.status ? { status: values.status } : {}),\n ...(values.source ? { source: values.source } : {}),\n ...(values.description ? { description: values.description } : {}),\n },\n updateInput,\n sourceIdentifier,\n }\n}\n\nfunction hasAddressValues(addressValues: AddressFieldValues): boolean {\n return Object.values(addressValues).some((value) => value !== null && value !== undefined)\n}\n\nfunction buildAddressCreateInput(addressValues: AddressFieldValues, entityId: string, scope: TenantScope) {\n if (!addressValues.addressLine1) return null\n\n return {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n entityId,\n addressLine1: addressValues.addressLine1,\n isPrimary: true,\n ...(addressValues.name ? { name: addressValues.name } : {}),\n ...(addressValues.purpose ? { purpose: addressValues.purpose } : {}),\n ...(addressValues.companyName ? { companyName: addressValues.companyName } : {}),\n ...(addressValues.addressLine2 ? { addressLine2: addressValues.addressLine2 } : {}),\n ...(addressValues.buildingNumber ? { buildingNumber: addressValues.buildingNumber } : {}),\n ...(addressValues.flatNumber ? { flatNumber: addressValues.flatNumber } : {}),\n ...(addressValues.city ? { city: addressValues.city } : {}),\n ...(addressValues.region ? { region: addressValues.region } : {}),\n ...(addressValues.postalCode ? { postalCode: addressValues.postalCode } : {}),\n ...(addressValues.country ? { country: addressValues.country } : {}),\n ...(addressValues.latitude !== null && addressValues.latitude !== undefined ? { latitude: addressValues.latitude } : {}),\n ...(addressValues.longitude !== null && addressValues.longitude !== undefined ? { longitude: addressValues.longitude } : {}),\n }\n}\n\nasync function upsertPrimaryAddress(params: {\n entityId: string\n addressValues: AddressFieldValues\n scope: TenantScope\n commandBus: CommandBus\n commandContext: CommandRuntimeContext\n em: EntityManager\n}): Promise<void> {\n if (!hasAddressValues(params.addressValues)) return\n if (!params.addressValues.addressLine1) return\n\n const [existingPrimaryAddress] = await findWithDecryption(\n params.em,\n CustomerAddress,\n {\n entity: params.entityId,\n organizationId: params.scope.organizationId,\n tenantId: params.scope.tenantId,\n isPrimary: true,\n },\n {\n orderBy: {\n createdAt: 'asc',\n },\n limit: 1,\n },\n params.scope,\n )\n\n if (existingPrimaryAddress) {\n await params.commandBus.execute('customers.addresses.update', {\n input: {\n id: existingPrimaryAddress.id,\n isPrimary: true,\n ...(params.addressValues.name ? { name: params.addressValues.name } : {}),\n ...(params.addressValues.purpose ? { purpose: params.addressValues.purpose } : {}),\n ...(params.addressValues.companyName ? { companyName: params.addressValues.companyName } : {}),\n ...(params.addressValues.addressLine1 ? { addressLine1: params.addressValues.addressLine1 } : {}),\n ...(params.addressValues.addressLine2 ? { addressLine2: params.addressValues.addressLine2 } : {}),\n ...(params.addressValues.buildingNumber ? { buildingNumber: params.addressValues.buildingNumber } : {}),\n ...(params.addressValues.flatNumber ? { flatNumber: params.addressValues.flatNumber } : {}),\n ...(params.addressValues.city ? { city: params.addressValues.city } : {}),\n ...(params.addressValues.region ? { region: params.addressValues.region } : {}),\n ...(params.addressValues.postalCode ? { postalCode: params.addressValues.postalCode } : {}),\n ...(params.addressValues.country ? { country: params.addressValues.country } : {}),\n ...(params.addressValues.latitude !== null && params.addressValues.latitude !== undefined\n ? { latitude: params.addressValues.latitude }\n : {}),\n ...(params.addressValues.longitude !== null && params.addressValues.longitude !== undefined\n ? { longitude: params.addressValues.longitude }\n : {}),\n },\n ctx: params.commandContext,\n })\n return\n }\n\n const createInput = buildAddressCreateInput(params.addressValues, params.entityId, params.scope)\n if (!createInput) return\n\n await params.commandBus.execute('customers.addresses.create', {\n input: createInput,\n ctx: params.commandContext,\n })\n}\n\nasync function loadStoredMapping(em: EntityManager, entityType: string, scope: TenantScope): Promise<DataMapping> {\n const stored = await findOneWithDecryption(\n em,\n SyncMapping,\n {\n integrationId: 'sync_excel',\n entityType,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n undefined,\n scope,\n )\n\n if (stored?.mapping && typeof stored.mapping === 'object') {\n return stored.mapping as unknown as DataMapping\n }\n\n return {\n entityType,\n fields: [],\n matchStrategy: 'custom',\n }\n}\n\nasync function resolveUpload(em: EntityManager, runId: string | undefined, cursor: SyncExcelCursor | null, scope: TenantScope): Promise<SyncExcelUpload | null> {\n if (runId) {\n const uploadByRun = await findOneWithDecryption(\n em,\n SyncExcelUpload,\n {\n syncRunId: runId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n undefined,\n scope,\n )\n if (uploadByRun) return uploadByRun\n }\n\n if (cursor?.uploadId) {\n return findOneWithDecryption(\n em,\n SyncExcelUpload,\n {\n id: cursor.uploadId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n undefined,\n scope,\n )\n }\n\n return null\n}\n\nasync function resolveExistingPersonId(params: {\n externalIdMappingService: ExternalIdMappingService\n externalId: string | null | undefined\n email: string | null | undefined\n emailDedupeIndex: EmailDedupeIndex\n scope: TenantScope\n}): Promise<string | null> {\n if (params.externalId) {\n const mappedLocalId = await params.externalIdMappingService.lookupLocalId(\n 'sync_excel',\n 'customers.person',\n params.externalId,\n params.scope,\n )\n if (mappedLocalId) return mappedLocalId\n }\n\n if (!params.email) return null\n return params.emailDedupeIndex.get(params.email) ?? null\n}\n\nfunction mappingHasEmailField(mapping: DataMapping): boolean {\n return mapping.fields.some((field) => field.localField === 'person.primaryEmail')\n}\n\nasync function buildEmailDedupeIndex(params: {\n em: EntityManager\n mapping: DataMapping\n scope: TenantScope\n}): Promise<EmailDedupeIndex> {\n if (!mappingHasEmailField(params.mapping)) return new Map()\n\n const candidates = await findWithDecryption(\n params.em,\n CustomerEntity,\n {\n kind: 'person',\n organizationId: params.scope.organizationId,\n tenantId: params.scope.tenantId,\n deletedAt: null,\n isActive: true,\n },\n {\n orderBy: {\n createdAt: 'asc',\n },\n },\n params.scope,\n )\n\n const index: EmailDedupeIndex = new Map()\n for (const candidate of candidates) {\n const email = normalizeEmail(candidate.primaryEmail)\n if (!email || index.has(email)) continue\n index.set(email, candidate.id)\n }\n return index\n}\n\nfunction isEmptyRow(row: CsvPreviewRow): boolean {\n return !Object.values(row).some((value) => normalizeOptionalString(value))\n}\n\nasync function processRow(params: {\n row: CsvPreviewRow\n rowNumber: number\n mapping: DataMapping\n scope: TenantScope\n commandBus: CommandBus\n commandContext: CommandRuntimeContext\n externalIdMappingService: ExternalIdMappingService\n emailDedupeIndex: EmailDedupeIndex\n customFieldDefinitions: Map<string, ImportCustomFieldDefinition>\n em: EntityManager\n}): Promise<ImportItem> {\n if (isEmptyRow(params.row)) {\n return {\n externalId: `row:${params.rowNumber}`,\n action: 'skip',\n data: {\n rowNumber: params.rowNumber,\n reason: 'empty_row',\n },\n }\n }\n\n const payload = buildPersonPayload(params.row, params.mapping, params.scope)\n const externalId = payload.values.externalId ?? null\n const sourceIdentifier = payload.sourceIdentifier ?? `row:${params.rowNumber}`\n const customFieldResult = coerceCustomFields(payload.customFields, params.customFieldDefinitions)\n if (!customFieldResult.ok) {\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'failed',\n data: {\n rowNumber: params.rowNumber,\n sourceIdentifier,\n errorMessage: customFieldResult.error,\n },\n }\n }\n const existingId = await resolveExistingPersonId({\n externalIdMappingService: params.externalIdMappingService,\n externalId,\n email: payload.values.primaryEmail,\n emailDedupeIndex: params.emailDedupeIndex,\n scope: params.scope,\n })\n\n if (!existingId && !payload.createInput) {\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'failed',\n data: {\n rowNumber: params.rowNumber,\n sourceIdentifier,\n errorMessage: 'Import row is missing a usable person name mapping.',\n },\n }\n }\n\n try {\n if (existingId) {\n const updateInput = {\n id: existingId,\n ...payload.updateInput,\n ...(Object.keys(customFieldResult.values).length > 0 ? { customFields: customFieldResult.values } : {}),\n }\n await params.commandBus.execute('customers.people.update', {\n input: updateInput,\n ctx: params.commandContext,\n })\n\n await upsertPrimaryAddress({\n entityId: existingId,\n addressValues: payload.addressValues,\n scope: params.scope,\n commandBus: params.commandBus,\n commandContext: params.commandContext,\n em: params.em,\n })\n\n if (externalId) {\n await params.externalIdMappingService.storeExternalIdMapping(\n 'sync_excel',\n 'customers.person',\n existingId,\n externalId,\n params.scope,\n )\n }\n if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {\n params.emailDedupeIndex.set(payload.values.primaryEmail, existingId)\n }\n\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'update',\n data: {\n localId: existingId,\n rowNumber: params.rowNumber,\n sourceIdentifier,\n },\n }\n }\n\n const commandResult = await params.commandBus.execute<\n NonNullable<BuiltPersonPayload['createInput']>,\n { entityId: string; personId: string }\n >('customers.people.create', {\n input: {\n ...payload.createInput!,\n ...(Object.keys(customFieldResult.values).length > 0 ? { customFields: customFieldResult.values } : {}),\n },\n ctx: params.commandContext,\n })\n\n await upsertPrimaryAddress({\n entityId: commandResult.result.entityId,\n addressValues: payload.addressValues,\n scope: params.scope,\n commandBus: params.commandBus,\n commandContext: params.commandContext,\n em: params.em,\n })\n\n if (externalId) {\n await params.externalIdMappingService.storeExternalIdMapping(\n 'sync_excel',\n 'customers.person',\n commandResult.result.entityId,\n externalId,\n params.scope,\n )\n }\n if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {\n params.emailDedupeIndex.set(payload.values.primaryEmail, commandResult.result.entityId)\n }\n\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'create',\n data: {\n localId: commandResult.result.entityId,\n personId: commandResult.result.personId,\n rowNumber: params.rowNumber,\n sourceIdentifier,\n },\n }\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Import row failed'\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'failed',\n data: {\n rowNumber: params.rowNumber,\n sourceIdentifier,\n errorMessage,\n },\n }\n }\n}\n\nexport const syncExcelCustomersAdapter: DataSyncAdapter = {\n providerKey: 'excel',\n runMode: 'provider',\n operationalTelemetry: true,\n direction: 'import',\n supportedEntities: ['customers.person'],\n\n async getMapping(input): Promise<DataMapping> {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n return loadStoredMapping(em, input.entityType, input.scope)\n },\n\n async *streamImport(input): AsyncIterable<ImportBatch> {\n if (input.entityType !== 'customers.person') {\n throw new Error(`Unsupported sync_excel entity type: ${input.entityType}`)\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const commandBus = container.resolve('commandBus') as CommandBus\n const externalIdMappingService = container.resolve('externalIdMappingService') as ExternalIdMappingService\n const cursor = parseCursor(input.cursor)\n const upload = await resolveUpload(em, input.runId, cursor, input.scope)\n\n if (!upload) {\n throw new Error('CSV upload session was not found for this sync run.')\n }\n\n if (input.runId) {\n upload.syncRunId = input.runId\n }\n upload.status = 'importing'\n await em.flush()\n\n try {\n const attachment = await findOneWithDecryption(\n em,\n Attachment,\n {\n id: upload.attachmentId,\n organizationId: input.scope.organizationId,\n tenantId: input.scope.tenantId,\n },\n undefined,\n input.scope,\n )\n\n if (!attachment) {\n throw new Error('CSV upload attachment could not be found.')\n }\n\n const document = await parseCsvStreamMetadata(await createSyncExcelUploadReadStream(attachment))\n const startOffset = cursor?.uploadId === upload.id ? cursor.offset : 0\n const commandContext = buildCommandContext(container, input.scope)\n const customFieldDefinitions = await loadImportCustomFieldDefinitions(em, input.scope)\n const emailDedupeIndex = await buildEmailDedupeIndex({\n em,\n mapping: input.mapping,\n scope: input.scope,\n })\n let batchIndex = 0\n\n for await (const batch of parseCsvDocumentBatches(await createSyncExcelUploadReadStream(attachment), {\n batchSize: input.batchSize,\n startOffset,\n })) {\n const batchRows = batch.rows\n const items: ImportItem[] = []\n\n for (let index = 0; index < batchRows.length; index += 1) {\n // Above the yield, so an abandoned page is never yielded and its cursor never committed \u2014\n // the rows applied so far are re-applied on resume, which the replay-safety contract on\n // `streamImport` already requires. Each row goes through the command bus, so a large page\n // is exactly the case where the operator would otherwise wait out the whole batch.\n if (input.signal?.aborted) return\n items.push(await processRow({\n row: batchRows[index],\n rowNumber: batch.rowStart + index + 1,\n mapping: input.mapping,\n scope: input.scope,\n commandBus,\n commandContext,\n externalIdMappingService,\n emailDedupeIndex,\n customFieldDefinitions,\n em,\n }))\n }\n\n yield {\n items,\n cursor: createCursor(upload.id, batch.nextOffset),\n hasMore: batch.nextOffset < document.totalRows,\n totalEstimate: document.totalRows,\n processedCount: batchRows.length,\n refreshCoverageEntityTypes: CUSTOMER_IMPORT_COVERAGE_ENTITY_TYPES,\n batchIndex,\n message: `Processed ${batch.nextOffset} of ${document.totalRows} CSV rows`,\n }\n batchIndex += 1\n }\n\n upload.status = 'completed'\n await em.flush()\n } catch (error) {\n upload.status = 'failed'\n await em.flush()\n throw error\n }\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'\nimport type {\n DataMapping,\n DataSyncAdapter,\n FieldMapping,\n ImportBatch,\n ImportItem,\n TenantScope,\n} from '../../../data_sync/lib/adapter'\nimport type { ExternalIdMappingService } from '../../../data_sync/lib/id-mapping'\nimport { SyncMapping } from '../../../data_sync/data/entities'\nimport { CustomerAddress, CustomerEntity } from '../../../customers/data/entities'\nimport { Attachment } from '../../../attachments/data/entities'\nimport { CustomFieldDef } from '../../../entities/data/entities'\nimport { SyncExcelUpload } from '../../data/entities'\nimport { parseCsvDocumentBatches, parseCsvStreamMetadata, type CsvPreviewRow } from '../parser'\nimport { createSyncExcelUploadReadStream } from '../upload-storage'\nimport { E } from '#generated/entities.ids.generated'\n\ntype SyncExcelCursor = {\n uploadId: string\n offset: number\n}\n\ntype Container = Awaited<ReturnType<typeof createRequestContainer>>\n\nconst CUSTOMER_IMPORT_COVERAGE_ENTITY_TYPES = [\n E.customers.customer_entity,\n E.customers.customer_person_profile,\n E.customers.customer_address,\n]\n\ntype PersonFieldValues = {\n externalId?: string | null\n firstName?: string | null\n lastName?: string | null\n displayName?: string | null\n primaryEmail?: string | null\n primaryPhone?: string | null\n jobTitle?: string | null\n status?: string | null\n source?: string | null\n description?: string | null\n}\n\ntype AddressFieldValues = {\n name?: string | null\n purpose?: string | null\n companyName?: string | null\n addressLine1?: string | null\n addressLine2?: string | null\n buildingNumber?: string | null\n flatNumber?: string | null\n city?: string | null\n region?: string | null\n postalCode?: string | null\n country?: string | null\n latitude?: number | null\n longitude?: number | null\n}\n\ntype PersonRowValues = {\n values: PersonFieldValues\n customFields: Record<string, unknown>\n addressValues: AddressFieldValues\n}\n\ntype ImportCustomFieldDefinition = {\n key: string\n kind: string\n entityId: string\n organizationId?: string | null\n tenantId?: string | null\n updatedAt?: Date | string | null\n}\n\ntype EmailDedupeIndex = Map<string, string>\n\ntype BuiltPersonPayload = {\n values: PersonFieldValues\n customFields: Record<string, unknown>\n addressValues: AddressFieldValues\n createInput: {\n organizationId: string\n tenantId: string\n firstName: string\n lastName: string\n displayName: string\n primaryEmail?: string\n primaryPhone?: string\n jobTitle?: string\n status?: string\n source?: string\n description?: string\n customFields?: Record<string, unknown>\n } | null\n updateInput: {\n organizationId: string\n tenantId: string\n primaryEmail?: string\n primaryPhone?: string\n jobTitle?: string\n status?: string\n source?: string\n description?: string\n firstName?: string\n lastName?: string\n displayName?: string\n customFields?: Record<string, unknown>\n }\n sourceIdentifier: string | null\n}\n\nfunction normalizeOptionalString(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nfunction normalizeEmail(value: unknown): string | null {\n const normalized = normalizeOptionalString(value)\n return normalized ? normalized.toLowerCase() : null\n}\n\nconst COUNTRY_DIAL_CODES: Record<string, string> = {\n canada: '1',\n england: '44',\n greatbritain: '44',\n poland: '48',\n scotland: '44',\n uk: '44',\n unitedkingdom: '44',\n unitedstates: '1',\n unitedstatesofamerica: '1',\n usa: '1',\n wales: '44',\n}\n\nfunction normalizeCountryKey(value: unknown): string | null {\n const normalized = normalizeOptionalString(value)\n if (!normalized) return null\n return normalized.toLowerCase().replace(/[^a-z]/g, '')\n}\n\nfunction resolveCountryDialCode(row: CsvPreviewRow): string | null {\n const countryCandidates = [\n row.Country,\n row.country,\n row['Country/Region'],\n row['country/region'],\n ]\n\n for (const candidate of countryCandidates) {\n const countryKey = normalizeCountryKey(candidate)\n if (!countryKey) continue\n const dialCode = COUNTRY_DIAL_CODES[countryKey]\n if (dialCode) return dialCode\n }\n\n return null\n}\n\nfunction stripPhoneExtension(value: string): string {\n return value\n .replace(/\\s*(ext\\.?|extension|x)\\s*\\d+$/i, '')\n .replace(/^\\+\\s+/, '+')\n .replace(/\\s+/g, ' ')\n .trim()\n}\n\nfunction normalizeImportedPhone(value: unknown, row: CsvPreviewRow): string | null {\n const normalized = normalizeOptionalString(value)\n if (!normalized) return null\n\n const directValidation = validatePhoneNumber(normalized)\n if (directValidation.valid) {\n return directValidation.normalized\n }\n\n const sanitized = stripPhoneExtension(normalized)\n const sanitizedValidation = validatePhoneNumber(sanitized)\n if (sanitizedValidation.valid) {\n return sanitizedValidation.normalized\n }\n\n const digits = extractPhoneDigits(sanitized)\n if (!digits) return null\n\n if (digits.length >= 9 && digits.length <= 15 && sanitized.startsWith('00')) {\n const internationalCandidate = `+${digits.slice(2)}`\n const internationalValidation = validatePhoneNumber(internationalCandidate)\n if (internationalValidation.valid) {\n return internationalValidation.normalized\n }\n }\n\n const dialCode = resolveCountryDialCode(row)\n if (!dialCode) return null\n\n let nationalDigits = digits\n if (nationalDigits.startsWith(dialCode)) {\n nationalDigits = nationalDigits.slice(dialCode.length)\n } else {\n nationalDigits = nationalDigits.replace(/^0+/, '')\n }\n\n if (!nationalDigits) return null\n\n const localizedCandidate = `+${dialCode}${nationalDigits}`\n const localizedValidation = validatePhoneNumber(localizedCandidate)\n return localizedValidation.valid ? localizedValidation.normalized : null\n}\n\nfunction normalizeOptionalNumber(value: unknown): number | null {\n if (typeof value === 'number') {\n return Number.isFinite(value) ? value : null\n }\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (trimmed.length === 0) return null\n const parsed = Number(trimmed)\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction normalizeCustomFieldDate(value: string): string | null {\n const parsed = new Date(value)\n if (Number.isNaN(parsed.getTime())) return null\n return parsed.toISOString().slice(0, 10)\n}\n\nfunction normalizeCustomFieldBoolean(value: string): boolean | null {\n const normalized = value.trim().toLowerCase()\n if (['true', '1', 'yes', 'y', 't', 'tak'].includes(normalized)) return true\n if (['false', '0', 'no', 'n', 'f', 'nie'].includes(normalized)) return false\n return null\n}\n\nfunction coerceCustomFieldValue(\n key: string,\n value: unknown,\n definitions: Map<string, ImportCustomFieldDefinition>,\n): { ok: true; value: unknown } | { ok: false; error: string } {\n if (typeof value === 'string' && value.trim().length === 0) {\n return { ok: true, value: null }\n }\n\n const definition = definitions.get(key)\n if (!definition) {\n return { ok: true, value: typeof value === 'string' ? value.trim() : value }\n }\n\n if (value === null || value === undefined) {\n return { ok: true, value: null }\n }\n\n const text = typeof value === 'string' ? value.trim() : String(value)\n if (text.length === 0) {\n return { ok: true, value: null }\n }\n\n const kind = definition.kind.toLowerCase()\n if (kind === 'boolean') {\n const booleanValue = normalizeCustomFieldBoolean(text)\n if (booleanValue === null) {\n return { ok: false, error: `Custom field \"${key}\" expects a boolean value.` }\n }\n return { ok: true, value: booleanValue }\n }\n\n if (kind === 'integer') {\n const numberValue = Number(text)\n if (!Number.isInteger(numberValue)) {\n return { ok: false, error: `Custom field \"${key}\" expects an integer value.` }\n }\n return { ok: true, value: numberValue }\n }\n\n if (kind === 'float' || kind === 'currency' || kind === 'number') {\n const numberValue = Number(text)\n if (!Number.isFinite(numberValue)) {\n return { ok: false, error: `Custom field \"${key}\" expects a number value.` }\n }\n return { ok: true, value: numberValue }\n }\n\n if (kind === 'date' || kind === 'datetime') {\n const dateValue = normalizeCustomFieldDate(text)\n if (!dateValue) {\n return { ok: false, error: `Custom field \"${key}\" expects a date value.` }\n }\n return { ok: true, value: dateValue }\n }\n\n return { ok: true, value: text }\n}\n\nfunction coerceCustomFields(\n values: Record<string, unknown>,\n definitions: Map<string, ImportCustomFieldDefinition>,\n): { ok: true; values: Record<string, unknown> } | { ok: false; error: string } {\n const coerced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(values)) {\n const result = coerceCustomFieldValue(key, value, definitions)\n if (!result.ok) return result\n coerced[key] = result.value\n }\n return { ok: true, values: coerced }\n}\n\nfunction customFieldDefinitionScopeScore(definition: ImportCustomFieldDefinition): number {\n return (definition.tenantId ? 2 : 0) + (definition.organizationId ? 1 : 0)\n}\n\nfunction customFieldDefinitionUpdatedAt(definition: ImportCustomFieldDefinition): number {\n if (!definition.updatedAt) return 0\n const value = definition.updatedAt instanceof Date\n ? definition.updatedAt.getTime()\n : new Date(definition.updatedAt).getTime()\n return Number.isFinite(value) ? value : 0\n}\n\nfunction preferCustomFieldDefinition(\n current: ImportCustomFieldDefinition | undefined,\n candidate: ImportCustomFieldDefinition,\n): boolean {\n if (!current) return true\n const candidateScore = customFieldDefinitionScopeScore(candidate)\n const currentScore = customFieldDefinitionScopeScore(current)\n if (candidateScore !== currentScore) return candidateScore > currentScore\n return customFieldDefinitionUpdatedAt(candidate) >= customFieldDefinitionUpdatedAt(current)\n}\n\nasync function loadImportCustomFieldDefinitions(\n em: EntityManager,\n scope: TenantScope,\n): Promise<Map<string, ImportCustomFieldDefinition>> {\n const definitions = await findWithDecryption(\n em,\n CustomFieldDef,\n {\n entityId: { $in: [E.customers.customer_entity, E.customers.customer_person_profile] as string[] },\n deletedAt: null,\n isActive: true,\n $and: [\n { $or: [{ tenantId: scope.tenantId }, { tenantId: null }] },\n { $or: [{ organizationId: scope.organizationId }, { organizationId: null }] },\n ],\n },\n undefined,\n scope,\n )\n\n const byKey = new Map<string, ImportCustomFieldDefinition>()\n for (const definition of definitions) {\n const candidate: ImportCustomFieldDefinition = {\n key: definition.key,\n kind: definition.kind,\n entityId: definition.entityId,\n organizationId: definition.organizationId ?? null,\n tenantId: definition.tenantId ?? null,\n updatedAt: definition.updatedAt ?? null,\n }\n if (preferCustomFieldDefinition(byKey.get(candidate.key), candidate)) {\n byKey.set(candidate.key, candidate)\n }\n }\n return byKey\n}\n\nfunction buildCommandContext(container: Container, scope: TenantScope): CommandRuntimeContext {\n return {\n container,\n auth: null,\n organizationScope: {\n selectedId: scope.organizationId,\n filterIds: [scope.organizationId],\n allowedIds: [scope.organizationId],\n tenantId: scope.tenantId,\n },\n selectedOrganizationId: scope.organizationId,\n organizationIds: [scope.organizationId],\n }\n}\n\nexport function createCursor(uploadId: string, offset: number): string {\n return JSON.stringify({\n uploadId,\n offset,\n })\n}\n\nexport function parseCursor(value: string | null | undefined): SyncExcelCursor | null {\n if (!value) return null\n try {\n const parsed = JSON.parse(value) as Partial<SyncExcelCursor> | null\n if (!parsed || typeof parsed !== 'object') return null\n if (typeof parsed.uploadId !== 'string' || parsed.uploadId.trim().length === 0) return null\n if (typeof parsed.offset !== 'number' || !Number.isFinite(parsed.offset) || parsed.offset < 0) return null\n return {\n uploadId: parsed.uploadId,\n offset: parsed.offset,\n }\n } catch {\n return null\n }\n}\n\nfunction mapRowValues(row: CsvPreviewRow, fields: FieldMapping[]): PersonRowValues {\n const values: PersonFieldValues = {}\n const customFields: Record<string, unknown> = {}\n const addressValues: AddressFieldValues = {}\n\n for (const field of fields) {\n if (field.mappingKind === 'ignore') continue\n const rawValue = row[field.externalField]\n if (rawValue === undefined || rawValue === null) continue\n if (field.mappingKind === 'custom_field' || field.localField.startsWith('cf:')) {\n const customFieldKey = field.localField.startsWith('cf:') ? field.localField.slice(3) : field.localField\n if (customFieldKey.trim().length > 0) {\n customFields[customFieldKey] = rawValue\n }\n continue\n }\n\n if (field.localField === 'person.externalId') {\n values.externalId = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.firstName') {\n values.firstName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.lastName') {\n values.lastName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.displayName') {\n values.displayName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.primaryEmail') {\n values.primaryEmail = normalizeEmail(rawValue)\n continue\n }\n if (field.localField === 'person.primaryPhone') {\n values.primaryPhone = normalizeImportedPhone(rawValue, row)\n continue\n }\n if (field.localField === 'person.jobTitle') {\n values.jobTitle = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.status') {\n values.status = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.source') {\n values.source = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'person.description') {\n values.description = normalizeOptionalString(rawValue)\n continue\n }\n\n if (field.localField === 'address.name') {\n addressValues.name = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.purpose') {\n addressValues.purpose = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.companyName') {\n addressValues.companyName = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.addressLine1') {\n addressValues.addressLine1 = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.addressLine2') {\n addressValues.addressLine2 = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.buildingNumber') {\n addressValues.buildingNumber = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.flatNumber') {\n addressValues.flatNumber = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.city') {\n addressValues.city = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.region') {\n addressValues.region = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.postalCode') {\n addressValues.postalCode = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.country') {\n addressValues.country = normalizeOptionalString(rawValue)\n continue\n }\n if (field.localField === 'address.latitude') {\n addressValues.latitude = normalizeOptionalNumber(rawValue)\n continue\n }\n if (field.localField === 'address.longitude') {\n addressValues.longitude = normalizeOptionalNumber(rawValue)\n }\n }\n\n return {\n values,\n customFields,\n addressValues,\n }\n}\n\nfunction derivePersonNames(values: PersonFieldValues): { firstName: string; lastName: string; displayName: string } | null {\n const firstName = values.firstName ?? null\n const lastName = values.lastName ?? null\n const explicitDisplayName = values.displayName ?? null\n\n if (firstName && lastName) {\n return {\n firstName,\n lastName,\n displayName: explicitDisplayName ?? `${firstName} ${lastName}`.trim(),\n }\n }\n\n if (explicitDisplayName) {\n const parts = explicitDisplayName.split(/\\s+/).filter((part) => part.length > 0)\n if (parts.length >= 2) {\n return {\n firstName: firstName ?? parts.slice(0, -1).join(' '),\n lastName: lastName ?? parts.at(-1) ?? explicitDisplayName,\n displayName: explicitDisplayName,\n }\n }\n return {\n firstName: firstName ?? explicitDisplayName,\n lastName: lastName ?? explicitDisplayName,\n displayName: explicitDisplayName,\n }\n }\n\n return null\n}\n\nexport function buildPersonPayload(row: CsvPreviewRow, mapping: DataMapping, scope: TenantScope): BuiltPersonPayload {\n const { values, customFields, addressValues } = mapRowValues(row, mapping.fields)\n const derivedNames = derivePersonNames(values)\n const sourceIdentifier = values.externalId ?? values.primaryEmail ?? values.displayName ?? null\n\n const updateInput: BuiltPersonPayload['updateInput'] = {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n }\n\n if (values.primaryEmail) updateInput.primaryEmail = values.primaryEmail\n if (values.primaryPhone) updateInput.primaryPhone = values.primaryPhone\n if (values.jobTitle) updateInput.jobTitle = values.jobTitle\n if (values.status) updateInput.status = values.status\n if (values.source) updateInput.source = values.source\n if (values.description) updateInput.description = values.description\n if (derivedNames?.firstName) updateInput.firstName = derivedNames.firstName\n if (derivedNames?.lastName) updateInput.lastName = derivedNames.lastName\n if (derivedNames?.displayName) updateInput.displayName = derivedNames.displayName\n\n if (!derivedNames) {\n return {\n values,\n customFields,\n addressValues,\n createInput: null,\n updateInput,\n sourceIdentifier,\n }\n }\n\n return {\n values,\n customFields,\n addressValues,\n createInput: {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n firstName: derivedNames.firstName,\n lastName: derivedNames.lastName,\n displayName: derivedNames.displayName,\n ...(values.primaryEmail ? { primaryEmail: values.primaryEmail } : {}),\n ...(values.primaryPhone ? { primaryPhone: values.primaryPhone } : {}),\n ...(values.jobTitle ? { jobTitle: values.jobTitle } : {}),\n ...(values.status ? { status: values.status } : {}),\n ...(values.source ? { source: values.source } : {}),\n ...(values.description ? { description: values.description } : {}),\n },\n updateInput,\n sourceIdentifier,\n }\n}\n\nfunction hasAddressValues(addressValues: AddressFieldValues): boolean {\n return Object.values(addressValues).some((value) => value !== null && value !== undefined)\n}\n\nfunction buildAddressCreateInput(addressValues: AddressFieldValues, entityId: string, scope: TenantScope) {\n if (!addressValues.addressLine1) return null\n\n return {\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n entityId,\n addressLine1: addressValues.addressLine1,\n isPrimary: true,\n ...(addressValues.name ? { name: addressValues.name } : {}),\n ...(addressValues.purpose ? { purpose: addressValues.purpose } : {}),\n ...(addressValues.companyName ? { companyName: addressValues.companyName } : {}),\n ...(addressValues.addressLine2 ? { addressLine2: addressValues.addressLine2 } : {}),\n ...(addressValues.buildingNumber ? { buildingNumber: addressValues.buildingNumber } : {}),\n ...(addressValues.flatNumber ? { flatNumber: addressValues.flatNumber } : {}),\n ...(addressValues.city ? { city: addressValues.city } : {}),\n ...(addressValues.region ? { region: addressValues.region } : {}),\n ...(addressValues.postalCode ? { postalCode: addressValues.postalCode } : {}),\n ...(addressValues.country ? { country: addressValues.country } : {}),\n ...(addressValues.latitude !== null && addressValues.latitude !== undefined ? { latitude: addressValues.latitude } : {}),\n ...(addressValues.longitude !== null && addressValues.longitude !== undefined ? { longitude: addressValues.longitude } : {}),\n }\n}\n\nasync function upsertPrimaryAddress(params: {\n entityId: string\n addressValues: AddressFieldValues\n scope: TenantScope\n commandBus: CommandBus\n commandContext: CommandRuntimeContext\n em: EntityManager\n}): Promise<void> {\n if (!hasAddressValues(params.addressValues)) return\n if (!params.addressValues.addressLine1) return\n\n const [existingPrimaryAddress] = await findWithDecryption(\n params.em,\n CustomerAddress,\n {\n entity: params.entityId,\n organizationId: params.scope.organizationId,\n tenantId: params.scope.tenantId,\n isPrimary: true,\n },\n {\n orderBy: {\n createdAt: 'asc',\n },\n limit: 1,\n },\n params.scope,\n )\n\n if (existingPrimaryAddress) {\n await params.commandBus.execute('customers.addresses.update', {\n input: {\n id: existingPrimaryAddress.id,\n isPrimary: true,\n ...(params.addressValues.name ? { name: params.addressValues.name } : {}),\n ...(params.addressValues.purpose ? { purpose: params.addressValues.purpose } : {}),\n ...(params.addressValues.companyName ? { companyName: params.addressValues.companyName } : {}),\n ...(params.addressValues.addressLine1 ? { addressLine1: params.addressValues.addressLine1 } : {}),\n ...(params.addressValues.addressLine2 ? { addressLine2: params.addressValues.addressLine2 } : {}),\n ...(params.addressValues.buildingNumber ? { buildingNumber: params.addressValues.buildingNumber } : {}),\n ...(params.addressValues.flatNumber ? { flatNumber: params.addressValues.flatNumber } : {}),\n ...(params.addressValues.city ? { city: params.addressValues.city } : {}),\n ...(params.addressValues.region ? { region: params.addressValues.region } : {}),\n ...(params.addressValues.postalCode ? { postalCode: params.addressValues.postalCode } : {}),\n ...(params.addressValues.country ? { country: params.addressValues.country } : {}),\n ...(params.addressValues.latitude !== null && params.addressValues.latitude !== undefined\n ? { latitude: params.addressValues.latitude }\n : {}),\n ...(params.addressValues.longitude !== null && params.addressValues.longitude !== undefined\n ? { longitude: params.addressValues.longitude }\n : {}),\n },\n ctx: params.commandContext,\n })\n return\n }\n\n const createInput = buildAddressCreateInput(params.addressValues, params.entityId, params.scope)\n if (!createInput) return\n\n await params.commandBus.execute('customers.addresses.create', {\n input: createInput,\n ctx: params.commandContext,\n })\n}\n\nasync function loadStoredMapping(em: EntityManager, entityType: string, scope: TenantScope): Promise<DataMapping> {\n const stored = await findOneWithDecryption(\n em,\n SyncMapping,\n {\n integrationId: 'sync_excel',\n entityType,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n undefined,\n scope,\n )\n\n if (stored?.mapping && typeof stored.mapping === 'object') {\n return stored.mapping as unknown as DataMapping\n }\n\n return {\n entityType,\n fields: [],\n matchStrategy: 'custom',\n }\n}\n\nasync function resolveUpload(em: EntityManager, runId: string | undefined, cursor: SyncExcelCursor | null, scope: TenantScope): Promise<SyncExcelUpload | null> {\n if (runId) {\n const uploadByRun = await findOneWithDecryption(\n em,\n SyncExcelUpload,\n {\n syncRunId: runId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n undefined,\n scope,\n )\n if (uploadByRun) return uploadByRun\n }\n\n if (cursor?.uploadId) {\n return findOneWithDecryption(\n em,\n SyncExcelUpload,\n {\n id: cursor.uploadId,\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n },\n undefined,\n scope,\n )\n }\n\n return null\n}\n\nasync function personExists(em: EntityManager, personId: string, scope: TenantScope): Promise<boolean> {\n const matches = await em.count(CustomerEntity, {\n id: personId,\n kind: 'person',\n organizationId: scope.organizationId,\n tenantId: scope.tenantId,\n deletedAt: null,\n })\n return matches > 0\n}\n\nasync function resolveExistingPersonId(params: {\n em: EntityManager\n externalIdMappingService: ExternalIdMappingService\n externalId: string | null | undefined\n email: string | null | undefined\n emailDedupeIndex: EmailDedupeIndex\n scope: TenantScope\n}): Promise<string | null> {\n if (params.externalId) {\n const mappedLocalId = await params.externalIdMappingService.lookupLocalId(\n 'sync_excel',\n 'customers.person',\n params.externalId,\n params.scope,\n )\n if (mappedLocalId && await personExists(params.em, mappedLocalId, params.scope)) return mappedLocalId\n }\n\n if (!params.email) return null\n return params.emailDedupeIndex.get(params.email) ?? null\n}\n\nfunction mappingHasEmailField(mapping: DataMapping): boolean {\n return mapping.fields.some((field) => field.localField === 'person.primaryEmail')\n}\n\nasync function buildEmailDedupeIndex(params: {\n em: EntityManager\n mapping: DataMapping\n scope: TenantScope\n}): Promise<EmailDedupeIndex> {\n if (!mappingHasEmailField(params.mapping)) return new Map()\n\n const candidates = await findWithDecryption(\n params.em,\n CustomerEntity,\n {\n kind: 'person',\n organizationId: params.scope.organizationId,\n tenantId: params.scope.tenantId,\n deletedAt: null,\n isActive: true,\n },\n {\n orderBy: {\n createdAt: 'asc',\n },\n },\n params.scope,\n )\n\n const index: EmailDedupeIndex = new Map()\n for (const candidate of candidates) {\n const email = normalizeEmail(candidate.primaryEmail)\n if (!email || index.has(email)) continue\n index.set(email, candidate.id)\n }\n return index\n}\n\nfunction isEmptyRow(row: CsvPreviewRow): boolean {\n return !Object.values(row).some((value) => normalizeOptionalString(value))\n}\n\nasync function processRow(params: {\n row: CsvPreviewRow\n rowNumber: number\n mapping: DataMapping\n scope: TenantScope\n commandBus: CommandBus\n commandContext: CommandRuntimeContext\n externalIdMappingService: ExternalIdMappingService\n emailDedupeIndex: EmailDedupeIndex\n customFieldDefinitions: Map<string, ImportCustomFieldDefinition>\n em: EntityManager\n}): Promise<ImportItem> {\n if (isEmptyRow(params.row)) {\n return {\n externalId: `row:${params.rowNumber}`,\n action: 'skip',\n data: {\n rowNumber: params.rowNumber,\n reason: 'empty_row',\n },\n }\n }\n\n const payload = buildPersonPayload(params.row, params.mapping, params.scope)\n const externalId = payload.values.externalId ?? null\n const sourceIdentifier = payload.sourceIdentifier ?? `row:${params.rowNumber}`\n const customFieldResult = coerceCustomFields(payload.customFields, params.customFieldDefinitions)\n if (!customFieldResult.ok) {\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'failed',\n data: {\n rowNumber: params.rowNumber,\n sourceIdentifier,\n errorMessage: customFieldResult.error,\n },\n }\n }\n const existingId = await resolveExistingPersonId({\n em: params.em,\n externalIdMappingService: params.externalIdMappingService,\n externalId,\n email: payload.values.primaryEmail,\n emailDedupeIndex: params.emailDedupeIndex,\n scope: params.scope,\n })\n\n if (!existingId && !payload.createInput) {\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'failed',\n data: {\n rowNumber: params.rowNumber,\n sourceIdentifier,\n errorMessage: 'Import row is missing a usable person name mapping.',\n },\n }\n }\n\n const rememberPersonIdentity = async (personId: string): Promise<void> => {\n if (externalId) {\n await params.externalIdMappingService.storeExternalIdMapping(\n 'sync_excel',\n 'customers.person',\n personId,\n externalId,\n params.scope,\n )\n }\n if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {\n params.emailDedupeIndex.set(payload.values.primaryEmail, personId)\n }\n }\n\n try {\n if (existingId) {\n const updateInput = {\n id: existingId,\n ...payload.updateInput,\n ...(Object.keys(customFieldResult.values).length > 0 ? { customFields: customFieldResult.values } : {}),\n }\n await params.commandBus.execute('customers.people.update', {\n input: updateInput,\n ctx: params.commandContext,\n })\n\n await rememberPersonIdentity(existingId)\n\n await upsertPrimaryAddress({\n entityId: existingId,\n addressValues: payload.addressValues,\n scope: params.scope,\n commandBus: params.commandBus,\n commandContext: params.commandContext,\n em: params.em,\n })\n\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'update',\n data: {\n localId: existingId,\n rowNumber: params.rowNumber,\n sourceIdentifier,\n },\n }\n }\n\n const commandResult = await params.commandBus.execute<\n NonNullable<BuiltPersonPayload['createInput']>,\n { entityId: string; personId: string }\n >('customers.people.create', {\n input: {\n ...payload.createInput!,\n ...(Object.keys(customFieldResult.values).length > 0 ? { customFields: customFieldResult.values } : {}),\n },\n ctx: params.commandContext,\n })\n\n await rememberPersonIdentity(commandResult.result.entityId)\n\n await upsertPrimaryAddress({\n entityId: commandResult.result.entityId,\n addressValues: payload.addressValues,\n scope: params.scope,\n commandBus: params.commandBus,\n commandContext: params.commandContext,\n em: params.em,\n })\n\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'create',\n data: {\n localId: commandResult.result.entityId,\n personId: commandResult.result.personId,\n rowNumber: params.rowNumber,\n sourceIdentifier,\n },\n }\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Import row failed'\n return {\n externalId: externalId ?? sourceIdentifier,\n action: 'failed',\n data: {\n rowNumber: params.rowNumber,\n sourceIdentifier,\n errorMessage,\n },\n }\n }\n}\n\nexport const syncExcelCustomersAdapter: DataSyncAdapter = {\n providerKey: 'excel',\n runMode: 'provider',\n operationalTelemetry: true,\n direction: 'import',\n supportedEntities: ['customers.person'],\n\n async getMapping(input): Promise<DataMapping> {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n return loadStoredMapping(em, input.entityType, input.scope)\n },\n\n async *streamImport(input): AsyncIterable<ImportBatch> {\n if (input.entityType !== 'customers.person') {\n throw new Error(`Unsupported sync_excel entity type: ${input.entityType}`)\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const commandBus = container.resolve('commandBus') as CommandBus\n const externalIdMappingService = container.resolve('externalIdMappingService') as ExternalIdMappingService\n const cursor = parseCursor(input.cursor)\n const upload = await resolveUpload(em, input.runId, cursor, input.scope)\n\n if (!upload) {\n throw new Error('CSV upload session was not found for this sync run.')\n }\n\n if (input.runId) {\n upload.syncRunId = input.runId\n }\n upload.status = 'importing'\n await em.flush()\n\n try {\n const attachment = await findOneWithDecryption(\n em,\n Attachment,\n {\n id: upload.attachmentId,\n organizationId: input.scope.organizationId,\n tenantId: input.scope.tenantId,\n },\n undefined,\n input.scope,\n )\n\n if (!attachment) {\n throw new Error('CSV upload attachment could not be found.')\n }\n\n const document = await parseCsvStreamMetadata(await createSyncExcelUploadReadStream(attachment))\n const startOffset = cursor?.uploadId === upload.id ? cursor.offset : 0\n const commandContext = buildCommandContext(container, input.scope)\n const customFieldDefinitions = await loadImportCustomFieldDefinitions(em, input.scope)\n const emailDedupeIndex = await buildEmailDedupeIndex({\n em,\n mapping: input.mapping,\n scope: input.scope,\n })\n let batchIndex = 0\n\n for await (const batch of parseCsvDocumentBatches(await createSyncExcelUploadReadStream(attachment), {\n batchSize: input.batchSize,\n startOffset,\n })) {\n const batchRows = batch.rows\n const items: ImportItem[] = []\n\n for (let index = 0; index < batchRows.length; index += 1) {\n // Above the yield, so an abandoned page is never yielded and its cursor never committed \u2014\n // the rows applied so far are re-applied on resume, which the replay-safety contract on\n // `streamImport` already requires. Each row goes through the command bus, so a large page\n // is exactly the case where the operator would otherwise wait out the whole batch.\n if (input.signal?.aborted) return\n items.push(await processRow({\n row: batchRows[index],\n rowNumber: batch.rowStart + index + 1,\n mapping: input.mapping,\n scope: input.scope,\n commandBus,\n commandContext,\n externalIdMappingService,\n emailDedupeIndex,\n customFieldDefinitions,\n em,\n }))\n }\n\n yield {\n items,\n cursor: createCursor(upload.id, batch.nextOffset),\n hasMore: batch.nextOffset < document.totalRows,\n totalEstimate: document.totalRows,\n processedCount: batchRows.length,\n refreshCoverageEntityTypes: CUSTOMER_IMPORT_COVERAGE_ENTITY_TYPES,\n batchIndex,\n message: `Processed ${batch.nextOffset} of ${document.totalRows} CSV rows`,\n }\n batchIndex += 1\n }\n\n upload.status = 'completed'\n await em.flush()\n } catch (error) {\n upload.status = 'failed'\n await em.flush()\n throw error\n }\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.8.1-develop.7448.1.94eec0a2d6",
3
+ "version": "0.8.1-develop.7450.1.77a915add3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -264,16 +264,16 @@
264
264
  "zod": "^4.4.3"
265
265
  },
266
266
  "peerDependencies": {
267
- "@open-mercato/ai-assistant": "0.8.1-develop.7448.1.94eec0a2d6",
268
- "@open-mercato/shared": "0.8.1-develop.7448.1.94eec0a2d6",
269
- "@open-mercato/ui": "0.8.1-develop.7448.1.94eec0a2d6",
267
+ "@open-mercato/ai-assistant": "0.8.1-develop.7450.1.77a915add3",
268
+ "@open-mercato/shared": "0.8.1-develop.7450.1.77a915add3",
269
+ "@open-mercato/ui": "0.8.1-develop.7450.1.77a915add3",
270
270
  "react": "^19.0.0",
271
271
  "react-dom": "^19.0.0"
272
272
  },
273
273
  "devDependencies": {
274
- "@open-mercato/ai-assistant": "0.8.1-develop.7448.1.94eec0a2d6",
275
- "@open-mercato/shared": "0.8.1-develop.7448.1.94eec0a2d6",
276
- "@open-mercato/ui": "0.8.1-develop.7448.1.94eec0a2d6",
274
+ "@open-mercato/ai-assistant": "0.8.1-develop.7450.1.77a915add3",
275
+ "@open-mercato/shared": "0.8.1-develop.7450.1.77a915add3",
276
+ "@open-mercato/ui": "0.8.1-develop.7450.1.77a915add3",
277
277
  "@testing-library/dom": "^10.4.1",
278
278
  "@testing-library/jest-dom": "^7.0.1",
279
279
  "@testing-library/react": "^16.3.3",
@@ -4,6 +4,8 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
6
6
  import { toAbsoluteUrl } from '@open-mercato/shared/lib/url'
7
+ import { createLogger } from '@open-mercato/shared/lib/logger'
8
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
7
9
  import { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'
8
10
  import { resolveOAuthClientCredentials } from '../../../../../lib/oauth-client-config'
9
11
  import {
@@ -16,6 +18,8 @@ import {
16
18
  OAuthStateError,
17
19
  } from '../../../../../lib/oauth-state'
18
20
 
21
+ const logger = createLogger('communication_channels').child({ component: 'oauth-initiate' })
22
+
19
23
  export const metadata = {
20
24
  path: '/communication_channels/oauth/[provider]/initiate',
21
25
  POST: {
@@ -56,6 +60,21 @@ function defaultRedirectUri(req: Request, providerKey: string): string {
56
60
  return toAbsoluteUrl(req, `/api/communication_channels/oauth/${providerKey}/callback`)
57
61
  }
58
62
 
63
+ function oauthStateErrorResponse(err: unknown): Response | null {
64
+ if (err instanceof OAuthStateError) {
65
+ if (err.code === 'missing_secret') {
66
+ logger.error('OAuth state secret is not configured', { err })
67
+ getTelemetryRuntime()?.reportError(err, {
68
+ module: 'communication_channels',
69
+ code: 'communication_channels.missing_secret',
70
+ })
71
+ return NextResponse.json({ error: 'OAuth is not available', code: err.code }, { status: 500 })
72
+ }
73
+ return NextResponse.json({ error: err.message, code: err.code }, { status: 500 })
74
+ }
75
+ return null
76
+ }
77
+
59
78
  export async function POST(req: Request, context: RouteContext): Promise<Response> {
60
79
  const { provider } = await context.params
61
80
  if (!/^[a-z0-9_-]+$/i.test(provider)) {
@@ -121,13 +140,20 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
121
140
  }
122
141
 
123
142
  const redirectUri = defaultRedirectUri(req, provider)
124
- const stateEnvelope = createOAuthState({
125
- userId: auth.sub as string,
126
- tenantId: auth.tenantId as string,
127
- organizationId: (auth as { orgId?: string | null }).orgId ?? null,
128
- providerKey: provider,
129
- returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),
130
- })
143
+ let stateEnvelope: ReturnType<typeof createOAuthState>
144
+ try {
145
+ stateEnvelope = createOAuthState({
146
+ userId: auth.sub as string,
147
+ tenantId: auth.tenantId as string,
148
+ organizationId: (auth as { orgId?: string | null }).orgId ?? null,
149
+ providerKey: provider,
150
+ returnUrl: normalizeOAuthReturnUrl(body.returnUrl, DEFAULT_OAUTH_RETURN_URL),
151
+ })
152
+ } catch (err) {
153
+ const response = oauthStateErrorResponse(err)
154
+ if (response) return response
155
+ throw err
156
+ }
131
157
 
132
158
  let result
133
159
  try {
@@ -153,12 +179,21 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
153
179
 
154
180
  // If the adapter packed extras (PKCE verifier, scopes), bake them into the
155
181
  // state cookie now so the callback handler can pass them to exchangeOAuthCode.
156
- const finalCookie = result.extra
157
- ? (await import('../../../../../lib/oauth-state')).encryptOAuthState({
182
+ let finalCookie: string
183
+ if (result.extra) {
184
+ try {
185
+ finalCookie = (await import('../../../../../lib/oauth-state')).encryptOAuthState({
158
186
  ...stateEnvelope.payload,
159
187
  extra: { ...(stateEnvelope.payload.extra ?? {}), ...result.extra },
160
188
  })
161
- : stateEnvelope.cookie
189
+ } catch (err) {
190
+ const response = oauthStateErrorResponse(err)
191
+ if (response) return response
192
+ throw err
193
+ }
194
+ } else {
195
+ finalCookie = stateEnvelope.cookie
196
+ }
162
197
 
163
198
  const response = NextResponse.json({ authorizeUrl: result.authorizeUrl })
164
199
  response.cookies.set({
@@ -184,6 +219,7 @@ export const openApi = {
184
219
  { status: 400, description: 'Invalid provider or unsupported (no OAuth)' },
185
220
  { status: 401, description: 'Unauthorized' },
186
221
  { status: 422, description: 'Invalid request body' },
222
+ { status: 500, description: 'OAuth state secret missing or state encryption failed' },
187
223
  { status: 502, description: 'Adapter failed to build authorize URL' },
188
224
  ],
189
225
  },
@@ -91,6 +91,7 @@
91
91
  "communication_channels.profile.connect.imapTitle": "IMAP-Postfach verbinden",
92
92
  "communication_channels.profile.connect.mailboxAlreadyConnected": "Dieses Postfach ist bereits über einen anderen Anbieter verbunden. Trennen Sie es zuerst, um es mit einem anderen Anbieter erneut zu verbinden.",
93
93
  "communication_channels.profile.connect.menu": "Kanal verbinden",
94
+ "communication_channels.profile.connect.notAvailable": "Die Verbindung mit einem Postfach ist nicht verfügbar. Bitten Sie einen Administrator, den OAuth-Zustandsschlüssel zu konfigurieren.",
94
95
  "communication_channels.profile.connect.notConfigured": "Dieser Anbieter ist noch nicht konfiguriert. Bitten Sie einen Administrator, die OAuth-Client-ID und das Secret unter Integrationen zu hinterlegen, bevor ein Postfach verbunden wird.",
95
96
  "communication_channels.profile.connect.oauthFailed": "OAuth-Verbindung konnte nicht gestartet werden.",
96
97
  "communication_channels.profile.connect.save": "Verbinden",
@@ -91,6 +91,7 @@
91
91
  "communication_channels.profile.connect.imapTitle": "Connect IMAP mailbox",
92
92
  "communication_channels.profile.connect.mailboxAlreadyConnected": "This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.",
93
93
  "communication_channels.profile.connect.menu": "Connect channel",
94
+ "communication_channels.profile.connect.notAvailable": "Mailbox connection is not available. Ask an administrator to configure the OAuth state key.",
94
95
  "communication_channels.profile.connect.notConfigured": "This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.",
95
96
  "communication_channels.profile.connect.oauthFailed": "Could not start OAuth connection.",
96
97
  "communication_channels.profile.connect.save": "Connect",
@@ -91,6 +91,7 @@
91
91
  "communication_channels.profile.connect.imapTitle": "Conectar buzón IMAP",
92
92
  "communication_channels.profile.connect.mailboxAlreadyConnected": "Este buzón ya está conectado a través de otro proveedor. Desconéctalo primero para volver a conectarlo con uno diferente.",
93
93
  "communication_channels.profile.connect.menu": "Conectar canal",
94
+ "communication_channels.profile.connect.notAvailable": "La conexión del buzón no está disponible. Pide a un administrador que configure la clave de estado de OAuth.",
94
95
  "communication_channels.profile.connect.notConfigured": "Este proveedor aún no está configurado. Pide a un administrador que añada el ID de cliente y el secreto de OAuth en Integraciones antes de conectar un buzón.",
95
96
  "communication_channels.profile.connect.oauthFailed": "No se pudo iniciar la conexión OAuth.",
96
97
  "communication_channels.profile.connect.save": "Conectar",
@@ -91,6 +91,7 @@
91
91
  "communication_channels.profile.connect.imapTitle": "IMAP 메일함 연결",
92
92
  "communication_channels.profile.connect.mailboxAlreadyConnected": "이 메일함은 이미 다른 제공업체를 통해 연결되어 있습니다. 다른 제공업체로 다시 연결하려면 먼저 연결을 해제하세요.",
93
93
  "communication_channels.profile.connect.menu": "채널 연결",
94
+ "communication_channels.profile.connect.notAvailable": "메일함 연결을 사용할 수 없습니다. 관리자에게 OAuth 상태 키를 구성하도록 요청하세요.",
94
95
  "communication_channels.profile.connect.notConfigured": "이 제공업체는 아직 구성되지 않았습니다. 메일함을 연결하기 전에 관리자에게 통합에서 OAuth 클라이언트 ID와 시크릿을 추가하도록 요청하세요.",
95
96
  "communication_channels.profile.connect.oauthFailed": "OAuth 연결을 시작할 수 없습니다.",
96
97
  "communication_channels.profile.connect.save": "연결",
@@ -91,6 +91,7 @@
91
91
  "communication_channels.profile.connect.imapTitle": "Połącz skrzynkę IMAP",
92
92
  "communication_channels.profile.connect.mailboxAlreadyConnected": "Ta skrzynka jest już połączona przez innego dostawcę. Najpierw ją rozłącz, aby połączyć ją z innym dostawcą.",
93
93
  "communication_channels.profile.connect.menu": "Połącz kanał",
94
+ "communication_channels.profile.connect.notAvailable": "Połączenie skrzynki pocztowej jest niedostępne. Poproś administratora o skonfigurowanie klucza stanu OAuth.",
94
95
  "communication_channels.profile.connect.notConfigured": "Ten dostawca nie został jeszcze skonfigurowany. Poproś administratora o dodanie identyfikatora klienta i sekretu OAuth w sekcji Integracje przed połączeniem skrzynki.",
95
96
  "communication_channels.profile.connect.oauthFailed": "Nie udało się rozpocząć połączenia OAuth.",
96
97
  "communication_channels.profile.connect.save": "Połącz",
@@ -138,7 +138,10 @@ function getSecret(): string {
138
138
  // state cookies, bypassing the userId/tenant binding. In non-production we fall
139
139
  // back to JWT_SECRET so dev/test envs that only configure one secret still work.
140
140
  if (process.env.NODE_ENV === 'production') {
141
- throw new Error('[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production')
141
+ throw new OAuthStateError(
142
+ '[internal] OM_HUB_OAUTH_STATE_KEY or KMS_MASTER_KEY required in production',
143
+ 'missing_secret',
144
+ )
142
145
  }
143
146
  const fallback = process.env.JWT_SECRET
144
147
  if (!fallback) {
@@ -46,6 +46,16 @@ export function useConnectChannel(options: {
46
46
  })
47
47
  const body = response.result as InitiateResponse | undefined
48
48
  if (!response.ok || !body?.authorizeUrl) {
49
+ if (body?.code === 'missing_secret') {
50
+ flash(
51
+ t(
52
+ 'communication_channels.profile.connect.notAvailable',
53
+ 'Mailbox connection is not available. Ask an administrator to configure the OAuth state key.',
54
+ ),
55
+ 'error',
56
+ )
57
+ return
58
+ }
49
59
  if (body?.code === 'oauth_client_not_configured') {
50
60
  flash(
51
61
  t(
@@ -764,7 +764,19 @@ async function resolveUpload(em: EntityManager, runId: string | undefined, curso
764
764
  return null
765
765
  }
766
766
 
767
+ async function personExists(em: EntityManager, personId: string, scope: TenantScope): Promise<boolean> {
768
+ const matches = await em.count(CustomerEntity, {
769
+ id: personId,
770
+ kind: 'person',
771
+ organizationId: scope.organizationId,
772
+ tenantId: scope.tenantId,
773
+ deletedAt: null,
774
+ })
775
+ return matches > 0
776
+ }
777
+
767
778
  async function resolveExistingPersonId(params: {
779
+ em: EntityManager
768
780
  externalIdMappingService: ExternalIdMappingService
769
781
  externalId: string | null | undefined
770
782
  email: string | null | undefined
@@ -778,7 +790,7 @@ async function resolveExistingPersonId(params: {
778
790
  params.externalId,
779
791
  params.scope,
780
792
  )
781
- if (mappedLocalId) return mappedLocalId
793
+ if (mappedLocalId && await personExists(params.em, mappedLocalId, params.scope)) return mappedLocalId
782
794
  }
783
795
 
784
796
  if (!params.email) return null
@@ -866,6 +878,7 @@ async function processRow(params: {
866
878
  }
867
879
  }
868
880
  const existingId = await resolveExistingPersonId({
881
+ em: params.em,
869
882
  externalIdMappingService: params.externalIdMappingService,
870
883
  externalId,
871
884
  email: payload.values.primaryEmail,
@@ -885,6 +898,21 @@ async function processRow(params: {
885
898
  }
886
899
  }
887
900
 
901
+ const rememberPersonIdentity = async (personId: string): Promise<void> => {
902
+ if (externalId) {
903
+ await params.externalIdMappingService.storeExternalIdMapping(
904
+ 'sync_excel',
905
+ 'customers.person',
906
+ personId,
907
+ externalId,
908
+ params.scope,
909
+ )
910
+ }
911
+ if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
912
+ params.emailDedupeIndex.set(payload.values.primaryEmail, personId)
913
+ }
914
+ }
915
+
888
916
  try {
889
917
  if (existingId) {
890
918
  const updateInput = {
@@ -897,6 +925,8 @@ async function processRow(params: {
897
925
  ctx: params.commandContext,
898
926
  })
899
927
 
928
+ await rememberPersonIdentity(existingId)
929
+
900
930
  await upsertPrimaryAddress({
901
931
  entityId: existingId,
902
932
  addressValues: payload.addressValues,
@@ -906,19 +936,6 @@ async function processRow(params: {
906
936
  em: params.em,
907
937
  })
908
938
 
909
- if (externalId) {
910
- await params.externalIdMappingService.storeExternalIdMapping(
911
- 'sync_excel',
912
- 'customers.person',
913
- existingId,
914
- externalId,
915
- params.scope,
916
- )
917
- }
918
- if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
919
- params.emailDedupeIndex.set(payload.values.primaryEmail, existingId)
920
- }
921
-
922
939
  return {
923
940
  externalId: externalId ?? sourceIdentifier,
924
941
  action: 'update',
@@ -941,6 +958,8 @@ async function processRow(params: {
941
958
  ctx: params.commandContext,
942
959
  })
943
960
 
961
+ await rememberPersonIdentity(commandResult.result.entityId)
962
+
944
963
  await upsertPrimaryAddress({
945
964
  entityId: commandResult.result.entityId,
946
965
  addressValues: payload.addressValues,
@@ -950,19 +969,6 @@ async function processRow(params: {
950
969
  em: params.em,
951
970
  })
952
971
 
953
- if (externalId) {
954
- await params.externalIdMappingService.storeExternalIdMapping(
955
- 'sync_excel',
956
- 'customers.person',
957
- commandResult.result.entityId,
958
- externalId,
959
- params.scope,
960
- )
961
- }
962
- if (payload.values.primaryEmail && !params.emailDedupeIndex.has(payload.values.primaryEmail)) {
963
- params.emailDedupeIndex.set(payload.values.primaryEmail, commandResult.result.entityId)
964
- }
965
-
966
972
  return {
967
973
  externalId: externalId ?? sourceIdentifier,
968
974
  action: 'create',