@open-mercato/core 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/crmFixtures.js.map +1 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
- package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
- package/dist/modules/communication_channels/lib/test-seed.js +5 -0
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/page.js +4 -3
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/cli.js +5 -5
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
- package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js.map +2 -2
- package/dist/modules/customers/lib/dealStatus.js +6 -4
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
- package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
- package/dist/modules/eudr/notifications.js +5 -0
- package/dist/modules/eudr/notifications.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +59 -28
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +146 -99
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +1 -30
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +1 -4
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +2 -1
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
- package/dist/modules/sales/data/validators.js +3 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +14 -2
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/json.js +8 -0
- package/dist/modules/sales/lib/json.js.map +7 -0
- package/dist/modules/sales/lib/lineSnapshots.js +66 -0
- package/dist/modules/sales/lib/lineSnapshots.js.map +7 -0
- package/dist/modules/warranty_claims/notifications.js +5 -0
- package/dist/modules/warranty_claims/notifications.js.map +2 -2
- package/dist/modules/wms/notifications.js +2 -0
- package/dist/modules/wms/notifications.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/crmFixtures.ts +1 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
- package/src/modules/communication_channels/lib/adapter.ts +11 -0
- package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
- package/src/modules/communication_channels/lib/test-seed.ts +19 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
- package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
- package/src/modules/customers/cli.ts +5 -5
- package/src/modules/customers/commands/deals.ts +4 -3
- package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
- package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
- package/src/modules/customers/i18n/de.json +1 -1
- package/src/modules/customers/i18n/en.json +1 -1
- package/src/modules/customers/i18n/es.json +1 -1
- package/src/modules/customers/i18n/ko.json +1 -1
- package/src/modules/customers/i18n/pl.json +1 -1
- package/src/modules/customers/lib/closureStage.ts +2 -1
- package/src/modules/customers/lib/dealStatus.ts +23 -12
- package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
- package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
- package/src/modules/eudr/notifications.ts +5 -0
- package/src/modules/query_index/lib/search-tokens.ts +100 -43
- package/src/modules/sales/commands/documents.ts +185 -105
- package/src/modules/sales/commands/returns.ts +1 -31
- package/src/modules/sales/commands/shared.ts +1 -4
- package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +12 -1
- package/src/modules/sales/data/validators.ts +3 -0
- package/src/modules/sales/lib/calculations.ts +33 -7
- package/src/modules/sales/lib/json.ts +4 -0
- package/src/modules/sales/lib/lineSnapshots.ts +98 -0
- package/src/modules/sales/lib/types.ts +20 -0
- package/src/modules/warranty_claims/notifications.ts +5 -0
- package/src/modules/wms/notifications.ts +2 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/helpers/integration/crmFixtures.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { readJsonSafe } from './generalFixtures';\n\ntype JsonRecord = Record<string, unknown>;\n\nexport { readJsonSafe } from './generalFixtures';\n\nfunction isRecord(value: unknown): value is JsonRecord {\n return typeof value === 'object' && value !== null;\n}\n\nfunction findStringByKeys(value: unknown, keys: readonly string[]): string | null {\n if (!isRecord(value)) return null;\n\n for (const key of keys) {\n const candidate = value[key];\n if (typeof candidate === 'string' && candidate.trim().length > 0) {\n return candidate.trim();\n }\n }\n\n for (const nested of Object.values(value)) {\n if (Array.isArray(nested)) continue;\n const found = findStringByKeys(nested, keys);\n if (found) return found;\n }\n\n return null;\n}\n\nasync function createEntity(\n request: APIRequestContext,\n token: string,\n path: string,\n data: Record<string, unknown>,\n idKeys: readonly string[],\n): Promise<string> {\n const response = await apiRequest(request, 'POST', path, { token, data });\n const payload = await readJsonSafe(response);\n expect(response.ok(), `Failed POST ${path}: ${response.status()}`).toBeTruthy();\n const id = findStringByKeys(payload, idKeys);\n expect(id, `No id in ${path} response`).toBeTruthy();\n return id as string;\n}\n\nexport async function createCompanyFixture(\n request: APIRequestContext,\n token: string,\n displayName: string,\n): Promise<string> {\n return createEntity(request, token, '/api/customers/companies', { displayName }, ['id', 'entityId', 'companyId']);\n}\n\nexport async function createPersonFixture(\n request: APIRequestContext,\n token: string,\n input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string },\n): Promise<string> {\n const data: Record<string, unknown> = {\n firstName: input.firstName,\n lastName: input.lastName,\n displayName: input.displayName,\n };\n if (input.companyEntityId) {\n data.companyEntityId = input.companyEntityId;\n }\n return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);\n}\n\nexport async function createDealFixture(\n request: APIRequestContext,\n token: string,\n input: {\n title: string;\n companyIds?: string[];\n personIds?: string[];\n pipelineId?: string;\n pipelineStageId?: string;\n valueAmount?: number;\n valueCurrency?: string;\n // Optional deal lifecycle fields \u2014 all valid on `dealCreateSchema`. Forwarded so KPI/summary\n // tests can seed won/lost/overdue/owned deals across quarters (see TC-CRM-082). `status` is a\n // free-form dictionary value (e.g. 'open', 'in_progress', 'win', '
|
|
4
|
+
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { readJsonSafe } from './generalFixtures';\n\ntype JsonRecord = Record<string, unknown>;\n\nexport { readJsonSafe } from './generalFixtures';\n\nfunction isRecord(value: unknown): value is JsonRecord {\n return typeof value === 'object' && value !== null;\n}\n\nfunction findStringByKeys(value: unknown, keys: readonly string[]): string | null {\n if (!isRecord(value)) return null;\n\n for (const key of keys) {\n const candidate = value[key];\n if (typeof candidate === 'string' && candidate.trim().length > 0) {\n return candidate.trim();\n }\n }\n\n for (const nested of Object.values(value)) {\n if (Array.isArray(nested)) continue;\n const found = findStringByKeys(nested, keys);\n if (found) return found;\n }\n\n return null;\n}\n\nasync function createEntity(\n request: APIRequestContext,\n token: string,\n path: string,\n data: Record<string, unknown>,\n idKeys: readonly string[],\n): Promise<string> {\n const response = await apiRequest(request, 'POST', path, { token, data });\n const payload = await readJsonSafe(response);\n expect(response.ok(), `Failed POST ${path}: ${response.status()}`).toBeTruthy();\n const id = findStringByKeys(payload, idKeys);\n expect(id, `No id in ${path} response`).toBeTruthy();\n return id as string;\n}\n\nexport async function createCompanyFixture(\n request: APIRequestContext,\n token: string,\n displayName: string,\n): Promise<string> {\n return createEntity(request, token, '/api/customers/companies', { displayName }, ['id', 'entityId', 'companyId']);\n}\n\nexport async function createPersonFixture(\n request: APIRequestContext,\n token: string,\n input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string },\n): Promise<string> {\n const data: Record<string, unknown> = {\n firstName: input.firstName,\n lastName: input.lastName,\n displayName: input.displayName,\n };\n if (input.companyEntityId) {\n data.companyEntityId = input.companyEntityId;\n }\n return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);\n}\n\nexport async function createDealFixture(\n request: APIRequestContext,\n token: string,\n input: {\n title: string;\n companyIds?: string[];\n personIds?: string[];\n pipelineId?: string;\n pipelineStageId?: string;\n valueAmount?: number;\n valueCurrency?: string;\n // Optional deal lifecycle fields \u2014 all valid on `dealCreateSchema`. Forwarded so KPI/summary\n // tests can seed won/lost/overdue/owned deals across quarters (see TC-CRM-082). `status` is a\n // free-form dictionary value (e.g. 'open', 'in_progress', 'win', 'lost'); `expectedCloseAt`\n // accepts an ISO string (the schema coerces it to a Date); `closureOutcome` is 'won' | 'lost'.\n status?: string;\n expectedCloseAt?: string;\n ownerUserId?: string;\n closureOutcome?: 'won' | 'lost';\n },\n): Promise<string> {\n const data: Record<string, unknown> = { title: input.title };\n if (input.companyIds?.length) data.companyIds = input.companyIds;\n if (input.personIds?.length) data.personIds = input.personIds;\n if (input.pipelineId) data.pipelineId = input.pipelineId;\n if (input.pipelineStageId) data.pipelineStageId = input.pipelineStageId;\n if (input.valueAmount !== undefined) data.valueAmount = input.valueAmount;\n if (input.valueCurrency) data.valueCurrency = input.valueCurrency;\n if (input.status) data.status = input.status;\n if (input.expectedCloseAt) data.expectedCloseAt = input.expectedCloseAt;\n if (input.ownerUserId) data.ownerUserId = input.ownerUserId;\n if (input.closureOutcome) data.closureOutcome = input.closureOutcome;\n return createEntity(request, token, '/api/customers/deals', data, ['dealId', 'id', 'entityId']);\n}\n\nexport async function createPipelineFixture(\n request: APIRequestContext,\n token: string,\n input: { name: string; isDefault?: boolean },\n): Promise<string> {\n const data: Record<string, unknown> = { name: input.name };\n if (input.isDefault !== undefined) data.isDefault = input.isDefault;\n return createEntity(request, token, '/api/customers/pipelines', data, ['id', 'pipelineId']);\n}\n\nexport async function createPipelineStageFixture(\n request: APIRequestContext,\n token: string,\n input: { pipelineId: string; label: string; order?: number },\n): Promise<string> {\n const data: Record<string, unknown> = { pipelineId: input.pipelineId, label: input.label };\n if (input.order !== undefined) data.order = input.order;\n return createEntity(request, token, '/api/customers/pipeline-stages', data, ['id', 'stageId']);\n}\n\nexport async function deleteEntityByBody(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', path, { token, data: { id } });\n } catch {\n return;\n }\n}\n\nexport async function deleteEntityIfExists(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', `${path}?id=${encodeURIComponent(id)}`, { token });\n } catch {\n return;\n }\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["readJsonSafe"]
|
|
7
7
|
}
|
|
@@ -28,7 +28,7 @@ const metadata = {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
const bodySchema = z.object({
|
|
31
|
-
to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\r\n]*$/),
|
|
31
|
+
to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\r\n]*$/).optional(),
|
|
32
32
|
subject: z.string().min(1).max(500).optional(),
|
|
33
33
|
body: z.string().max(5e4).optional()
|
|
34
34
|
});
|
|
@@ -168,7 +168,14 @@ async function POST(req, context) {
|
|
|
168
168
|
tenantId: auth.tenantId,
|
|
169
169
|
organizationId: channelCredentialsOrg
|
|
170
170
|
},
|
|
171
|
-
|
|
171
|
+
// `to` is omitted from the metadata rather than passed as `undefined` when
|
|
172
|
+
// the caller left it out, so an adapter reading `metadata.to` sees no key
|
|
173
|
+
// at all and falls through to its own configured target.
|
|
174
|
+
metadata: {
|
|
175
|
+
...body.to !== void 0 ? { to: body.to } : {},
|
|
176
|
+
subject: body.subject ?? "Test send",
|
|
177
|
+
testSend: true
|
|
178
|
+
}
|
|
172
179
|
});
|
|
173
180
|
await guard.afterSuccess();
|
|
174
181
|
return NextResponse.json({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/communication_channels/api/post/channels/%5Bid%5D/test-send/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 type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../../../../../data/entities'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport {\n ChannelAccessDeniedError,\n assertCanManageChannel,\n channelOrgScopeWhere,\n} from '../../../../../lib/access-control'\nimport { refreshCredentialsIfNeeded } from '../../../../../lib/credential-refresh'\nimport {\n MAX_OUTBOUND_RECIPIENT_LENGTH,\n validateOutboundRecipient,\n} from '../../../../../lib/outbound-recipient'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport const metadata = {\n path: '/communication_channels/channels/[id]/test-send',\n POST: {\n // Owner self-service: a user may send a test from their OWN mailbox (gated\n // by `connect_user_channel`). Test-sending from a shared/tenant-wide channel\n // still requires `manage` \u2014 enforced per channel type by `assertCanManageChannel`.\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\n// `to` is deliberately NOT typed as an email here. The recipient shape depends\n// on the provider (an email address for Gmail/IMAP, a channel snowflake for\n// Discord), and the adapter is only known once the channel is loaded \u2014 so the\n// schema accepts any non-empty single-line string and `validateOutboundRecipient`\n// applies the provider-appropriate rules below, defaulting to email. The CR/LF\n// rejection stays at the schema level so a header-injection attempt is refused\n// before the request reaches the channel lookup or the mutation guard.\nconst bodySchema = z.object({\n to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\\r\\n]*$/),\n subject: z.string().min(1).max(500).optional(),\n body: z.string().max(50_000).optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\n/**\n * Admin diagnostic \u2014 send a test message via the adapter without creating a\n * platform `Message`. Useful for verifying credentials + outbound connectivity\n * after a channel is configured.\n *\n * The result is NOT persisted to `ExternalMessage` / `MessageChannelLink` \u2014 it\n * is a one-shot probe.\n */\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\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 bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\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 em = (container.resolve('em') as EntityManager).fork()\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const dscope = { tenantId: auth.tenantId as string, organizationId }\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id,\n tenantId: auth.tenantId as string,\n ...channelOrgScopeWhere(organizationId),\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n // Resolve credentials at the channel's OWN org, not the caller's selected org.\n // Tenant-wide channels store `organization_id = NULL` and their credentials\n // live at `organization_id = tenantId` (see connect-credential-channel.ts), so\n // the read key must be `channel.organizationId ?? tenantId` \u2014 keying on the\n // session org would resolve `{}` for a tenant-wide channel viewed from a\n // non-null org.\n const channelCredentialsOrg = channel.organizationId ?? (auth.tenantId as string)\n // Load features via RBAC service so admin bypass (`communication_channels.admin`,\n // wildcards, super-admin) is honoured. The `auth` object from\n // `getAuthFromRequest` carries identity only \u2014 feature ACLs live in the RBAC\n // service and must be loaded per request.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(auth.sub as string, {\n tenantId: auth.tenantId as string,\n organizationId,\n })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: channel.userId },\n auth.sub as string,\n userFeatures,\n 'communication_channels.manage',\n )\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n const status = (err as { statusCode?: number }).statusCode ?? 403\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Access denied' },\n { status },\n )\n }\n if (!channel.isActive || channel.status !== 'connected') {\n return NextResponse.json(\n { error: `Channel is in status '${channel.status}' (not connected)` },\n { status: 409 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n operation: 'custom',\n mutationPayload: body as unknown as Record<string, unknown>,\n },\n })\n if ('response' in guard) return guard.response\n\n const adapter = getChannelAdapter(channel.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${channel.providerKey}'` },\n { status: 404 },\n )\n }\n\n const recipientCheck = validateOutboundRecipient(body.to, adapter.capabilities)\n if (!recipientCheck.ok) {\n return NextResponse.json({ error: recipientCheck.error }, { status: 422 })\n }\n\n // Resolve credentials + optionally refresh.\n let credentials: Record<string, unknown> = {}\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n if (channel.credentialsRef && credentialsService) {\n credentials =\n (await credentialsService\n .resolve(`channel_${channel.providerKey}`, {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n })\n .catch(() => null)) ?? {}\n }\n const credentialScope = {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n }\n const refreshed = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n try {\n const converted = await adapter.convertOutbound({\n body: body.body ?? 'Test message from Open Mercato',\n bodyFormat: 'text',\n })\n const result = await adapter.sendMessage({\n content: converted.content,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n },\n metadata: { to: body.to, subject: body.subject ?? 'Test send', testSend: true },\n })\n await guard.afterSuccess()\n return NextResponse.json({\n status: result.status,\n externalMessageId: result.externalMessageId,\n providerError: result.error ?? null,\n })\n } catch (err) {\n const message = err instanceof Error ? err.message : 'send failed'\n return NextResponse.json({ status: 'failed', error: message }, { status: 502 })\n }\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Diagnostic \u2014 send a test message through the channel without persisting',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Test send result' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Not allowed to manage this channel' },\n { status: 404, description: 'Channel or adapter not found' },\n { status: 409, description: 'Channel not connected' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Provider error' },\n ],\n },\n },\n}\nexport default POST\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAE7B,SAAS,6BAA6B;AACtC,SAAS,4BAA4B;AACrC,SAAS,yBAAyB;AAClC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,kCAAkC;AAC3C;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,kCAAkC;AASpC,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA;AAAA;AAAA;AAAA,IAIJ,aAAa;AAAA,IACb,iBAAiB,CAAC,6CAA6C;AAAA,EACjE;AACF;
|
|
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 type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../../../../../data/entities'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport {\n ChannelAccessDeniedError,\n assertCanManageChannel,\n channelOrgScopeWhere,\n} from '../../../../../lib/access-control'\nimport { refreshCredentialsIfNeeded } from '../../../../../lib/credential-refresh'\nimport {\n MAX_OUTBOUND_RECIPIENT_LENGTH,\n validateOutboundRecipient,\n} from '../../../../../lib/outbound-recipient'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport const metadata = {\n path: '/communication_channels/channels/[id]/test-send',\n POST: {\n // Owner self-service: a user may send a test from their OWN mailbox (gated\n // by `connect_user_channel`). Test-sending from a shared/tenant-wide channel\n // still requires `manage` \u2014 enforced per channel type by `assertCanManageChannel`.\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\n// `to` is deliberately NOT typed as an email here, and deliberately optional.\n// The recipient shape depends on the provider (an email address for Gmail/IMAP,\n// a channel snowflake for Discord), and the adapter is only known once the\n// channel is loaded \u2014 so the schema accepts any non-empty single-line string and\n// `validateOutboundRecipient` applies the provider-appropriate rules below,\n// defaulting to email. Whether the recipient may be left out is provider-derived\n// too: an adapter that carries its own outbound target (Discord's\n// `defaultChannelId`) accepts a body with no `to` and posts there, which is the\n// smoke test the Discord spec documents; an email provider has no such default\n// and `validateOutboundRecipient` still refuses the request. The CR/LF rejection\n// stays at the schema level so a header-injection attempt is refused before the\n// request reaches the channel lookup or the mutation guard.\nconst bodySchema = z.object({\n to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\\r\\n]*$/).optional(),\n subject: z.string().min(1).max(500).optional(),\n body: z.string().max(50_000).optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\n/**\n * Admin diagnostic \u2014 send a test message via the adapter without creating a\n * platform `Message`. Useful for verifying credentials + outbound connectivity\n * after a channel is configured.\n *\n * The result is NOT persisted to `ExternalMessage` / `MessageChannelLink` \u2014 it\n * is a one-shot probe.\n */\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\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 bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\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 em = (container.resolve('em') as EntityManager).fork()\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const dscope = { tenantId: auth.tenantId as string, organizationId }\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id,\n tenantId: auth.tenantId as string,\n ...channelOrgScopeWhere(organizationId),\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n // Resolve credentials at the channel's OWN org, not the caller's selected org.\n // Tenant-wide channels store `organization_id = NULL` and their credentials\n // live at `organization_id = tenantId` (see connect-credential-channel.ts), so\n // the read key must be `channel.organizationId ?? tenantId` \u2014 keying on the\n // session org would resolve `{}` for a tenant-wide channel viewed from a\n // non-null org.\n const channelCredentialsOrg = channel.organizationId ?? (auth.tenantId as string)\n // Load features via RBAC service so admin bypass (`communication_channels.admin`,\n // wildcards, super-admin) is honoured. The `auth` object from\n // `getAuthFromRequest` carries identity only \u2014 feature ACLs live in the RBAC\n // service and must be loaded per request.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(auth.sub as string, {\n tenantId: auth.tenantId as string,\n organizationId,\n })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: channel.userId },\n auth.sub as string,\n userFeatures,\n 'communication_channels.manage',\n )\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n const status = (err as { statusCode?: number }).statusCode ?? 403\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Access denied' },\n { status },\n )\n }\n if (!channel.isActive || channel.status !== 'connected') {\n return NextResponse.json(\n { error: `Channel is in status '${channel.status}' (not connected)` },\n { status: 409 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n operation: 'custom',\n mutationPayload: body as unknown as Record<string, unknown>,\n },\n })\n if ('response' in guard) return guard.response\n\n const adapter = getChannelAdapter(channel.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${channel.providerKey}'` },\n { status: 404 },\n )\n }\n\n const recipientCheck = validateOutboundRecipient(body.to, adapter.capabilities)\n if (!recipientCheck.ok) {\n return NextResponse.json({ error: recipientCheck.error }, { status: 422 })\n }\n\n // Resolve credentials + optionally refresh.\n let credentials: Record<string, unknown> = {}\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n if (channel.credentialsRef && credentialsService) {\n credentials =\n (await credentialsService\n .resolve(`channel_${channel.providerKey}`, {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n })\n .catch(() => null)) ?? {}\n }\n const credentialScope = {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n }\n const refreshed = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n try {\n const converted = await adapter.convertOutbound({\n body: body.body ?? 'Test message from Open Mercato',\n bodyFormat: 'text',\n })\n const result = await adapter.sendMessage({\n content: converted.content,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n },\n // `to` is omitted from the metadata rather than passed as `undefined` when\n // the caller left it out, so an adapter reading `metadata.to` sees no key\n // at all and falls through to its own configured target.\n metadata: {\n ...(body.to !== undefined ? { to: body.to } : {}),\n subject: body.subject ?? 'Test send',\n testSend: true,\n },\n })\n await guard.afterSuccess()\n return NextResponse.json({\n status: result.status,\n externalMessageId: result.externalMessageId,\n providerError: result.error ?? null,\n })\n } catch (err) {\n const message = err instanceof Error ? err.message : 'send failed'\n return NextResponse.json({ status: 'failed', error: message }, { status: 502 })\n }\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Diagnostic \u2014 send a test message through the channel without persisting',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Test send result' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Not allowed to manage this channel' },\n { status: 404, description: 'Channel or adapter not found' },\n { status: 409, description: 'Channel not connected' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Provider error' },\n ],\n },\n },\n}\nexport default POST\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,6 +3,10 @@ const MAX_OUTBOUND_RECIPIENT_LENGTH = 320;
|
|
|
3
3
|
const emailRecipientSchema = z.string().email();
|
|
4
4
|
const PROVIDER_NATIVE_ALLOWED = /^[A-Za-z0-9._:@+-]+$/;
|
|
5
5
|
function validateOutboundRecipient(recipient, capabilities) {
|
|
6
|
+
const isProviderNative = capabilities?.recipientFormat === "provider-native";
|
|
7
|
+
if (recipient === void 0 && isProviderNative) {
|
|
8
|
+
return { ok: true };
|
|
9
|
+
}
|
|
6
10
|
if (typeof recipient !== "string" || recipient.length === 0) {
|
|
7
11
|
return { ok: false, error: "Recipient is required" };
|
|
8
12
|
}
|
|
@@ -12,7 +16,7 @@ function validateOutboundRecipient(recipient, capabilities) {
|
|
|
12
16
|
error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`
|
|
13
17
|
};
|
|
14
18
|
}
|
|
15
|
-
if (
|
|
19
|
+
if (!isProviderNative) {
|
|
16
20
|
return emailRecipientSchema.safeParse(recipient).success ? { ok: true } : { ok: false, error: "Recipient must be a valid email address" };
|
|
17
21
|
}
|
|
18
22
|
if (!PROVIDER_NATIVE_ALLOWED.test(recipient)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/lib/outbound-recipient.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport type { ChannelCapabilities } from './adapter'\n\n/**\n * Upper bound shared by both recipient shapes. 320 is the RFC 5321 maximum for\n * an email address (64 local + `@` + 255 domain); provider-native identifiers\n * are far shorter, so one ceiling covers both.\n */\nexport const MAX_OUTBOUND_RECIPIENT_LENGTH = 320\n\nconst emailRecipientSchema = z.string().email()\n\n/**\n * Characters a provider-native recipient may contain. This is an allowlist, not a\n * denylist, because the recipient reaches adapters that interpolate it into a REST\n * path (Discord posts to `/channels/{recipient}/messages`), and a denylist fails\n * open on every character nobody thought of \u2014 percent-encoded separators\n * (`%2F`, `%2e%2e%2f`) and control bytes (NUL, DEL) all walked through the\n * previous `[\\r\\n\\s/\\\\?#]` class. An allowlist fails closed instead: the worst\n * case is a provider whose identifier alphabet needs widening, which is a\n * reviewable one-line change rather than a silent path-steering primitive.\n *\n * The set covers every provider-native identifier shape the hub has to carry\n * today \u2014 Discord snowflakes (digits), Slack-style ids (alphanumerics), and email\n * addresses, since `'provider-native'` must never narrow what `'email'` accepts.\n */\nconst PROVIDER_NATIVE_ALLOWED = /^[A-Za-z0-9._:@+-]+$/\n\nexport type OutboundRecipientCheck = { ok: true } | { ok: false; error: string }\n\n/**\n * Validate an outbound recipient against the provider's declared recipient\n * shape.\n *\n * The hub used to hard-wire `z.string().email()` on every outbound endpoint,\n * which left providers whose recipients are not email addresses with no product\n * path to send at all (#4976). Validation now follows the adapter's\n * `capabilities.recipientFormat`, defaulting to `'email'` so every existing\n * provider keeps byte-identical behavior.\n */\nexport function validateOutboundRecipient(\n recipient: unknown,\n capabilities: Pick<ChannelCapabilities, 'recipientFormat'> | null | undefined,\n): OutboundRecipientCheck {\n if (typeof recipient !== 'string' || recipient.length === 0) {\n return { ok: false, error: 'Recipient is required' }\n }\n if (recipient.length > MAX_OUTBOUND_RECIPIENT_LENGTH) {\n return {\n ok: false,\n error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`,\n }\n }\n if (
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAQX,MAAM,gCAAgC;AAE7C,MAAM,uBAAuB,EAAE,OAAO,EAAE,MAAM;AAgB9C,MAAM,0BAA0B;
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport type { ChannelCapabilities } from './adapter'\n\n/**\n * Upper bound shared by both recipient shapes. 320 is the RFC 5321 maximum for\n * an email address (64 local + `@` + 255 domain); provider-native identifiers\n * are far shorter, so one ceiling covers both.\n */\nexport const MAX_OUTBOUND_RECIPIENT_LENGTH = 320\n\nconst emailRecipientSchema = z.string().email()\n\n/**\n * Characters a provider-native recipient may contain. This is an allowlist, not a\n * denylist, because the recipient reaches adapters that interpolate it into a REST\n * path (Discord posts to `/channels/{recipient}/messages`), and a denylist fails\n * open on every character nobody thought of \u2014 percent-encoded separators\n * (`%2F`, `%2e%2e%2f`) and control bytes (NUL, DEL) all walked through the\n * previous `[\\r\\n\\s/\\\\?#]` class. An allowlist fails closed instead: the worst\n * case is a provider whose identifier alphabet needs widening, which is a\n * reviewable one-line change rather than a silent path-steering primitive.\n *\n * The set covers every provider-native identifier shape the hub has to carry\n * today \u2014 Discord snowflakes (digits), Slack-style ids (alphanumerics), and email\n * addresses, since `'provider-native'` must never narrow what `'email'` accepts.\n */\nconst PROVIDER_NATIVE_ALLOWED = /^[A-Za-z0-9._:@+-]+$/\n\nexport type OutboundRecipientCheck = { ok: true } | { ok: false; error: string }\n\n/**\n * Validate an outbound recipient against the provider's declared recipient\n * shape.\n *\n * The hub used to hard-wire `z.string().email()` on every outbound endpoint,\n * which left providers whose recipients are not email addresses with no product\n * path to send at all (#4976). Validation now follows the adapter's\n * `capabilities.recipientFormat`, defaulting to `'email'` so every existing\n * provider keeps byte-identical behavior.\n *\n * A `'provider-native'` provider may also be addressed by **omitting** the\n * recipient entirely: those adapters are configured with their own outbound\n * target (Discord's `defaultChannelId`) and fall back to it when the message\n * names no channel, which is the smoke test the Discord spec documents in\n * \u00A7 6 \"Test it\". An email provider has no such default, so `'email'` \u2014 the\n * format every existing provider resolves to \u2014 still requires a recipient.\n * Omission means `undefined` only: `null` or `''` is a caller that meant to\n * supply an address and got it wrong, and stays a 422 on every provider.\n */\nexport function validateOutboundRecipient(\n recipient: unknown,\n capabilities: Pick<ChannelCapabilities, 'recipientFormat'> | null | undefined,\n): OutboundRecipientCheck {\n const isProviderNative = capabilities?.recipientFormat === 'provider-native'\n if (recipient === undefined && isProviderNative) {\n return { ok: true }\n }\n if (typeof recipient !== 'string' || recipient.length === 0) {\n return { ok: false, error: 'Recipient is required' }\n }\n if (recipient.length > MAX_OUTBOUND_RECIPIENT_LENGTH) {\n return {\n ok: false,\n error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`,\n }\n }\n if (!isProviderNative) {\n return emailRecipientSchema.safeParse(recipient).success\n ? { ok: true }\n : { ok: false, error: 'Recipient must be a valid email address' }\n }\n if (!PROVIDER_NATIVE_ALLOWED.test(recipient)) {\n return {\n ok: false,\n error: 'Recipient may only contain letters, digits, and the characters . _ : @ + -',\n }\n }\n // `.` is allowed (email addresses need it), so traversal still has to be\n // rejected explicitly even though the separators it would need are not.\n if (recipient.includes('..')) {\n return { ok: false, error: 'Recipient must not contain \"..\"' }\n }\n return { ok: true }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAQX,MAAM,gCAAgC;AAE7C,MAAM,uBAAuB,EAAE,OAAO,EAAE,MAAM;AAgB9C,MAAM,0BAA0B;AAuBzB,SAAS,0BACd,WACA,cACwB;AACxB,QAAM,mBAAmB,cAAc,oBAAoB;AAC3D,MAAI,cAAc,UAAa,kBAAkB;AAC/C,WAAO,EAAE,IAAI,KAAK;AAAA,EACpB;AACA,MAAI,OAAO,cAAc,YAAY,UAAU,WAAW,GAAG;AAC3D,WAAO,EAAE,IAAI,OAAO,OAAO,wBAAwB;AAAA,EACrD;AACA,MAAI,UAAU,SAAS,+BAA+B;AACpD,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO,6BAA6B,6BAA6B;AAAA,IACnE;AAAA,EACF;AACA,MAAI,CAAC,kBAAkB;AACrB,WAAO,qBAAqB,UAAU,SAAS,EAAE,UAC7C,EAAE,IAAI,KAAK,IACX,EAAE,IAAI,OAAO,OAAO,0CAA0C;AAAA,EACpE;AACA,MAAI,CAAC,wBAAwB,KAAK,SAAS,GAAG;AAC5C,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO;AAAA,IACT;AAAA,EACF;AAGA,MAAI,UAAU,SAAS,IAAI,GAAG;AAC5B,WAAO,EAAE,IAAI,OAAO,OAAO,kCAAkC;AAAA,EAC/D;AACA,SAAO,EAAE,IAAI,KAAK;AACpB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -52,11 +52,16 @@ class TestSeedChannelAdapter {
|
|
|
52
52
|
return { ok: true };
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
const testSeedChatCapabilities = {
|
|
56
|
+
...testSeedCapabilities,
|
|
57
|
+
recipientFormat: "provider-native"
|
|
58
|
+
};
|
|
55
59
|
class TestSeedChatChannelAdapter extends TestSeedChannelAdapter {
|
|
56
60
|
constructor() {
|
|
57
61
|
super(...arguments);
|
|
58
62
|
this.providerKey = TEST_SEED_CHAT_PROVIDER_KEY;
|
|
59
63
|
this.channelType = "discord";
|
|
64
|
+
this.capabilities = testSeedChatCapabilities;
|
|
60
65
|
}
|
|
61
66
|
async normalizeInbound(raw) {
|
|
62
67
|
const frame = raw.raw ?? {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/lib/test-seed.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n ChannelAdapter,\n ChannelCapabilities,\n ChannelNativeContent,\n ConvertOutboundInput,\n GetMessageStatusInput,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n SendMessageInput,\n SendMessageResult,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from './adapter'\nimport { baseEmailCapabilities } from './email-capabilities'\nimport { hasChannelAdapter, registerChannelAdapter } from './adapter-registry-singleton'\n\n/**\n * Test-only channel seeding support.\n *\n * The ephemeral integration harness cannot connect a REAL email channel:\n * - IMAP/SMTP `validateCredentials` performs a live LOGIN against a mail server\n * (none exists in CI), so `POST /channels/connect/credentials` returns 422.\n * - Even with a connected channel, the outbound delivery worker calls the real\n * SMTP adapter, which fails with no server \u2014 so `communication_channels.message.sent`\n * never fires and the customers link subscriber never runs.\n *\n * To make the compose \u2192 deliver \u2192 `.sent` \u2192 CRM-link \u2192 cross-user-visibility chain\n * (TC-CRM-EMAIL-001) and the inbound auto-link chain (TC-CRM-EMAIL-002..005) runnable\n * end-to-end against real Postgres, this module provides a network-free stub adapter\n * that is registered ONLY when `OM_ENABLE_TEST_CHANNEL_SEEDING` is set.\n *\n * Production safety: the registration is gated by {@link isTestChannelSeedingEnabled};\n * when the env flag is unset (the production default) the adapter is never registered\n * and the `__test_seed__` provider key resolves to no adapter \u2014 so the connect route\n * returns 404 `no_adapter` exactly as it would for any unknown provider. The dedicated\n * test-seed API route enforces the same gate independently (fail-closed 404 in prod).\n */\n\n/** Provider key for the network-free test stub adapter. */\nexport const TEST_SEED_PROVIDER_KEY = '__test_seed__'\n\n/**\n * Provider key for the network-free stub adapter that stands in for a CHAT\n * provider \u2014 one whose senders are identified by an opaque handle and have no\n * email address at all (Discord, Slack, Telegram\u2026).\n *\n * It exists because the email-shaped stub above can only ever prove the hub\n * accepts email-shaped data. That is precisely how CI stayed green while every\n * real inbound Discord message was rejected (#4975): the fixture invented an\n * address the provider can never produce. Tests that need to prove the hub's\n * non-email identity contract MUST drive this provider instead.\n */\nexport const TEST_SEED_CHAT_PROVIDER_KEY = '__test_seed_chat__'\n\n/** Env flag that unlocks test-only channel seeding. Off in production. */\nexport const TEST_CHANNEL_SEEDING_ENV = 'OM_ENABLE_TEST_CHANNEL_SEEDING'\n\n/**\n * True only when the test-seeding env flag is explicitly enabled. Accepts the\n * usual truthy tokens (`1`, `true`, `yes`, `on`) so the harness can opt in via a\n * plain `=true`. Any other value (including unset) is treated as disabled.\n */\nexport function isTestChannelSeedingEnabled(): boolean {\n const raw = process.env[TEST_CHANNEL_SEEDING_ENV]\n if (typeof raw !== 'string') return false\n return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())\n}\n\n/**\n * Capabilities for the stub: an email channel that supports neither reactions,\n * edit/delete, nor conversation history \u2014 so the strict registry validator\n * (`validateAdapterCapabilities`) requires only the core method surface.\n */\nconst testSeedCapabilities: ChannelCapabilities = {\n ...baseEmailCapabilities,\n conversationHistory: false,\n realtimePush: false,\n}\n\n/**\n * A `ChannelAdapter` whose `sendMessage` reports a successful send WITHOUT any\n * network I/O. Used exclusively by the integration harness to let the outbound\n * delivery worker reach its success path and emit `communication_channels.message.sent`.\n */\nclass TestSeedChannelAdapter implements ChannelAdapter {\n // Widened to `string` rather than inferred as a literal so the chat-flavoured\n // subclass below can override both with its own provider key / channel type.\n readonly providerKey: string = TEST_SEED_PROVIDER_KEY\n readonly channelType: string = 'email'\n readonly capabilities = testSeedCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n // Synthesize a deterministic-looking RFC2822-style message id; never touches\n // the network. The delivery worker persists this as the external message id.\n const externalMessageId = `test-seed-${Date.now()}-${Math.random().toString(16).slice(2, 10)}@test-seed.local`\n return {\n externalMessageId,\n conversationId: input.conversationId,\n status: 'sent',\n metadata: { testSeed: true },\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n // No real webhook \u2014 return the inert event so the generic webhook route 202s\n // without enqueuing tenant-scoped work (mirrors the IMAP adapter contract).\n return { raw: {}, eventType: 'other', metadata: { reason: 'test-seed-no-webhook' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return {\n content: {\n text: input.body,\n bodyFormat: input.bodyFormat,\n },\n metadata: input.channelMetadata ?? {},\n }\n }\n\n async normalizeInbound(_raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // The test-seed inbound path seeds MessageChannelLink rows directly and emits\n // the hub event, so this adapter never normalizes a raw inbound payload.\n throw new Error('[internal] TestSeedChannelAdapter.normalizeInbound is not used by the seed harness')\n }\n\n async validateCredentials(_input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n // No real server to authenticate against \u2014 accept any credentials so the\n // connect command persists a connected channel.\n return { ok: true }\n }\n}\n\n/**\n * Chat-flavoured twin of {@link TestSeedChannelAdapter}: same network-free\n * behaviour, but it declares a non-email `channelType`, so a channel connected\n * through it is shaped like a real chat channel \u2014 including an\n * `externalIdentifier` of NULL when no email-ish credential key is supplied.\n */\nclass TestSeedChatChannelAdapter extends TestSeedChannelAdapter {\n readonly providerKey: string = TEST_SEED_CHAT_PROVIDER_KEY\n readonly channelType: string = 'discord'\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // Unlike the email stub, this one is reachable: the test-seed ingest action\n // feeds it a chat-shaped frame so the message travels the real ingest path\n // (and therefore the real compose validation) rather than a SQL shortcut.\n const frame = (raw.raw ?? {}) as Record<string, unknown>\n const senderIdentifier = String(frame.senderIdentifier ?? '')\n if (!senderIdentifier) {\n throw new Error('[internal] TestSeedChatChannelAdapter requires a senderIdentifier')\n }\n return {\n externalMessageId: String(frame.externalMessageId ?? ''),\n externalConversationId: String(frame.externalConversationId ?? ''),\n senderIdentifier,\n senderDisplayName:\n typeof frame.senderDisplayName === 'string' ? frame.senderDisplayName : undefined,\n body: typeof frame.body === 'string' ? frame.body : '',\n bodyFormat: 'text',\n timestamp: new Date(),\n channelPayload: {},\n channelContentType: 'text/plain',\n channelMetadata: {},\n }\n }\n}\n\nlet cachedTestSeedAdapter: TestSeedChannelAdapter | null = null\nlet cachedTestSeedChatAdapter: TestSeedChatChannelAdapter | null = null\n\nfunction getTestSeedChannelAdapter(): TestSeedChannelAdapter {\n if (!cachedTestSeedAdapter) cachedTestSeedAdapter = new TestSeedChannelAdapter()\n return cachedTestSeedAdapter\n}\n\nfunction getTestSeedChatChannelAdapter(): TestSeedChatChannelAdapter {\n if (!cachedTestSeedChatAdapter) cachedTestSeedChatAdapter = new TestSeedChatChannelAdapter()\n return cachedTestSeedChatAdapter\n}\n\n/**\n * Register the test-seed adapter exactly once, but ONLY when the env flag is set.\n * Idempotent and safe to call from every container creation (`di.register`) \u2014 a\n * no-op when seeding is disabled or the adapter is already registered.\n */\nexport function ensureTestSeedAdapterRegistered(): void {\n if (!isTestChannelSeedingEnabled()) return\n if (!hasChannelAdapter(TEST_SEED_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChannelAdapter())\n }\n if (!hasChannelAdapter(TEST_SEED_CHAT_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChatChannelAdapter())\n }\n}\n"],
|
|
5
|
-
"mappings": "AAeA,SAAS,6BAA6B;AACtC,SAAS,mBAAmB,8BAA8B;AAyBnD,MAAM,yBAAyB;AAa/B,MAAM,8BAA8B;AAGpC,MAAM,2BAA2B;AAOjC,SAAS,8BAAuC;AACrD,QAAM,MAAM,QAAQ,IAAI,wBAAwB;AAChD,MAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,SAAO,CAAC,KAAK,QAAQ,OAAO,IAAI,EAAE,SAAS,IAAI,KAAK,EAAE,YAAY,CAAC;AACrE;AAOA,MAAM,uBAA4C;AAAA,EAChD,GAAG;AAAA,EACH,qBAAqB;AAAA,EACrB,cAAc;AAChB;AAOA,MAAM,uBAAiD;AAAA,EAAvD;AAGE;AAAA;AAAA,SAAS,cAAsB;AAC/B,SAAS,cAAsB;AAC/B,SAAS,eAAe;AAAA;AAAA,EAExB,MAAM,YAAY,OAAqD;AAGrE,UAAM,oBAAoB,aAAa,KAAK,IAAI,CAAC,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,EAAE,CAAC;AAC5F,WAAO;AAAA,MACL;AAAA,MACA,gBAAgB,MAAM;AAAA,MACtB,QAAQ;AAAA,MACR,UAAU,EAAE,UAAU,KAAK;AAAA,IAC7B;AAAA,EACF;AAAA,EAEA,MAAM,cAAc,QAAqD;AAGvE,WAAO,EAAE,KAAK,CAAC,GAAG,WAAW,SAAS,UAAU,EAAE,QAAQ,uBAAuB,EAAE;AAAA,EACrF;AAAA,EAEA,MAAM,UAAU,QAAuD;AACrE,WAAO,EAAE,QAAQ,OAAO;AAAA,EAC1B;AAAA,EAEA,MAAM,gBAAgB,OAA4D;AAChF,WAAO;AAAA,MACL,SAAS;AAAA,QACP,MAAM,MAAM;AAAA,QACZ,YAAY,MAAM;AAAA,MACpB;AAAA,MACA,UAAU,MAAM,mBAAmB,CAAC;AAAA,IACtC;AAAA,EACF;AAAA,EAEA,MAAM,iBAAiB,MAAyD;AAG9E,UAAM,IAAI,MAAM,oFAAoF;AAAA,EACtG;AAAA,EAEA,MAAM,oBAAoB,QAAsE;AAG9F,WAAO,EAAE,IAAI,KAAK;AAAA,EACpB;AACF;AAQA,MAAM,mCAAmC,uBAAuB;AAAA,EAAhE;AAAA;AACE,SAAS,cAAsB;AAC/B,SAAS,cAAsB;AAAA;AAAA,
|
|
4
|
+
"sourcesContent": ["import type {\n ChannelAdapter,\n ChannelCapabilities,\n ChannelNativeContent,\n ConvertOutboundInput,\n GetMessageStatusInput,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n SendMessageInput,\n SendMessageResult,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from './adapter'\nimport { baseEmailCapabilities } from './email-capabilities'\nimport { hasChannelAdapter, registerChannelAdapter } from './adapter-registry-singleton'\n\n/**\n * Test-only channel seeding support.\n *\n * The ephemeral integration harness cannot connect a REAL email channel:\n * - IMAP/SMTP `validateCredentials` performs a live LOGIN against a mail server\n * (none exists in CI), so `POST /channels/connect/credentials` returns 422.\n * - Even with a connected channel, the outbound delivery worker calls the real\n * SMTP adapter, which fails with no server \u2014 so `communication_channels.message.sent`\n * never fires and the customers link subscriber never runs.\n *\n * To make the compose \u2192 deliver \u2192 `.sent` \u2192 CRM-link \u2192 cross-user-visibility chain\n * (TC-CRM-EMAIL-001) and the inbound auto-link chain (TC-CRM-EMAIL-002..005) runnable\n * end-to-end against real Postgres, this module provides a network-free stub adapter\n * that is registered ONLY when `OM_ENABLE_TEST_CHANNEL_SEEDING` is set.\n *\n * Production safety: the registration is gated by {@link isTestChannelSeedingEnabled};\n * when the env flag is unset (the production default) the adapter is never registered\n * and the `__test_seed__` provider key resolves to no adapter \u2014 so the connect route\n * returns 404 `no_adapter` exactly as it would for any unknown provider. The dedicated\n * test-seed API route enforces the same gate independently (fail-closed 404 in prod).\n */\n\n/** Provider key for the network-free test stub adapter. */\nexport const TEST_SEED_PROVIDER_KEY = '__test_seed__'\n\n/**\n * Provider key for the network-free stub adapter that stands in for a CHAT\n * provider \u2014 one whose senders are identified by an opaque handle and have no\n * email address at all (Discord, Slack, Telegram\u2026).\n *\n * It exists because the email-shaped stub above can only ever prove the hub\n * accepts email-shaped data. That is precisely how CI stayed green while every\n * real inbound Discord message was rejected (#4975): the fixture invented an\n * address the provider can never produce. Tests that need to prove the hub's\n * non-email identity contract MUST drive this provider instead.\n */\nexport const TEST_SEED_CHAT_PROVIDER_KEY = '__test_seed_chat__'\n\n/** Env flag that unlocks test-only channel seeding. Off in production. */\nexport const TEST_CHANNEL_SEEDING_ENV = 'OM_ENABLE_TEST_CHANNEL_SEEDING'\n\n/**\n * True only when the test-seeding env flag is explicitly enabled. Accepts the\n * usual truthy tokens (`1`, `true`, `yes`, `on`) so the harness can opt in via a\n * plain `=true`. Any other value (including unset) is treated as disabled.\n */\nexport function isTestChannelSeedingEnabled(): boolean {\n const raw = process.env[TEST_CHANNEL_SEEDING_ENV]\n if (typeof raw !== 'string') return false\n return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())\n}\n\n/**\n * Capabilities for the stub: an email channel that supports neither reactions,\n * edit/delete, nor conversation history \u2014 so the strict registry validator\n * (`validateAdapterCapabilities`) requires only the core method surface.\n */\nconst testSeedCapabilities: ChannelCapabilities = {\n ...baseEmailCapabilities,\n conversationHistory: false,\n realtimePush: false,\n}\n\n/**\n * A `ChannelAdapter` whose `sendMessage` reports a successful send WITHOUT any\n * network I/O. Used exclusively by the integration harness to let the outbound\n * delivery worker reach its success path and emit `communication_channels.message.sent`.\n */\nclass TestSeedChannelAdapter implements ChannelAdapter {\n // Widened to `string` rather than inferred as a literal so the chat-flavoured\n // subclass below can override both with its own provider key / channel type.\n readonly providerKey: string = TEST_SEED_PROVIDER_KEY\n readonly channelType: string = 'email'\n readonly capabilities = testSeedCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n // Synthesize a deterministic-looking RFC2822-style message id; never touches\n // the network. The delivery worker persists this as the external message id.\n const externalMessageId = `test-seed-${Date.now()}-${Math.random().toString(16).slice(2, 10)}@test-seed.local`\n return {\n externalMessageId,\n conversationId: input.conversationId,\n status: 'sent',\n metadata: { testSeed: true },\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n // No real webhook \u2014 return the inert event so the generic webhook route 202s\n // without enqueuing tenant-scoped work (mirrors the IMAP adapter contract).\n return { raw: {}, eventType: 'other', metadata: { reason: 'test-seed-no-webhook' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return {\n content: {\n text: input.body,\n bodyFormat: input.bodyFormat,\n },\n metadata: input.channelMetadata ?? {},\n }\n }\n\n async normalizeInbound(_raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // The test-seed inbound path seeds MessageChannelLink rows directly and emits\n // the hub event, so this adapter never normalizes a raw inbound payload.\n throw new Error('[internal] TestSeedChannelAdapter.normalizeInbound is not used by the seed harness')\n }\n\n async validateCredentials(_input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n // No real server to authenticate against \u2014 accept any credentials so the\n // connect command persists a connected channel.\n return { ok: true }\n }\n}\n\n/**\n * Capabilities for the chat stub. Identical to the email stub's except for the\n * recipient shape: a chat channel is addressed by a provider-issued identifier\n * (a Discord snowflake), not an email address.\n *\n * Without this override the chat stub inherited `baseEmailCapabilities`, so it\n * claimed `channelType: 'discord'` while validating recipients as email\n * addresses \u2014 a channel that could not be addressed the way the provider it\n * imitates actually is. That left the hub's `'provider-native'` branch\n * (#4976: per-provider recipient validation, and the omitted recipient that\n * falls back to the adapter's own target) reachable only with a live Discord\n * bot, so no integration test could cover it and CI never exercised it.\n */\nconst testSeedChatCapabilities: ChannelCapabilities = {\n ...testSeedCapabilities,\n recipientFormat: 'provider-native',\n}\n\n/**\n * Chat-flavoured twin of {@link TestSeedChannelAdapter}: same network-free\n * behaviour, but it declares a non-email `channelType`, so a channel connected\n * through it is shaped like a real chat channel \u2014 including an\n * `externalIdentifier` of NULL when no email-ish credential key is supplied.\n */\nclass TestSeedChatChannelAdapter extends TestSeedChannelAdapter {\n readonly providerKey: string = TEST_SEED_CHAT_PROVIDER_KEY\n readonly channelType: string = 'discord'\n readonly capabilities = testSeedChatCapabilities\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // Unlike the email stub, this one is reachable: the test-seed ingest action\n // feeds it a chat-shaped frame so the message travels the real ingest path\n // (and therefore the real compose validation) rather than a SQL shortcut.\n const frame = (raw.raw ?? {}) as Record<string, unknown>\n const senderIdentifier = String(frame.senderIdentifier ?? '')\n if (!senderIdentifier) {\n throw new Error('[internal] TestSeedChatChannelAdapter requires a senderIdentifier')\n }\n return {\n externalMessageId: String(frame.externalMessageId ?? ''),\n externalConversationId: String(frame.externalConversationId ?? ''),\n senderIdentifier,\n senderDisplayName:\n typeof frame.senderDisplayName === 'string' ? frame.senderDisplayName : undefined,\n body: typeof frame.body === 'string' ? frame.body : '',\n bodyFormat: 'text',\n timestamp: new Date(),\n channelPayload: {},\n channelContentType: 'text/plain',\n channelMetadata: {},\n }\n }\n}\n\nlet cachedTestSeedAdapter: TestSeedChannelAdapter | null = null\nlet cachedTestSeedChatAdapter: TestSeedChatChannelAdapter | null = null\n\nfunction getTestSeedChannelAdapter(): TestSeedChannelAdapter {\n if (!cachedTestSeedAdapter) cachedTestSeedAdapter = new TestSeedChannelAdapter()\n return cachedTestSeedAdapter\n}\n\nfunction getTestSeedChatChannelAdapter(): TestSeedChatChannelAdapter {\n if (!cachedTestSeedChatAdapter) cachedTestSeedChatAdapter = new TestSeedChatChannelAdapter()\n return cachedTestSeedChatAdapter\n}\n\n/**\n * Register the test-seed adapter exactly once, but ONLY when the env flag is set.\n * Idempotent and safe to call from every container creation (`di.register`) \u2014 a\n * no-op when seeding is disabled or the adapter is already registered.\n */\nexport function ensureTestSeedAdapterRegistered(): void {\n if (!isTestChannelSeedingEnabled()) return\n if (!hasChannelAdapter(TEST_SEED_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChannelAdapter())\n }\n if (!hasChannelAdapter(TEST_SEED_CHAT_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChatChannelAdapter())\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/customers/backend/customers/deals/%5Bid%5D/hooks/useDealClosure.ts"],
|
|
4
|
-
"sourcesContent": ["import * as React from 'react'\nimport { updateCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { DealStatsPayload, GuardedMutationRunner } from './types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\ntype UseDealClosureOptions = {\n currentDealId: string | null\n dealUpdatedAt: string | null\n runMutationWithContext: GuardedMutationRunner\n confirmDiscardIfDirty: () => Promise<boolean>\n onClosed: () => Promise<void>\n}\n\ntype UseDealClosureResult = {\n lostDialogOpen: boolean\n wonPopupOpen: boolean\n lostPopupOpen: boolean\n wonStats: DealStatsPayload | null\n lostStats: DealStatsPayload | null\n openLostDialog: () => void\n closeLostDialog: () => void\n closeWonPopup: () => void\n closeLostPopup: () => void\n handleWon: () => Promise<void>\n handleLostConfirm: (input: { lossReasonId: string; lossNotes?: string }) => Promise<void>\n}\n\nexport function useDealClosure({\n currentDealId,\n dealUpdatedAt,\n runMutationWithContext,\n confirmDiscardIfDirty,\n onClosed,\n}: UseDealClosureOptions): UseDealClosureResult {\n const t = useT()\n const [lostDialogOpen, setLostDialogOpen] = React.useState(false)\n const [wonPopupOpen, setWonPopupOpen] = React.useState(false)\n const [lostPopupOpen, setLostPopupOpen] = React.useState(false)\n const [wonStats, setWonStats] = React.useState<DealStatsPayload | null>(null)\n const [lostStats, setLostStats] = React.useState<DealStatsPayload | null>(null)\n\n const fetchDealStats = React.useCallback(async (): Promise<DealStatsPayload | null> => {\n if (!currentDealId) return null\n try {\n return await readApiResultOrThrow<DealStatsPayload>(\n `/api/customers/deals/${encodeURIComponent(currentDealId)}/stats`,\n )\n } catch (statsError) {\n logger.error('customers.deals.detail.stats failed', { statsError })\n return null\n }\n }, [currentDealId])\n\n const handleWon = React.useCallback(async () => {\n if (!currentDealId) return\n if (!(await confirmDiscardIfDirty())) return\n try {\n await runMutationWithContext(\n () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(dealUpdatedAt),\n () => updateCrud('customers/deals', { id: currentDealId, closureOutcome: 'won', status: 'win' }),\n ),\n { id: currentDealId, closureOutcome: 'won', status: 'win', operation: 'closeWon' },\n )\n } catch (err) {\n if (!surfaceRecordConflict(err, t, { onRefresh: () => { void onClosed() } })) {\n flash(t('customers.deals.detail.closeWonError', 'Failed to mark deal as won.'), 'error')\n }\n return\n }\n const stats = await fetchDealStats()\n setWonStats(stats)\n setWonPopupOpen(true)\n await onClosed()\n }, [confirmDiscardIfDirty, currentDealId, dealUpdatedAt, fetchDealStats, onClosed, runMutationWithContext, t])\n\n const handleLostConfirm = React.useCallback(\n async (input: { lossReasonId: string; lossNotes?: string }) => {\n if (!currentDealId) return\n if (!(await confirmDiscardIfDirty())) return\n const lossPayload = {\n id: currentDealId,\n closureOutcome: 'lost' as const,\n status: '
|
|
4
|
+
"sourcesContent": ["import * as React from 'react'\nimport { updateCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { DealStatsPayload, GuardedMutationRunner } from './types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\ntype UseDealClosureOptions = {\n currentDealId: string | null\n dealUpdatedAt: string | null\n runMutationWithContext: GuardedMutationRunner\n confirmDiscardIfDirty: () => Promise<boolean>\n onClosed: () => Promise<void>\n}\n\ntype UseDealClosureResult = {\n lostDialogOpen: boolean\n wonPopupOpen: boolean\n lostPopupOpen: boolean\n wonStats: DealStatsPayload | null\n lostStats: DealStatsPayload | null\n openLostDialog: () => void\n closeLostDialog: () => void\n closeWonPopup: () => void\n closeLostPopup: () => void\n handleWon: () => Promise<void>\n handleLostConfirm: (input: { lossReasonId: string; lossNotes?: string }) => Promise<void>\n}\n\nexport function useDealClosure({\n currentDealId,\n dealUpdatedAt,\n runMutationWithContext,\n confirmDiscardIfDirty,\n onClosed,\n}: UseDealClosureOptions): UseDealClosureResult {\n const t = useT()\n const [lostDialogOpen, setLostDialogOpen] = React.useState(false)\n const [wonPopupOpen, setWonPopupOpen] = React.useState(false)\n const [lostPopupOpen, setLostPopupOpen] = React.useState(false)\n const [wonStats, setWonStats] = React.useState<DealStatsPayload | null>(null)\n const [lostStats, setLostStats] = React.useState<DealStatsPayload | null>(null)\n\n const fetchDealStats = React.useCallback(async (): Promise<DealStatsPayload | null> => {\n if (!currentDealId) return null\n try {\n return await readApiResultOrThrow<DealStatsPayload>(\n `/api/customers/deals/${encodeURIComponent(currentDealId)}/stats`,\n )\n } catch (statsError) {\n logger.error('customers.deals.detail.stats failed', { statsError })\n return null\n }\n }, [currentDealId])\n\n const handleWon = React.useCallback(async () => {\n if (!currentDealId) return\n if (!(await confirmDiscardIfDirty())) return\n try {\n await runMutationWithContext(\n () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(dealUpdatedAt),\n () => updateCrud('customers/deals', { id: currentDealId, closureOutcome: 'won', status: 'win' }),\n ),\n { id: currentDealId, closureOutcome: 'won', status: 'win', operation: 'closeWon' },\n )\n } catch (err) {\n if (!surfaceRecordConflict(err, t, { onRefresh: () => { void onClosed() } })) {\n flash(t('customers.deals.detail.closeWonError', 'Failed to mark deal as won.'), 'error')\n }\n return\n }\n const stats = await fetchDealStats()\n setWonStats(stats)\n setWonPopupOpen(true)\n await onClosed()\n }, [confirmDiscardIfDirty, currentDealId, dealUpdatedAt, fetchDealStats, onClosed, runMutationWithContext, t])\n\n const handleLostConfirm = React.useCallback(\n async (input: { lossReasonId: string; lossNotes?: string }) => {\n if (!currentDealId) return\n if (!(await confirmDiscardIfDirty())) return\n const lossPayload = {\n id: currentDealId,\n closureOutcome: 'lost' as const,\n status: 'lost',\n lossReasonId: input.lossReasonId,\n ...(input.lossNotes ? { lossNotes: input.lossNotes } : {}),\n }\n try {\n await runMutationWithContext(\n () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(dealUpdatedAt),\n () => updateCrud('customers/deals', lossPayload),\n ),\n {\n ...lossPayload,\n operation: 'closeLost',\n },\n )\n } catch (err) {\n if (!surfaceRecordConflict(err, t, { onRefresh: () => { void onClosed() } })) {\n flash(t('customers.deals.detail.closeLostError', 'Failed to mark deal as lost.'), 'error')\n }\n return\n }\n setLostDialogOpen(false)\n const stats = await fetchDealStats()\n setLostStats(stats)\n setLostPopupOpen(true)\n await onClosed()\n },\n [confirmDiscardIfDirty, currentDealId, dealUpdatedAt, fetchDealStats, onClosed, runMutationWithContext, t],\n )\n\n const openLostDialog = React.useCallback(() => setLostDialogOpen(true), [])\n const closeLostDialog = React.useCallback(() => setLostDialogOpen(false), [])\n const closeWonPopup = React.useCallback(() => setWonPopupOpen(false), [])\n const closeLostPopup = React.useCallback(() => setLostPopupOpen(false), [])\n\n return {\n lostDialogOpen,\n wonPopupOpen,\n lostPopupOpen,\n wonStats,\n lostStats,\n openLostDialog,\n closeLostDialog,\n closeWonPopup,\n closeLostPopup,\n handleWon,\n handleLostConfirm,\n }\n}\n"],
|
|
5
5
|
"mappings": "AAAA,YAAY,WAAW;AACvB,SAAS,kBAAkB;AAC3B,SAAS,sBAAsB,mCAAmC;AAClE,SAAS,iCAAiC;AAC1C,SAAS,6BAA6B;AACtC,SAAS,aAAa;AACtB,SAAS,YAAY;AAErB,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW;AAwBhC,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAgD;AAC9C,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM,SAAS,KAAK;AAChE,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAS,KAAK;AAC5D,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAM,SAAS,KAAK;AAC9D,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAkC,IAAI;AAC5E,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAkC,IAAI;AAE9E,QAAM,iBAAiB,MAAM,YAAY,YAA8C;AACrF,QAAI,CAAC,cAAe,QAAO;AAC3B,QAAI;AACF,aAAO,MAAM;AAAA,QACX,wBAAwB,mBAAmB,aAAa,CAAC;AAAA,MAC3D;AAAA,IACF,SAAS,YAAY;AACnB,aAAO,MAAM,uCAAuC,EAAE,WAAW,CAAC;AAClE,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAC,aAAa,CAAC;AAElB,QAAM,YAAY,MAAM,YAAY,YAAY;AAC9C,QAAI,CAAC,cAAe;AACpB,QAAI,CAAE,MAAM,sBAAsB,EAAI;AACtC,QAAI;AACF,YAAM;AAAA,QACJ,MAAM;AAAA,UACJ,0BAA0B,aAAa;AAAA,UACvC,MAAM,WAAW,mBAAmB,EAAE,IAAI,eAAe,gBAAgB,OAAO,QAAQ,MAAM,CAAC;AAAA,QACjG;AAAA,QACA,EAAE,IAAI,eAAe,gBAAgB,OAAO,QAAQ,OAAO,WAAW,WAAW;AAAA,MACnF;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,CAAC,sBAAsB,KAAK,GAAG,EAAE,WAAW,MAAM;AAAE,aAAK,SAAS;AAAA,MAAE,EAAE,CAAC,GAAG;AAC5E,cAAM,EAAE,wCAAwC,6BAA6B,GAAG,OAAO;AAAA,MACzF;AACA;AAAA,IACF;AACA,UAAM,QAAQ,MAAM,eAAe;AACnC,gBAAY,KAAK;AACjB,oBAAgB,IAAI;AACpB,UAAM,SAAS;AAAA,EACjB,GAAG,CAAC,uBAAuB,eAAe,eAAe,gBAAgB,UAAU,wBAAwB,CAAC,CAAC;AAE7G,QAAM,oBAAoB,MAAM;AAAA,IAC9B,OAAO,UAAwD;AAC7D,UAAI,CAAC,cAAe;AACpB,UAAI,CAAE,MAAM,sBAAsB,EAAI;AACtC,YAAM,cAAc;AAAA,QAClB,IAAI;AAAA,QACJ,gBAAgB;AAAA,QAChB,QAAQ;AAAA,QACR,cAAc,MAAM;AAAA,QACpB,GAAI,MAAM,YAAY,EAAE,WAAW,MAAM,UAAU,IAAI,CAAC;AAAA,MAC1D;AACA,UAAI;AACF,cAAM;AAAA,UACJ,MAAM;AAAA,YACJ,0BAA0B,aAAa;AAAA,YACvC,MAAM,WAAW,mBAAmB,WAAW;AAAA,UACjD;AAAA,UACA;AAAA,YACE,GAAG;AAAA,YACH,WAAW;AAAA,UACb;AAAA,QACF;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,CAAC,sBAAsB,KAAK,GAAG,EAAE,WAAW,MAAM;AAAE,eAAK,SAAS;AAAA,QAAE,EAAE,CAAC,GAAG;AAC5E,gBAAM,EAAE,yCAAyC,8BAA8B,GAAG,OAAO;AAAA,QAC3F;AACA;AAAA,MACF;AACA,wBAAkB,KAAK;AACvB,YAAM,QAAQ,MAAM,eAAe;AACnC,mBAAa,KAAK;AAClB,uBAAiB,IAAI;AACrB,YAAM,SAAS;AAAA,IACjB;AAAA,IACA,CAAC,uBAAuB,eAAe,eAAe,gBAAgB,UAAU,wBAAwB,CAAC;AAAA,EAC3G;AAEA,QAAM,iBAAiB,MAAM,YAAY,MAAM,kBAAkB,IAAI,GAAG,CAAC,CAAC;AAC1E,QAAM,kBAAkB,MAAM,YAAY,MAAM,kBAAkB,KAAK,GAAG,CAAC,CAAC;AAC5E,QAAM,gBAAgB,MAAM,YAAY,MAAM,gBAAgB,KAAK,GAAG,CAAC,CAAC;AACxE,QAAM,iBAAiB,MAAM,YAAY,MAAM,iBAAiB,KAAK,GAAG,CAAC,CAAC;AAE1E,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -26,6 +26,7 @@ import { Avatar, AvatarStack } from "@open-mercato/ui/primitives/avatar";
|
|
|
26
26
|
import { Tag } from "@open-mercato/ui/primitives/tag";
|
|
27
27
|
import { SimpleTooltip } from "@open-mercato/ui/primitives/tooltip";
|
|
28
28
|
import { Briefcase, AlertTriangle, X } from "lucide-react";
|
|
29
|
+
import { isLostDealStatus, isWonDealStatus } from "../../../lib/dealStatus.js";
|
|
29
30
|
import { formatRelativeTime } from "@open-mercato/shared/lib/time";
|
|
30
31
|
import { ViewTabsRow } from "./pipeline/components/ViewTabsRow.js";
|
|
31
32
|
import { DealsKpiStrip } from "../../../components/DealsKpiStrip.js";
|
|
@@ -77,7 +78,7 @@ function makeDealsPresets() {
|
|
|
77
78
|
}
|
|
78
79
|
},
|
|
79
80
|
// The Deal entity has no dedicated "at risk" or health-score field — `customer_deals`
|
|
80
|
-
// exposes only `status` (open/win/
|
|
81
|
+
// exposes only `status` (open/win/lost/closed/in_progress, dictionary-driven) and
|
|
81
82
|
// `closure_outcome`. Rather than fabricate a mapping, the "At risk" preset is omitted
|
|
82
83
|
// until the data model exposes a first-class signal.
|
|
83
84
|
{
|
|
@@ -716,9 +717,9 @@ function CustomersDealsPage() {
|
|
|
716
717
|
let subtitle = null;
|
|
717
718
|
if (isDealOverdue(row.original)) {
|
|
718
719
|
subtitle = /* @__PURE__ */ jsx("span", { className: "text-xs text-status-error-text", children: t("customers.deals.list.close.overdue") });
|
|
719
|
-
} else if (row.original.status
|
|
720
|
+
} else if (isWonDealStatus(row.original.status)) {
|
|
720
721
|
subtitle = /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: t("customers.deals.list.close.won") });
|
|
721
|
-
} else if (row.original.status
|
|
722
|
+
} else if (isLostDealStatus(row.original.status)) {
|
|
722
723
|
subtitle = /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: t("customers.deals.list.close.lost") });
|
|
723
724
|
} else {
|
|
724
725
|
const relative = formatRelativeTime(expectedCloseAt, { locale, translate: t });
|