@open-mercato/core 0.9.0 → 0.9.1-develop.7459.1.99e3accd30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_dictionary_entry/index.js +2 -0
  3. package/dist/generated/entities/customer_dictionary_entry/index.js.map +2 -2
  4. package/dist/generated/entities/customer_interaction/index.js +2 -0
  5. package/dist/generated/entities/customer_interaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/commands/categories.js +1 -1
  9. package/dist/modules/catalog/commands/categories.js.map +2 -2
  10. package/dist/modules/catalog/commands/productDeleteChildren.js +389 -0
  11. package/dist/modules/catalog/commands/productDeleteChildren.js.map +7 -0
  12. package/dist/modules/catalog/commands/products.js +52 -17
  13. package/dist/modules/catalog/commands/products.js.map +2 -2
  14. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js +4 -6
  15. package/dist/modules/communication_channels/api/post/channels/connect/credentials/route.js.map +2 -2
  16. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js +4 -6
  17. package/dist/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.js.map +2 -2
  18. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js +47 -11
  19. package/dist/modules/communication_channels/api/post/oauth/[provider]/initiate/route.js.map +3 -3
  20. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -1
  21. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  22. package/dist/modules/communication_channels/lib/inbound-message-origin.js +1 -1
  23. package/dist/modules/communication_channels/lib/inbound-message-origin.js.map +2 -2
  24. package/dist/modules/communication_channels/lib/oauth-state.js +4 -1
  25. package/dist/modules/communication_channels/lib/oauth-state.js.map +2 -2
  26. package/dist/modules/communication_channels/lib/use-connect-channel.js +10 -0
  27. package/dist/modules/communication_channels/lib/use-connect-channel.js.map +2 -2
  28. package/dist/modules/customers/api/activity-types/route.js +151 -0
  29. package/dist/modules/customers/api/activity-types/route.js.map +7 -0
  30. package/dist/modules/customers/api/deals/[id]/people/route.js +58 -5
  31. package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
  32. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js +13 -5
  33. package/dist/modules/customers/api/dictionaries/[kind]/[id]/route.js.map +2 -2
  34. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -5
  35. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  36. package/dist/modules/customers/api/interactions/route.js +3 -0
  37. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  38. package/dist/modules/customers/api/interactions/tasks/route.js +1 -0
  39. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  40. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +18 -0
  41. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +2 -2
  42. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +0 -23
  43. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/deals/[id]/page.js +15 -17
  45. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  46. package/dist/modules/customers/calendar-event-types.js +548 -0
  47. package/dist/modules/customers/calendar-event-types.js.map +7 -0
  48. package/dist/modules/customers/commands/dictionaries.js +70 -10
  49. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  50. package/dist/modules/customers/commands/interactions.js +187 -22
  51. package/dist/modules/customers/commands/interactions.js.map +3 -3
  52. package/dist/modules/customers/components/ActivityTypeEditor.js +288 -0
  53. package/dist/modules/customers/components/ActivityTypeEditor.js.map +7 -0
  54. package/dist/modules/customers/components/DictionarySettings.js +3 -1
  55. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  56. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +391 -297
  57. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +3 -3
  58. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +9 -74
  59. package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
  60. package/dist/modules/customers/components/calendar/TimeGrid.js +20 -16
  61. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  62. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js +218 -0
  63. package/dist/modules/customers/components/calendar/editor/EventTypePanel.js.map +7 -0
  64. package/dist/modules/customers/components/calendar/editor/LocationField.js +7 -3
  65. package/dist/modules/customers/components/calendar/editor/LocationField.js.map +2 -2
  66. package/dist/modules/customers/components/calendar/editor/PeopleField.js +5 -2
  67. package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
  68. package/dist/modules/customers/components/calendar/editor/PriorityField.js +3 -1
  69. package/dist/modules/customers/components/calendar/editor/PriorityField.js.map +2 -2
  70. package/dist/modules/customers/components/calendar/editor/RepeatField.js +9 -1
  71. package/dist/modules/customers/components/calendar/editor/RepeatField.js.map +2 -2
  72. package/dist/modules/customers/components/calendar/editor/ResourcesField.js +4 -1
  73. package/dist/modules/customers/components/calendar/editor/ResourcesField.js.map +2 -2
  74. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +15 -7
  75. package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
  76. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js +3 -1
  77. package/dist/modules/customers/components/calendar/editor/SegmentGroup.js.map +2 -2
  78. package/dist/modules/customers/components/calendar/editor/hooks.js +28 -13
  79. package/dist/modules/customers/components/calendar/editor/hooks.js.map +3 -3
  80. package/dist/modules/customers/components/calendar/editor/inputs.js +14 -6
  81. package/dist/modules/customers/components/calendar/editor/inputs.js.map +2 -2
  82. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js +54 -0
  83. package/dist/modules/customers/components/calendar/editor/useCalendarCustomFields.js.map +7 -0
  84. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js +82 -0
  85. package/dist/modules/customers/components/calendar/editor/useEventTypeCatalog.js.map +7 -0
  86. package/dist/modules/customers/components/calendar/types.js +1 -0
  87. package/dist/modules/customers/components/calendar/types.js.map +2 -2
  88. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +78 -343
  89. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  90. package/dist/modules/customers/components/detail/CreatePersonDialog.js +12 -11
  91. package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
  92. package/dist/modules/customers/components/detail/DealForm.js +7 -3
  93. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  94. package/dist/modules/customers/components/detail/DealPeopleSection.js +206 -0
  95. package/dist/modules/customers/components/detail/DealPeopleSection.js.map +7 -0
  96. package/dist/modules/customers/components/detail/LinkedPeopleSection.js +342 -0
  97. package/dist/modules/customers/components/detail/LinkedPeopleSection.js.map +7 -0
  98. package/dist/modules/customers/components/detail/PersonCard.js +5 -2
  99. package/dist/modules/customers/components/detail/PersonCard.js.map +2 -2
  100. package/dist/modules/customers/components/linking/adapters/personAdapter.js +3 -0
  101. package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
  102. package/dist/modules/customers/data/entities.js +6 -0
  103. package/dist/modules/customers/data/entities.js.map +2 -2
  104. package/dist/modules/customers/data/validators.js +39 -10
  105. package/dist/modules/customers/data/validators.js.map +2 -2
  106. package/dist/modules/customers/di.js +20 -1
  107. package/dist/modules/customers/di.js.map +2 -2
  108. package/dist/modules/customers/extension-points.js +13 -0
  109. package/dist/modules/customers/extension-points.js.map +2 -2
  110. package/dist/modules/customers/lib/calendar/conflicts.js +59 -16
  111. package/dist/modules/customers/lib/calendar/conflicts.js.map +2 -2
  112. package/dist/modules/customers/lib/calendar/editorPayload.js +34 -16
  113. package/dist/modules/customers/lib/calendar/editorPayload.js.map +2 -2
  114. package/dist/modules/customers/lib/calendar/eventTypeResolver.js +197 -0
  115. package/dist/modules/customers/lib/calendar/eventTypeResolver.js.map +7 -0
  116. package/dist/modules/customers/lib/calendar/interactionApplicability.js +67 -0
  117. package/dist/modules/customers/lib/calendar/interactionApplicability.js.map +7 -0
  118. package/dist/modules/customers/lib/calendar/mapItem.js +5 -2
  119. package/dist/modules/customers/lib/calendar/mapItem.js.map +2 -2
  120. package/dist/modules/customers/lib/calendar/recurrence.js +62 -1
  121. package/dist/modules/customers/lib/calendar/recurrence.js.map +2 -2
  122. package/dist/modules/customers/lib/calendar/timezone.js +111 -0
  123. package/dist/modules/customers/lib/calendar/timezone.js.map +7 -0
  124. package/dist/modules/customers/lib/interactionReadModel.js +1 -0
  125. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  126. package/dist/modules/customers/lib/todoCompatibility.js +1 -0
  127. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  128. package/dist/modules/customers/migrations/Migration20260928175058_customers.js +17 -0
  129. package/dist/modules/customers/migrations/Migration20260928175058_customers.js.map +7 -0
  130. package/dist/modules/customers/migrations/Migration20260930110235_customers.js +17 -0
  131. package/dist/modules/customers/migrations/Migration20260930110235_customers.js.map +7 -0
  132. package/dist/modules/dictionaries/components/DictionaryTable.js +60 -51
  133. package/dist/modules/dictionaries/components/DictionaryTable.js.map +2 -2
  134. package/dist/modules/messages/subscribers/message-notification.js +13 -1
  135. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  136. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js +201 -53
  137. package/dist/modules/payment_gateways/api/webhook/[provider]/route.js.map +2 -2
  138. package/dist/modules/payment_gateways/lib/gateway-service.js +37 -11
  139. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  140. package/dist/modules/planner/lib/availabilityMerge.js +101 -38
  141. package/dist/modules/planner/lib/availabilityMerge.js.map +3 -3
  142. package/dist/modules/planner/services/plannerAvailabilityService.js.map +2 -2
  143. package/dist/modules/sales/components/documents/LineItemDialog.js +159 -81
  144. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  145. package/dist/modules/sales/components/documents/lineItemUtils.js +7 -0
  146. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  147. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +12 -7
  148. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  149. package/dist/modules/sync_excel/lib/adapters/customers.js +28 -25
  150. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  151. package/generated/entities/customer_dictionary_entry/index.ts +1 -0
  152. package/generated/entities/customer_interaction/index.ts +1 -0
  153. package/generated/entity-fields-registry.ts +2 -0
  154. package/package.json +9 -8
  155. package/src/modules/auth/i18n/de.json +3 -0
  156. package/src/modules/auth/i18n/en.json +3 -0
  157. package/src/modules/auth/i18n/es.json +3 -0
  158. package/src/modules/auth/i18n/ko.json +3 -0
  159. package/src/modules/auth/i18n/pl.json +3 -0
  160. package/src/modules/catalog/commands/categories.ts +1 -1
  161. package/src/modules/catalog/commands/productDeleteChildren.ts +557 -0
  162. package/src/modules/catalog/commands/products.ts +58 -17
  163. package/src/modules/communication_channels/api/post/channels/connect/credentials/route.ts +4 -6
  164. package/src/modules/communication_channels/api/post/channels/connect/tenant-credentials/route.ts +4 -6
  165. package/src/modules/communication_channels/api/post/oauth/[provider]/initiate/route.ts +46 -10
  166. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +6 -1
  167. package/src/modules/communication_channels/i18n/de.json +4 -0
  168. package/src/modules/communication_channels/i18n/en.json +4 -0
  169. package/src/modules/communication_channels/i18n/es.json +4 -0
  170. package/src/modules/communication_channels/i18n/ko.json +4 -0
  171. package/src/modules/communication_channels/i18n/pl.json +4 -0
  172. package/src/modules/communication_channels/lib/inbound-message-origin.ts +2 -6
  173. package/src/modules/communication_channels/lib/oauth-state.ts +4 -1
  174. package/src/modules/communication_channels/lib/use-connect-channel.ts +10 -0
  175. package/src/modules/customers/api/activity-types/route.ts +154 -0
  176. package/src/modules/customers/api/deals/[id]/people/route.ts +82 -3
  177. package/src/modules/customers/api/dictionaries/[kind]/[id]/route.ts +9 -1
  178. package/src/modules/customers/api/dictionaries/[kind]/route.ts +18 -2
  179. package/src/modules/customers/api/interactions/route.ts +4 -0
  180. package/src/modules/customers/api/interactions/tasks/route.ts +1 -0
  181. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +20 -0
  182. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +0 -29
  183. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +15 -17
  184. package/src/modules/customers/calendar-event-types.ts +674 -0
  185. package/src/modules/customers/commands/dictionaries.ts +84 -8
  186. package/src/modules/customers/commands/interactions.ts +244 -21
  187. package/src/modules/customers/components/ActivityTypeEditor.tsx +239 -0
  188. package/src/modules/customers/components/DictionarySettings.tsx +5 -3
  189. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +347 -218
  190. package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +18 -96
  191. package/src/modules/customers/components/calendar/TimeGrid.tsx +20 -18
  192. package/src/modules/customers/components/calendar/editor/EventTypePanel.tsx +230 -0
  193. package/src/modules/customers/components/calendar/editor/LocationField.tsx +7 -1
  194. package/src/modules/customers/components/calendar/editor/PeopleField.tsx +4 -0
  195. package/src/modules/customers/components/calendar/editor/PriorityField.tsx +3 -0
  196. package/src/modules/customers/components/calendar/editor/RepeatField.tsx +10 -0
  197. package/src/modules/customers/components/calendar/editor/ResourcesField.tsx +4 -0
  198. package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +16 -4
  199. package/src/modules/customers/components/calendar/editor/SegmentGroup.tsx +3 -0
  200. package/src/modules/customers/components/calendar/editor/hooks.ts +29 -13
  201. package/src/modules/customers/components/calendar/editor/inputs.tsx +17 -5
  202. package/src/modules/customers/components/calendar/editor/useCalendarCustomFields.ts +49 -0
  203. package/src/modules/customers/components/calendar/editor/useEventTypeCatalog.ts +100 -0
  204. package/src/modules/customers/components/calendar/types.ts +1 -0
  205. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +94 -388
  206. package/src/modules/customers/components/detail/CreatePersonDialog.tsx +39 -19
  207. package/src/modules/customers/components/detail/DealForm.tsx +7 -3
  208. package/src/modules/customers/components/detail/DealPeopleSection.tsx +256 -0
  209. package/src/modules/customers/components/detail/LinkedPeopleSection.tsx +474 -0
  210. package/src/modules/customers/components/detail/PersonCard.tsx +12 -4
  211. package/src/modules/customers/components/detail/types.ts +1 -0
  212. package/src/modules/customers/components/linking/adapters/personAdapter.tsx +4 -0
  213. package/src/modules/customers/data/entities.ts +8 -1
  214. package/src/modules/customers/data/validators.ts +42 -7
  215. package/src/modules/customers/di.ts +21 -0
  216. package/src/modules/customers/extension-points.ts +13 -0
  217. package/src/modules/customers/i18n/de.json +104 -8
  218. package/src/modules/customers/i18n/en.json +104 -8
  219. package/src/modules/customers/i18n/es.json +104 -8
  220. package/src/modules/customers/i18n/ko.json +104 -8
  221. package/src/modules/customers/i18n/pl.json +104 -8
  222. package/src/modules/customers/lib/calendar/conflicts.ts +106 -27
  223. package/src/modules/customers/lib/calendar/editorPayload.ts +39 -19
  224. package/src/modules/customers/lib/calendar/eventTypeResolver.ts +258 -0
  225. package/src/modules/customers/lib/calendar/interactionApplicability.ts +99 -0
  226. package/src/modules/customers/lib/calendar/mapItem.ts +6 -2
  227. package/src/modules/customers/lib/calendar/recurrence.ts +65 -1
  228. package/src/modules/customers/lib/calendar/timezone.ts +117 -0
  229. package/src/modules/customers/lib/interactionReadModel.ts +1 -0
  230. package/src/modules/customers/lib/todoCompatibility.ts +2 -0
  231. package/src/modules/customers/migrations/.snapshot-open-mercato.json +34 -0
  232. package/src/modules/customers/migrations/Migration20260928175058_customers.ts +15 -0
  233. package/src/modules/customers/migrations/Migration20260930110235_customers.ts +15 -0
  234. package/src/modules/dictionaries/components/DictionaryTable.tsx +63 -56
  235. package/src/modules/messages/subscribers/message-notification.ts +13 -3
  236. package/src/modules/payment_gateways/api/webhook/[provider]/route.ts +243 -62
  237. package/src/modules/payment_gateways/lib/gateway-service.ts +39 -12
  238. package/src/modules/planner/lib/availabilityMerge.ts +106 -41
  239. package/src/modules/planner/services/plannerAvailabilityService.ts +4 -0
  240. package/src/modules/sales/components/documents/LineItemDialog.tsx +194 -111
  241. package/src/modules/sales/components/documents/lineItemUtils.ts +18 -0
  242. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +25 -13
  243. package/src/modules/sync_excel/lib/adapters/customers.ts +33 -27
@@ -33,16 +33,14 @@ export async function POST(req: Request): Promise<Response> {
33
33
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
34
34
  }
35
35
 
36
- let body: z.infer<typeof bodySchema>
37
- try {
38
- const json = await readJsonSafe(req, null)
39
- body = bodySchema.parse(json)
40
- } catch (err) {
36
+ const parsed = bodySchema.safeParse(await readJsonSafe(req, null))
37
+ if (!parsed.success) {
41
38
  return NextResponse.json(
42
- { error: err instanceof Error ? err.message : 'Invalid request body' },
39
+ { error: 'Invalid request body', details: parsed.error.issues },
43
40
  { status: 422 },
44
41
  )
45
42
  }
43
+ const body = parsed.data
46
44
 
47
45
  const container = await createRequestContainer()
48
46
 
@@ -33,16 +33,14 @@ export async function POST(req: Request): Promise<Response> {
33
33
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
34
34
  }
35
35
 
36
- let body: z.infer<typeof bodySchema>
37
- try {
38
- const json = await readJsonSafe(req, null)
39
- body = bodySchema.parse(json)
40
- } catch (err) {
36
+ const parsed = bodySchema.safeParse(await readJsonSafe(req, null))
37
+ if (!parsed.success) {
41
38
  return NextResponse.json(
42
- { error: err instanceof Error ? err.message : 'Invalid request body' },
39
+ { error: 'Invalid request body', details: parsed.error.issues },
43
40
  { status: 422 },
44
41
  )
45
42
  }
43
+ const body = parsed.data
46
44
 
47
45
  const container = await createRequestContainer()
48
46
 
@@ -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
  },
@@ -76,6 +76,11 @@ export type DeliverOutboundMessageResult =
76
76
  export const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =
77
77
  'communication_channels.message.deliver_outbound'
78
78
 
79
+ function omitRawMessage(metadata: Record<string, unknown> | undefined): Record<string, unknown> {
80
+ if (!metadata) return {}
81
+ return Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== 'rawMessage'))
82
+ }
83
+
79
84
  type CredentialsServiceLike = {
80
85
  resolve: (
81
86
  integrationId: string,
@@ -500,7 +505,7 @@ const deliverOutboundMessageCommand: CommandHandler<
500
505
  link.externalMessageId = externalMessageRowId
501
506
  link.channelMetadata = {
502
507
  ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),
503
- ...(converted.metadata ?? {}),
508
+ ...omitRawMessage(converted.metadata),
504
509
  externalMessageId: sendResult.externalMessageId,
505
510
  // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ
506
511
  // strategy in lib/thread-matcher) and sent-folder dedup can resolve this
@@ -71,6 +71,9 @@
71
71
  "communication_channels.profile.connect.connected": "Kanal verbunden.",
72
72
  "communication_channels.profile.connect.connecting": "Wird verbunden...",
73
73
  "communication_channels.profile.connect.credentialsFailed": "Postfach konnte nicht verbunden werden.",
74
+ "communication_channels.profile.connect.errors.invalidEmail": "Geben Sie eine gültige E-Mail-Adresse ein.",
75
+ "communication_channels.profile.connect.errors.invalidPort": "Geben Sie einen Port zwischen 1 und 65535 ein.",
76
+ "communication_channels.profile.connect.errors.required": "Dieses Feld ist erforderlich.",
74
77
  "communication_channels.profile.connect.fields.displayName": "Anzeigename",
75
78
  "communication_channels.profile.connect.fields.fromAddress": "Absenderadresse",
76
79
  "communication_channels.profile.connect.fields.imapHost": "IMAP-Host",
@@ -88,6 +91,7 @@
88
91
  "communication_channels.profile.connect.imapTitle": "IMAP-Postfach verbinden",
89
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.",
90
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.",
91
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.",
92
96
  "communication_channels.profile.connect.oauthFailed": "OAuth-Verbindung konnte nicht gestartet werden.",
93
97
  "communication_channels.profile.connect.save": "Verbinden",
@@ -71,6 +71,9 @@
71
71
  "communication_channels.profile.connect.connected": "Channel connected.",
72
72
  "communication_channels.profile.connect.connecting": "Connecting...",
73
73
  "communication_channels.profile.connect.credentialsFailed": "Could not connect mailbox.",
74
+ "communication_channels.profile.connect.errors.invalidEmail": "Enter a valid email address.",
75
+ "communication_channels.profile.connect.errors.invalidPort": "Enter a port between 1 and 65535.",
76
+ "communication_channels.profile.connect.errors.required": "This field is required.",
74
77
  "communication_channels.profile.connect.fields.displayName": "Display name",
75
78
  "communication_channels.profile.connect.fields.fromAddress": "From address",
76
79
  "communication_channels.profile.connect.fields.imapHost": "IMAP host",
@@ -88,6 +91,7 @@
88
91
  "communication_channels.profile.connect.imapTitle": "Connect IMAP mailbox",
89
92
  "communication_channels.profile.connect.mailboxAlreadyConnected": "This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.",
90
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.",
91
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.",
92
96
  "communication_channels.profile.connect.oauthFailed": "Could not start OAuth connection.",
93
97
  "communication_channels.profile.connect.save": "Connect",
@@ -71,6 +71,9 @@
71
71
  "communication_channels.profile.connect.connected": "Canal conectado.",
72
72
  "communication_channels.profile.connect.connecting": "Conectando...",
73
73
  "communication_channels.profile.connect.credentialsFailed": "No se pudo conectar el buzón.",
74
+ "communication_channels.profile.connect.errors.invalidEmail": "Introduce una dirección de correo electrónico válida.",
75
+ "communication_channels.profile.connect.errors.invalidPort": "Introduce un puerto entre 1 y 65535.",
76
+ "communication_channels.profile.connect.errors.required": "Este campo es obligatorio.",
74
77
  "communication_channels.profile.connect.fields.displayName": "Nombre visible",
75
78
  "communication_channels.profile.connect.fields.fromAddress": "Dirección de remitente",
76
79
  "communication_channels.profile.connect.fields.imapHost": "Host IMAP",
@@ -88,6 +91,7 @@
88
91
  "communication_channels.profile.connect.imapTitle": "Conectar buzón IMAP",
89
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.",
90
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.",
91
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.",
92
96
  "communication_channels.profile.connect.oauthFailed": "No se pudo iniciar la conexión OAuth.",
93
97
  "communication_channels.profile.connect.save": "Conectar",
@@ -71,6 +71,9 @@
71
71
  "communication_channels.profile.connect.connected": "채널이 연결되었습니다.",
72
72
  "communication_channels.profile.connect.connecting": "연결 중...",
73
73
  "communication_channels.profile.connect.credentialsFailed": "메일함에 연결할 수 없습니다.",
74
+ "communication_channels.profile.connect.errors.invalidEmail": "유효한 이메일 주소를 입력하세요.",
75
+ "communication_channels.profile.connect.errors.invalidPort": "1에서 65535 사이의 포트를 입력하세요.",
76
+ "communication_channels.profile.connect.errors.required": "필수 입력 항목입니다.",
74
77
  "communication_channels.profile.connect.fields.displayName": "표시 이름",
75
78
  "communication_channels.profile.connect.fields.fromAddress": "보내는 주소",
76
79
  "communication_channels.profile.connect.fields.imapHost": "IMAP 호스트",
@@ -88,6 +91,7 @@
88
91
  "communication_channels.profile.connect.imapTitle": "IMAP 메일함 연결",
89
92
  "communication_channels.profile.connect.mailboxAlreadyConnected": "이 메일함은 이미 다른 제공업체를 통해 연결되어 있습니다. 다른 제공업체로 다시 연결하려면 먼저 연결을 해제하세요.",
90
93
  "communication_channels.profile.connect.menu": "채널 연결",
94
+ "communication_channels.profile.connect.notAvailable": "메일함 연결을 사용할 수 없습니다. 관리자에게 OAuth 상태 키를 구성하도록 요청하세요.",
91
95
  "communication_channels.profile.connect.notConfigured": "이 제공업체는 아직 구성되지 않았습니다. 메일함을 연결하기 전에 관리자에게 통합에서 OAuth 클라이언트 ID와 시크릿을 추가하도록 요청하세요.",
92
96
  "communication_channels.profile.connect.oauthFailed": "OAuth 연결을 시작할 수 없습니다.",
93
97
  "communication_channels.profile.connect.save": "연결",
@@ -71,6 +71,9 @@
71
71
  "communication_channels.profile.connect.connected": "Kanał połączony.",
72
72
  "communication_channels.profile.connect.connecting": "Łączenie...",
73
73
  "communication_channels.profile.connect.credentialsFailed": "Nie udało się połączyć skrzynki pocztowej.",
74
+ "communication_channels.profile.connect.errors.invalidEmail": "Wprowadź prawidłowy adres e-mail.",
75
+ "communication_channels.profile.connect.errors.invalidPort": "Wprowadź port z zakresu od 1 do 65535.",
76
+ "communication_channels.profile.connect.errors.required": "To pole jest wymagane.",
74
77
  "communication_channels.profile.connect.fields.displayName": "Nazwa wyświetlana",
75
78
  "communication_channels.profile.connect.fields.fromAddress": "Adres nadawcy",
76
79
  "communication_channels.profile.connect.fields.imapHost": "Host IMAP",
@@ -88,6 +91,7 @@
88
91
  "communication_channels.profile.connect.imapTitle": "Połącz skrzynkę IMAP",
89
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ą.",
90
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.",
91
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.",
92
96
  "communication_channels.profile.connect.oauthFailed": "Nie udało się rozpocząć połączenia OAuth.",
93
97
  "communication_channels.profile.connect.save": "Połącz",
@@ -1,12 +1,8 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { resolveCommunicationChannelsSystemUserId } from './system-user'
3
+ import { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE } from '../../messages/lib/messageSourceEntityTypes'
3
4
 
4
- /**
5
- * Entity type a channel-backed conversation is exposed under on a message's
6
- * `sourceEntityType`. Written by `commands/ingest-inbound-message.ts` and
7
- * inherited by every operator message composed on the same conversation.
8
- */
9
- export const EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE = 'communication_channels.external_conversation'
5
+ export { EXTERNAL_CONVERSATION_SOURCE_ENTITY_TYPE }
10
6
 
11
7
  /**
12
8
  * Dedup key `ingest-inbound-message` stamps on the platform message it composes,
@@ -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(
@@ -0,0 +1,154 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+ import type { OrganizationHierarchyService } from '@open-mercato/shared/lib/auth/principal-service'
8
+ import { createLogger } from '@open-mercato/shared/lib/logger'
9
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
10
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
+ import { resolveDictionaryRouteContext } from '../dictionaries/context'
12
+ import { createDictionaryCacheTags, DICTIONARY_CACHE_TTL_MS } from '../dictionaries/cache'
13
+ import { getInjectionRegistryVersion } from '@open-mercato/shared/modules/widgets/injection-loader'
14
+ import {
15
+ resolveScopedCalendarEventTypes,
16
+ } from '../../lib/calendar/eventTypeResolver'
17
+ import { calendarEventTypeBehaviorSchema } from '../../calendar-event-types'
18
+ import { getCalendarEventTypeRegistryVersion } from '../../calendar-event-types'
19
+
20
+ const logger = createLogger('customers')
21
+
22
+ function reportActivityTypeError(error: unknown, code: string, attributes?: Record<string, string>): void {
23
+ try {
24
+ getTelemetryRuntime()?.reportError(error, { module: 'customers', code, attributes })
25
+ } catch {}
26
+ }
27
+
28
+ const querySchema = z.object({ organizationId: z.string().uuid().optional() })
29
+ const responseSchema = z.object({
30
+ fallbackKey: z.literal('meeting'),
31
+ items: z.array(z.object({
32
+ key: z.string(),
33
+ label: z.string(),
34
+ labelKey: z.string().optional(),
35
+ icon: z.string().nullable().optional(),
36
+ color: z.string().nullable().optional(),
37
+ behavior: calendarEventTypeBehaviorSchema,
38
+ selectable: z.boolean(),
39
+ panelKey: z.string().optional(),
40
+ source: z.string(),
41
+ provenance: z.record(z.string(), z.unknown()),
42
+ historical: z.boolean(),
43
+ fallbackReason: z.enum(['tombstoned', 'module-unavailable', 'unknown']).optional(),
44
+ adminConfigurable: z.boolean(),
45
+ isInherited: z.boolean(),
46
+ isLocalOverride: z.boolean(),
47
+ updatedAt: z.string().nullable(),
48
+ missingCustomFieldsetIds: z.array(z.string()),
49
+ inactiveDictionaryOverride: z.boolean().optional(),
50
+ })),
51
+ })
52
+
53
+ export const metadata = {
54
+ GET: { requireAuth: true },
55
+ }
56
+
57
+ export async function GET(req: Request) {
58
+ try {
59
+ const url = new URL(req.url)
60
+ const query = querySchema.parse({ organizationId: url.searchParams.get('organizationId') ?? undefined })
61
+ const context = await resolveDictionaryRouteContext(req, { selectedId: query.organizationId })
62
+ if (!context.organizationId) {
63
+ return NextResponse.json({ error: context.translate('customers.errors.organization_required', 'Organization context is required') }, { status: 400 })
64
+ }
65
+ const organizationId = context.organizationId
66
+ const rbac = context.container.resolve('rbacService') as RbacService | undefined
67
+ const actorId = context.auth?.sub
68
+ if (!rbac || !actorId) {
69
+ throw new CrudHttpError(403, { error: context.translate('customers.calendar.activityTypes.errors.forbidden', 'You do not have permission to view activity types.') })
70
+ }
71
+ const permissionScope = { tenantId: context.tenantId, organizationId }
72
+ const canViewInteractions = await rbac.userHasAllFeatures(actorId, ['customers.interactions.view'], permissionScope)
73
+ const canManageSettings = canViewInteractions || await rbac.userHasAllFeatures(actorId, ['customers.settings.manage'], permissionScope)
74
+ if (!canManageSettings) {
75
+ throw new CrudHttpError(403, { error: context.translate('customers.calendar.activityTypes.errors.forbidden', 'You do not have permission to view activity types.') })
76
+ }
77
+ const hierarchy = context.container.resolve('organizationHierarchyService') as OrganizationHierarchyService
78
+ const ancestors = await hierarchy.resolveAncestorIds({ tenantId: context.tenantId, organizationId })
79
+ if (ancestors === null) {
80
+ throw new CrudHttpError(403, { error: context.translate('customers.errors.organization_forbidden', 'Organization not accessible') })
81
+ }
82
+ const scopeIds = Array.from(new Set([organizationId, ...ancestors.slice().reverse()]))
83
+ const cacheKey = `customers:activity-types:${context.tenantId}:org=${organizationId}:scope=${scopeIds.join('|')}:widgets=${getInjectionRegistryVersion()}:registry=${getCalendarEventTypeRegistryVersion()}`
84
+ const cached = await context.cache?.get(cacheKey)
85
+ if (cached) return NextResponse.json(cached)
86
+
87
+ let catalog
88
+ try {
89
+ catalog = await resolveScopedCalendarEventTypes({
90
+ em: context.em,
91
+ tenantId: context.tenantId,
92
+ organizationId,
93
+ readableOrganizationIds: scopeIds,
94
+ })
95
+ } catch (err) {
96
+ logger.error('customers.activity_types.resolve failed', {
97
+ err,
98
+ tenantId: context.tenantId,
99
+ organizationId,
100
+ })
101
+ reportActivityTypeError(err, 'customers.activity_type_catalog_resolution_failed', {
102
+ tenantId: context.tenantId,
103
+ organizationId,
104
+ })
105
+ return NextResponse.json({
106
+ error: context.translate('customers.calendar.activityTypes.errors.loadFailed', 'Failed to load activity types'),
107
+ code: 'activity_type_catalog_unavailable',
108
+ }, { status: 503 })
109
+ }
110
+ if (context.cache) {
111
+ await context.cache.set(cacheKey, catalog, {
112
+ ttl: DICTIONARY_CACHE_TTL_MS,
113
+ tags: createDictionaryCacheTags({
114
+ tenantId: context.tenantId,
115
+ mappedKind: 'activity_type',
116
+ organizationIds: scopeIds,
117
+ }),
118
+ }).catch((err) => {
119
+ logger.warn('customers.activity_types cache write failed', { err })
120
+ reportActivityTypeError(err, 'customers.activity_type_catalog_cache_write_failed', {
121
+ tenantId: context.tenantId,
122
+ organizationId,
123
+ })
124
+ })
125
+ }
126
+ return NextResponse.json(catalog)
127
+ } catch (err) {
128
+ if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })
129
+ logger.error('customers.activity_types request failed', { err })
130
+ reportActivityTypeError(err, 'customers.activity_type_catalog_request_failed')
131
+ const { translate } = await resolveTranslations()
132
+ return NextResponse.json({
133
+ error: translate('customers.calendar.activityTypes.errors.loadFailed', 'Failed to load activity types'),
134
+ }, { status: err instanceof z.ZodError ? 400 : 500 })
135
+ }
136
+ }
137
+
138
+ export const openApi: OpenApiRouteDoc = {
139
+ tag: 'Customers',
140
+ summary: 'Effective calendar activity types',
141
+ methods: {
142
+ GET: {
143
+ summary: 'List effective calendar activity types',
144
+ description: 'Returns the tenant and organization scoped calendar activity-type catalog. Requires customers.interactions.view or customers.settings.manage.',
145
+ responses: [{ status: 200, description: 'Effective activity-type catalog', schema: responseSchema }],
146
+ errors: [
147
+ { status: 400, description: 'Organization scope is required', schema: z.object({ error: z.string() }) },
148
+ { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
149
+ { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },
150
+ { status: 500, description: 'Unexpected server error', schema: z.object({ error: z.string() }) },
151
+ ],
152
+ },
153
+ },
154
+ }
@@ -10,7 +10,12 @@ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
10
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
11
11
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
12
  import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
13
- import { CustomerDeal, CustomerDealPersonLink, CustomerEntity } from '../../../../data/entities'
13
+ import {
14
+ CustomerDeal,
15
+ CustomerDealPersonLink,
16
+ CustomerEntity,
17
+ CustomerPersonProfile,
18
+ } from '../../../../data/entities'
14
19
  import { createLogger } from '@open-mercato/shared/lib/logger'
15
20
 
16
21
  const logger = createLogger('customers')
@@ -41,12 +46,36 @@ type DealPersonItem = {
41
46
  kind: 'person'
42
47
  linkedAt: string
43
48
  isPrimary: boolean
49
+ // Added for the shared linked-people card, which renders the same shape the company
50
+ // endpoint already returns. Purely additive: every key above keeps its meaning, so
51
+ // `DealLinkedEntitiesTab` and any third-party consumer are unaffected.
52
+ displayName: string
53
+ primaryEmail: string | null
54
+ primaryPhone: string | null
55
+ status: string | null
56
+ lifecycleStage: string | null
57
+ jobTitle: string | null
58
+ department: string | null
59
+ createdAt: string
60
+ organizationId: string
61
+ temperature: string | null
62
+ source: string | null
44
63
  }
45
64
 
46
65
  function matchesSearch(item: DealPersonItem, query: string): boolean {
47
66
  const normalized = query.trim().toLowerCase()
48
67
  if (!normalized.length) return true
49
- return [item.label, item.subtitle]
68
+ return [
69
+ item.label,
70
+ item.subtitle,
71
+ item.primaryEmail,
72
+ item.primaryPhone,
73
+ item.jobTitle,
74
+ item.department,
75
+ item.status,
76
+ item.lifecycleStage,
77
+ item.source,
78
+ ]
50
79
  .filter((value): value is string => typeof value === 'string' && value.length > 0)
51
80
  .some((value) => value.toLowerCase().includes(normalized))
52
81
  }
@@ -119,17 +148,56 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
119
148
  entityScope,
120
149
  )
121
150
 
151
+ // One batched read for the profile-only columns, keyed by every linked person — not one
152
+ // query per row. The handler filters, sorts and paginates in memory below, so the batch
153
+ // covers the whole link set rather than the page; that is fine at the expected scale of
154
+ // tens of contacts per deal, and the in-memory pipeline is what to revisit first if it
155
+ // ever is not.
156
+ const personIds = links
157
+ .map((link) => (link.person as CustomerEntity | null)?.id)
158
+ .filter((personId): personId is string => typeof personId === 'string' && personId.length > 0)
159
+
160
+ const profiles = personIds.length > 0
161
+ ? await findWithDecryption(
162
+ em,
163
+ CustomerPersonProfile,
164
+ {
165
+ entity: { $in: personIds },
166
+ tenantId: deal.tenantId,
167
+ organizationId: deal.organizationId,
168
+ },
169
+ {},
170
+ entityScope,
171
+ )
172
+ : []
173
+ const profileByPersonId = new Map(
174
+ profiles.map((profile) => [(profile.entity as { id: string }).id, profile]),
175
+ )
176
+
122
177
  const items = links
123
178
  .map((link) => {
124
179
  const person = link.person as CustomerEntity | null
125
180
  if (!person?.id) return null
181
+ const profile = profileByPersonId.get(person.id) ?? null
182
+ const displayName = person.displayName ?? person.primaryEmail ?? person.id
126
183
  return {
127
184
  id: person.id,
128
- label: person.displayName ?? person.primaryEmail ?? person.id,
185
+ label: displayName,
129
186
  subtitle: person.primaryEmail ?? person.primaryPhone ?? null,
130
187
  kind: 'person',
131
188
  linkedAt: link.createdAt.toISOString(),
132
189
  isPrimary: link.isPrimary === true,
190
+ displayName,
191
+ primaryEmail: person.primaryEmail ?? null,
192
+ primaryPhone: person.primaryPhone ?? null,
193
+ status: person.status ?? null,
194
+ lifecycleStage: person.lifecycleStage ?? null,
195
+ jobTitle: profile?.jobTitle ?? null,
196
+ department: profile?.department ?? null,
197
+ createdAt: person.createdAt.toISOString(),
198
+ organizationId: person.organizationId,
199
+ temperature: person.temperature ?? null,
200
+ source: person.source ?? null,
133
201
  } satisfies DealPersonItem
134
202
  })
135
203
  .filter((item): item is DealPersonItem => item !== null)
@@ -176,6 +244,17 @@ export const openApi: OpenApiRouteDoc = {
176
244
  kind: z.literal('person'),
177
245
  linkedAt: z.string(),
178
246
  isPrimary: z.boolean(),
247
+ displayName: z.string(),
248
+ primaryEmail: z.string().nullable(),
249
+ primaryPhone: z.string().nullable(),
250
+ status: z.string().nullable(),
251
+ lifecycleStage: z.string().nullable(),
252
+ jobTitle: z.string().nullable(),
253
+ department: z.string().nullable(),
254
+ createdAt: z.string(),
255
+ organizationId: z.string().uuid().nullable(),
256
+ temperature: z.string().nullable(),
257
+ source: z.string().nullable(),
179
258
  }),
180
259
  ),
181
260
  total: z.number().int().nonnegative(),
@@ -17,6 +17,7 @@ import {
17
17
  } from '@open-mercato/shared/lib/crud/mutation-guard'
18
18
  import { createLogger } from '@open-mercato/shared/lib/logger'
19
19
  import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
20
+ import { calendarEventTypeBehaviorSchema } from '../../../../calendar-event-types'
20
21
 
21
22
  const logger = createLogger('customers')
22
23
 
@@ -30,8 +31,9 @@ const patchSchema = z
30
31
  label: z.string().trim().max(150).optional(),
31
32
  color: z.union([z.string().trim(), z.null()]).optional(),
32
33
  icon: z.union([z.string().trim(), z.null()]).optional(),
34
+ behavior: calendarEventTypeBehaviorSchema.nullable().optional(),
33
35
  })
34
- .refine((input) => input.value !== undefined || input.label !== undefined || input.color !== undefined || input.icon !== undefined, {
36
+ .refine((input) => input.value !== undefined || input.label !== undefined || input.color !== undefined || input.icon !== undefined || input.behavior !== undefined, {
35
37
  message: 'No changes provided',
36
38
  })
37
39
 
@@ -81,6 +83,7 @@ export async function PATCH(req: Request, ctx: { params?: { kind?: string; id?:
81
83
  label: payload.label,
82
84
  color: payload.color,
83
85
  icon: payload.icon,
86
+ behavior: payload.behavior,
84
87
  },
85
88
  ctx: routeContext.ctx,
86
89
  })) as CommandExecuteResult<{ entryId: string; changed: boolean }>
@@ -103,6 +106,7 @@ export async function PATCH(req: Request, ctx: { params?: { kind?: string; id?:
103
106
  ),
104
107
  })
105
108
  }
109
+ if (typeof err.body.code === 'string') throw err
106
110
  throw new CrudHttpError(409, { error: routeContext.translate('customers.config.dictionaries.errors.duplicate', 'An entry with this value already exists') })
107
111
  }
108
112
  if (err.status === 400) {
@@ -141,8 +145,10 @@ export async function PATCH(req: Request, ctx: { params?: { kind?: string; id?:
141
145
  label: entry.label,
142
146
  color: entry.color,
143
147
  icon: entry.icon,
148
+ ...(mappedKind === 'activity_type' ? { behavior: entry.activityTypeBehavior ?? null } : {}),
144
149
  organizationId: entry.organizationId,
145
150
  isInherited: false,
151
+ updatedAt: entry.updatedAt,
146
152
  })
147
153
  if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
148
154
  response.headers.set(
@@ -297,8 +303,10 @@ const dictionaryEntrySchema = z.object({
297
303
  label: z.string().nullable().optional(),
298
304
  color: z.string().nullable().optional(),
299
305
  icon: z.string().nullable().optional(),
306
+ behavior: calendarEventTypeBehaviorSchema.nullable().optional(),
300
307
  organizationId: z.string().uuid().nullable().optional(),
301
308
  isInherited: z.boolean().optional(),
309
+ updatedAt: z.date().or(z.string()).optional(),
302
310
  })
303
311
 
304
312
  const dictionaryDeleteResponseSchema = z.object({