@open-mercato/core 0.4.5-develop-811deeb983 → 0.4.5-develop-3d8e759e45

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 (76) hide show
  1. package/dist/modules/catalog/inbox-actions.js +51 -0
  2. package/dist/modules/catalog/inbox-actions.js.map +7 -0
  3. package/dist/modules/customers/inbox-actions.js +230 -0
  4. package/dist/modules/customers/inbox-actions.js.map +7 -0
  5. package/dist/modules/inbox_ops/api/emails/[id]/route.js +40 -1
  6. package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
  7. package/dist/modules/inbox_ops/api/extract/route.js +87 -0
  8. package/dist/modules/inbox_ops/api/extract/route.js.map +7 -0
  9. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js +6 -1
  10. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js.map +2 -2
  11. package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
  12. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js +40 -14
  13. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js.map +2 -2
  14. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js +2 -2
  15. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js.map +2 -2
  16. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +161 -79
  17. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  18. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js +2 -2
  19. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js.map +2 -2
  20. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +109 -62
  21. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +3 -3
  22. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js +2 -2
  23. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js.map +2 -2
  24. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +36 -14
  25. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
  26. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js +2 -2
  27. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js.map +2 -2
  28. package/dist/modules/inbox_ops/components/proposals/ActionCard.js +65 -10
  29. package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
  30. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +58 -10
  31. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  32. package/dist/modules/inbox_ops/lib/constants.js.map +2 -2
  33. package/dist/modules/inbox_ops/lib/contactValidation.js +40 -0
  34. package/dist/modules/inbox_ops/lib/contactValidation.js.map +7 -0
  35. package/dist/modules/inbox_ops/lib/executionEngine.js +31 -826
  36. package/dist/modules/inbox_ops/lib/executionEngine.js.map +3 -3
  37. package/dist/modules/inbox_ops/lib/executionHelpers.js +368 -0
  38. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +7 -0
  39. package/dist/modules/inbox_ops/lib/extractionPrompt.js +28 -35
  40. package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +3 -3
  41. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js +1 -0
  42. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js.map +7 -0
  43. package/dist/modules/inbox_ops/lib/translationProvider.js +15 -10
  44. package/dist/modules/inbox_ops/lib/translationProvider.js.map +2 -2
  45. package/dist/modules/inbox_ops/subscribers/extractionWorker.js +16 -16
  46. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
  47. package/dist/modules/sales/inbox-actions.js +278 -0
  48. package/dist/modules/sales/inbox-actions.js.map +7 -0
  49. package/jest.config.cjs +1 -0
  50. package/jest.mocks/inbox-actions.generated.js +5 -0
  51. package/package.json +2 -2
  52. package/src/modules/catalog/inbox-actions.ts +60 -0
  53. package/src/modules/customers/inbox-actions.ts +285 -0
  54. package/src/modules/inbox_ops/api/emails/[id]/route.ts +44 -0
  55. package/src/modules/inbox_ops/api/extract/route.ts +94 -0
  56. package/src/modules/inbox_ops/api/proposals/[id]/translate/route.ts +6 -1
  57. package/src/modules/inbox_ops/api/proposals/counts/route.ts +2 -0
  58. package/src/modules/inbox_ops/backend/inbox-ops/log/page.meta.ts +2 -2
  59. package/src/modules/inbox_ops/backend/inbox-ops/log/page.tsx +43 -13
  60. package/src/modules/inbox_ops/backend/inbox-ops/page.meta.ts +2 -2
  61. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +176 -81
  62. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.ts +2 -2
  63. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +122 -68
  64. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.meta.ts +2 -2
  65. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +36 -14
  66. package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +91 -7
  67. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +64 -12
  68. package/src/modules/inbox_ops/lib/constants.ts +9 -0
  69. package/src/modules/inbox_ops/lib/contactValidation.ts +54 -0
  70. package/src/modules/inbox_ops/lib/executionEngine.ts +47 -1060
  71. package/src/modules/inbox_ops/lib/executionHelpers.ts +527 -0
  72. package/src/modules/inbox_ops/lib/extractionPrompt.ts +45 -34
  73. package/src/modules/inbox_ops/lib/inbox-actions-generated.d.ts +11 -0
  74. package/src/modules/inbox_ops/lib/translationProvider.ts +16 -10
  75. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +16 -18
  76. package/src/modules/sales/inbox-actions.ts +359 -0
@@ -19,6 +19,61 @@ import {
19
19
  ShoppingBag,
20
20
  } from 'lucide-react'
21
21
  import type { ActionDetail, DiscrepancyDetail } from './types'
22
+ import { hasContactNameIssue } from '../../lib/contactValidation'
23
+
24
+ export { hasContactNameIssue }
25
+
26
+ /**
27
+ * Resolves discrepancy description i18n keys stored in the database.
28
+ * Falls back to the raw description string for legacy data or LLM-generated descriptions.
29
+ */
30
+ export function useDiscrepancyDescriptions(): (description: string, foundValue?: string | null) => string {
31
+ const t = useT()
32
+ const translations: Record<string, string> = {
33
+ 'inbox_ops.discrepancy.desc.no_channel': t('inbox_ops.discrepancy.desc.no_channel', 'No sales channel available. Create a channel in Sales settings before accepting this order.'),
34
+ 'inbox_ops.discrepancy.desc.no_currency': t('inbox_ops.discrepancy.desc.no_currency', 'No currency could be resolved for this order. Set a currency code or configure a sales channel with a default currency.'),
35
+ 'inbox_ops.discrepancy.desc.product_not_matched': t('inbox_ops.discrepancy.desc.product_not_matched', 'Product could not be matched to any catalog product'),
36
+ 'inbox_ops.discrepancy.desc.no_matching_contact': t('inbox_ops.discrepancy.desc.no_matching_contact', 'No matching contact found'),
37
+ 'inbox_ops.discrepancy.desc.draft_reply_no_contact': t('inbox_ops.discrepancy.desc.draft_reply_no_contact', 'Draft reply target has no matching contact. Create the contact first.'),
38
+ 'inbox_ops.discrepancy.desc.duplicate_order_reference': t('inbox_ops.discrepancy.desc.duplicate_order_reference', 'An order with this customer reference already exists'),
39
+ }
40
+ return (description: string, foundValue?: string | null) => {
41
+ const translated = translations[description]
42
+ if (!translated) return description
43
+ if (foundValue && (description === 'inbox_ops.discrepancy.desc.product_not_matched' || description === 'inbox_ops.discrepancy.desc.no_matching_contact')) {
44
+ return `${translated}: ${foundValue}`
45
+ }
46
+ return translated
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Resolves action description i18n keys stored in the database.
52
+ * Auto-generated actions store keys like `inbox_ops.action.desc.create_contact`;
53
+ * LLM-generated actions store plain text which is returned as-is.
54
+ */
55
+ export function useActionDescriptionResolver(): (description: string, payload: Record<string, unknown>) => string {
56
+ const t = useT()
57
+ return (description: string, payload: Record<string, unknown>) => {
58
+ if (!description.startsWith('inbox_ops.action.desc.')) return description
59
+ const name = (payload.name as string) || (payload.contactName as string) || ''
60
+ const email = (payload.email as string) || (payload.emailAddress as string) || ''
61
+ const title = (payload.title as string) || ''
62
+ const toName = (payload.toName as string) || (payload.to as string) || ''
63
+ const subject = (payload.subject as string) || ''
64
+ const translations: Record<string, string> = {
65
+ 'inbox_ops.action.desc.create_contact': t('inbox_ops.action.desc.create_contact', 'Create contact for {name} ({email})')
66
+ .replace('{name}', name).replace('{email}', email),
67
+ 'inbox_ops.action.desc.link_contact': t('inbox_ops.action.desc.link_contact', 'Link {name} ({email}) to existing contact')
68
+ .replace('{name}', name).replace('{email}', email),
69
+ 'inbox_ops.action.desc.create_product': t('inbox_ops.action.desc.create_product', 'Create catalog product "{title}"')
70
+ .replace('{title}', title),
71
+ 'inbox_ops.action.desc.draft_reply': t('inbox_ops.action.desc.draft_reply', 'Draft reply to {toName}: {subject}')
72
+ .replace('{toName}', toName).replace('{subject}', subject),
73
+ }
74
+ return translations[description] || description
75
+ }
76
+ }
22
77
 
23
78
  const ACTION_TYPE_ICONS: Record<string, React.ElementType> = {
24
79
  create_order: Package,
@@ -169,6 +224,7 @@ export function ActionCard({
169
224
  onRetry,
170
225
  onEdit,
171
226
  translatedDescription,
227
+ resolveDiscrepancyDescription,
172
228
  }: {
173
229
  action: ActionDetail
174
230
  discrepancies: DiscrepancyDetail[]
@@ -178,14 +234,16 @@ export function ActionCard({
178
234
  onRetry: (id: string) => void
179
235
  onEdit: (action: ActionDetail) => void
180
236
  translatedDescription?: string
237
+ resolveDiscrepancyDescription?: (description: string, foundValue?: string | null) => string
181
238
  }) {
182
239
  const t = useT()
183
240
  const Icon = ACTION_TYPE_ICONS[action.actionType] || Package
184
241
  const label = actionTypeLabels[action.actionType] || action.actionType
242
+ const resolveActionDescription = useActionDescriptionResolver()
185
243
 
186
244
  const actionDiscrepancies = discrepancies.filter((d) => d.actionId === action.id && !d.resolved)
187
245
  const hasBlockingDiscrepancies = actionDiscrepancies.some((d) => d.severity === 'error')
188
- const displayDescription = translatedDescription || action.description
246
+ const displayDescription = translatedDescription || resolveActionDescription(action.description, action.payload)
189
247
 
190
248
  if (action.status === 'executed') {
191
249
  return (
@@ -198,7 +256,7 @@ export function ActionCard({
198
256
  {action.createdEntityId && (
199
257
  <div className="mt-2">
200
258
  <span className="text-xs text-green-600">
201
- Created {action.createdEntityType} · {action.executedAt && new Date(action.executedAt).toLocaleString()}
259
+ {t('inbox_ops.action.created_entity', 'Created {type}').replace('{type}', action.createdEntityType || '')} · {action.executedAt && new Date(action.executedAt).toLocaleString()}
202
260
  </span>
203
261
  </div>
204
262
  )}
@@ -233,6 +291,7 @@ export function ActionCard({
233
291
  )}
234
292
  <div className="mt-3 flex items-center gap-2">
235
293
  <Button
294
+ type="button"
236
295
  size="sm"
237
296
  className="h-11 md:h-9"
238
297
  onClick={() => onRetry(action.id)}
@@ -241,6 +300,7 @@ export function ActionCard({
241
300
  {t('inbox_ops.action.retry', 'Retry')}
242
301
  </Button>
243
302
  <Button
303
+ type="button"
244
304
  variant="outline"
245
305
  size="sm"
246
306
  className="h-11 md:h-9"
@@ -250,6 +310,7 @@ export function ActionCard({
250
310
  {t('inbox_ops.action.edit', 'Edit')}
251
311
  </Button>
252
312
  <Button
313
+ type="button"
253
314
  variant="outline"
254
315
  size="sm"
255
316
  className="h-11 md:h-9"
@@ -263,6 +324,8 @@ export function ActionCard({
263
324
  )
264
325
  }
265
326
 
327
+ const hasNameIssue = hasContactNameIssue(action)
328
+
266
329
  return (
267
330
  <div className="border rounded-lg p-3 md:p-4">
268
331
  <div className="flex items-center gap-2 mb-2">
@@ -288,12 +351,12 @@ export function ActionCard({
288
351
  }`}>
289
352
  <AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
290
353
  <div>
291
- <span>{d.description}</span>
354
+ <span>{resolveDiscrepancyDescription ? resolveDiscrepancyDescription(d.description, d.foundValue) : d.description}</span>
292
355
  {(d.expectedValue || d.foundValue) && (
293
356
  <div className="mt-0.5 text-[11px] opacity-80">
294
- {d.expectedValue && <span>Expected: {d.expectedValue}</span>}
357
+ {d.expectedValue && <span>{t('inbox_ops.discrepancy.expected', 'Expected')}: {d.expectedValue}</span>}
295
358
  {d.expectedValue && d.foundValue && <span> · </span>}
296
- {d.foundValue && <span>Found: {d.foundValue}</span>}
359
+ {d.foundValue && <span>{t('inbox_ops.discrepancy.found', 'Found')}: {d.foundValue}</span>}
297
360
  </div>
298
361
  )}
299
362
  </div>
@@ -302,19 +365,39 @@ export function ActionCard({
302
365
  </div>
303
366
  )}
304
367
 
368
+ {hasNameIssue && (
369
+ <div className="mb-3 flex items-start gap-2 text-xs rounded px-2 py-1.5 bg-amber-50 text-amber-700 dark:bg-amber-950/20 dark:text-amber-300">
370
+ <AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
371
+ <span>{action.actionType === 'link_contact'
372
+ ? t('inbox_ops.contact.link_name_missing_warning', 'Contact name is missing. Please edit and provide a name before accepting.')
373
+ : t('inbox_ops.contact.name_missing_warning', 'First and last name could not be extracted. Please edit before accepting.')
374
+ }</span>
375
+ </div>
376
+ )}
377
+
305
378
  <div className="flex items-center gap-2">
306
- <div title={hasBlockingDiscrepancies ? t('inbox_ops.action.accept_blocked', 'Resolve errors before accepting') : undefined}>
379
+ <div title={
380
+ hasNameIssue
381
+ ? action.actionType === 'link_contact'
382
+ ? t('inbox_ops.contact.link_name_missing_warning', 'Contact name is missing. Please edit and provide a name before accepting.')
383
+ : t('inbox_ops.contact.name_missing_warning', 'First and last name could not be extracted. Please edit before accepting.')
384
+ : hasBlockingDiscrepancies
385
+ ? t('inbox_ops.action.accept_blocked', 'Resolve errors before accepting')
386
+ : undefined
387
+ }>
307
388
  <Button
389
+ type="button"
308
390
  size="sm"
309
391
  className="h-11 md:h-9"
310
392
  onClick={() => onAccept(action.id)}
311
- disabled={hasBlockingDiscrepancies}
393
+ disabled={hasBlockingDiscrepancies || hasNameIssue}
312
394
  >
313
395
  <CheckCircle className="h-4 w-4 mr-1" />
314
396
  {t('inbox_ops.action.accept', 'Accept')}
315
397
  </Button>
316
398
  </div>
317
399
  <Button
400
+ type="button"
318
401
  variant="outline"
319
402
  size="sm"
320
403
  className="h-11 md:h-9"
@@ -324,6 +407,7 @@ export function ActionCard({
324
407
  {t('inbox_ops.action.edit', 'Edit')}
325
408
  </Button>
326
409
  <Button
410
+ type="button"
327
411
  variant="outline"
328
412
  size="sm"
329
413
  className="h-11 md:h-9"
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { Button } from '@open-mercato/ui/primitives/button'
5
5
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
6
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
6
7
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
9
  import { Loader2 } from 'lucide-react'
@@ -75,18 +76,62 @@ function ContactPayloadEditor({
75
76
  updateField: (key: string, value: unknown) => void
76
77
  }) {
77
78
  const t = useT()
79
+ const type = (payload.type as string) || 'person'
80
+ const isPerson = type === 'person'
81
+
82
+ const existingName = (payload.name as string) || ''
83
+ const nameParts = existingName.trim().split(/\s+/).filter((p) => p.length > 0)
84
+ const [firstName, setFirstName] = React.useState(() => isPerson ? (nameParts[0] || '') : '')
85
+ const [lastName, setLastName] = React.useState(() => isPerson ? (nameParts.slice(1).join(' ') || '') : '')
86
+
87
+ const updatePersonName = React.useCallback((first: string, last: string) => {
88
+ const combined = `${first} ${last}`.trim()
89
+ updateField('name', combined)
90
+ }, [updateField])
91
+
92
+ const lastNameMissing = isPerson && !lastName.trim()
93
+
78
94
  return (
79
95
  <div className="space-y-3">
80
96
  <div className="grid grid-cols-2 gap-3">
81
- <div>
82
- <Label>{t('inbox_ops.edit_dialog.name', 'Name')}</Label>
83
- <Input value={(payload.name as string) || ''} onChange={(event) => updateField('name', event.target.value)} />
84
- </div>
97
+ {isPerson ? (
98
+ <>
99
+ <div>
100
+ <Label>{t('inbox_ops.contact.first_name', 'First Name')}</Label>
101
+ <Input
102
+ value={firstName}
103
+ onChange={(event) => {
104
+ setFirstName(event.target.value)
105
+ updatePersonName(event.target.value, lastName)
106
+ }}
107
+ />
108
+ </div>
109
+ <div>
110
+ <Label>{t('inbox_ops.contact.last_name', 'Last Name')}</Label>
111
+ <Input
112
+ value={lastName}
113
+ onChange={(event) => {
114
+ setLastName(event.target.value)
115
+ updatePersonName(firstName, event.target.value)
116
+ }}
117
+ className={lastNameMissing ? 'border-red-500' : ''}
118
+ />
119
+ {lastNameMissing && (
120
+ <p className="text-xs text-red-600 mt-1">{t('inbox_ops.contact.last_name_required', 'Last name is required')}</p>
121
+ )}
122
+ </div>
123
+ </>
124
+ ) : (
125
+ <div>
126
+ <Label>{t('inbox_ops.edit_dialog.name', 'Name')}</Label>
127
+ <Input value={existingName} onChange={(event) => updateField('name', event.target.value)} />
128
+ </div>
129
+ )}
85
130
  <div>
86
131
  <Label>{t('inbox_ops.edit_dialog.type', 'Type')}</Label>
87
132
  <select
88
133
  className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
89
- value={(payload.type as string) || 'person'}
134
+ value={type}
90
135
  onChange={(event) => updateField('type', event.target.value)}
91
136
  >
92
137
  <option value="person">{t('inbox_ops.contact_type.person', 'Person')}</option>
@@ -367,6 +412,9 @@ export function EditActionDialog({
367
412
  onSaved: () => void
368
413
  }) {
369
414
  const t = useT()
415
+ const { runMutation } = useGuardedMutation<Record<string, unknown>>({
416
+ contextId: 'inbox-ops-edit-action',
417
+ })
370
418
  const [payload, setPayload] = React.useState<Record<string, unknown>>(
371
419
  () => structuredClone(action.payload),
372
420
  )
@@ -388,10 +436,13 @@ export function EditActionDialog({
388
436
  }
389
437
 
390
438
  setIsSaving(true)
391
- const result = await apiCall<{ ok: boolean; error?: string }>(
392
- `/api/inbox_ops/proposals/${action.proposalId}/actions/${action.id}`,
393
- { method: 'PATCH', body: JSON.stringify({ payload: finalPayload }) },
394
- )
439
+ const result = await runMutation({
440
+ operation: () => apiCall<{ ok: boolean; error?: string }>(
441
+ `/api/inbox_ops/proposals/${action.proposalId}/actions/${action.id}`,
442
+ { method: 'PATCH', body: JSON.stringify({ payload: finalPayload }) },
443
+ ),
444
+ context: {},
445
+ })
395
446
  if (result?.ok && result.result?.ok) {
396
447
  flash(t('inbox_ops.edit_dialog.saved', 'Action updated successfully'), 'success')
397
448
  onSaved()
@@ -400,7 +451,7 @@ export function EditActionDialog({
400
451
  flash(result?.result?.error || t('inbox_ops.flash.save_failed', 'Failed to save'), 'error')
401
452
  }
402
453
  setIsSaving(false)
403
- }, [action, payload, jsonMode, jsonText, t, onSaved, onClose])
454
+ }, [action, payload, jsonMode, jsonText, t, onSaved, onClose, runMutation])
404
455
 
405
456
  React.useEffect(() => {
406
457
  function handleKeyDown(event: KeyboardEvent) {
@@ -471,6 +522,7 @@ export function EditActionDialog({
471
522
 
472
523
  {hasTypedEditor && (
473
524
  <Button
525
+ type="button"
474
526
  variant="ghost"
475
527
  size="sm"
476
528
  onClick={() => {
@@ -494,10 +546,10 @@ export function EditActionDialog({
494
546
  </div>
495
547
 
496
548
  <DialogFooter>
497
- <Button variant="outline" onClick={onClose} disabled={isSaving}>
549
+ <Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
498
550
  {t('inbox_ops.edit_dialog.cancel', 'Cancel')}
499
551
  </Button>
500
- <Button onClick={handleSave} disabled={isSaving}>
552
+ <Button type="button" onClick={handleSave} disabled={isSaving}>
501
553
  {isSaving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
502
554
  {t('inbox_ops.edit_dialog.save', 'Save Changes')}
503
555
  </Button>
@@ -1,5 +1,14 @@
1
1
  import type { InboxActionType } from '../data/entities'
2
2
 
3
+ /**
4
+ * Synchronous action-type-to-RBAC-feature mapping.
5
+ *
6
+ * The generated inbox action registry (`getInboxAction(type)?.requiredFeature`)
7
+ * provides the same data but requires async loading. This map exists as the
8
+ * synchronous equivalent used by the extraction worker and execution engine.
9
+ *
10
+ * TODO: Consolidate with the generated registry once it supports sync access.
11
+ */
3
12
  export const REQUIRED_FEATURES_MAP: Record<InboxActionType, string> = {
4
13
  create_order: 'sales.orders.manage',
5
14
  create_quote: 'sales.quotes.manage',
@@ -0,0 +1,54 @@
1
+ import type { InboxActionType } from '../data/entities'
2
+
3
+ /**
4
+ * Check if a contact action has a name issue that prevents acceptance.
5
+ * - create_contact (person): requires first+last name (2+ space-separated parts)
6
+ * - link_contact: requires a non-empty contactName
7
+ */
8
+ export function hasContactNameIssue(action: {
9
+ actionType: InboxActionType | string
10
+ payload: Record<string, unknown>
11
+ }): boolean {
12
+ if (action.actionType === 'link_contact') {
13
+ const contactName = (action.payload.contactName as string) || ''
14
+ return contactName.trim().length === 0
15
+ }
16
+ if (action.actionType !== 'create_contact') return false
17
+ const type = (action.payload.type as string) || 'person'
18
+ if (type !== 'person') return false
19
+ const name = (action.payload.name as string) || ''
20
+ return name.trim().split(/\s+/).length < 2
21
+ }
22
+
23
+ /**
24
+ * Split a full name into first and last name parts.
25
+ * Falls back to deriving name parts from email when name is a single word.
26
+ */
27
+ export function splitPersonName(name: string, email?: string): { firstName: string; lastName: string } {
28
+ const trimmed = name.trim()
29
+ const parts = trimmed.split(/\s+/).filter((item) => item.length > 0)
30
+
31
+ if (parts.length >= 2) {
32
+ return {
33
+ firstName: parts[0],
34
+ lastName: parts.slice(1).join(' '),
35
+ }
36
+ }
37
+
38
+ // Fallback: try to derive first/last from email address
39
+ if (email) {
40
+ const localPart = email.split('@')[0] || ''
41
+ const emailParts = localPart.split(/[._-]/).filter((p) => p.length > 0)
42
+ if (emailParts.length >= 2) {
43
+ return {
44
+ firstName: emailParts[0].charAt(0).toUpperCase() + emailParts[0].slice(1).toLowerCase(),
45
+ lastName: emailParts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' '),
46
+ }
47
+ }
48
+ }
49
+
50
+ return {
51
+ firstName: parts[0] || trimmed,
52
+ lastName: '',
53
+ }
54
+ }