@open-mercato/core 0.7.1-develop.7175.1.d49ab48ee2 → 0.7.1-develop.7176.1.d229567a50

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 (79) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/phone_call/index.js +53 -0
  3. package/dist/generated/entities/phone_call/index.js.map +7 -0
  4. package/dist/generated/entities/phone_call_participant/index.js +27 -0
  5. package/dist/generated/entities/phone_call_participant/index.js.map +7 -0
  6. package/dist/generated/entities.ids.generated.js +5 -0
  7. package/dist/generated/entities.ids.generated.js.map +2 -2
  8. package/dist/generated/entity-fields-registry.js +41 -0
  9. package/dist/generated/entity-fields-registry.js.map +2 -2
  10. package/dist/modules/configs/lib/upgrade-actions.js +20 -0
  11. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  12. package/dist/modules/entities/migrations/Migration20260822120000.js +44 -0
  13. package/dist/modules/entities/migrations/Migration20260822120000.js.map +7 -0
  14. package/dist/modules/phone_calls/acl.js +15 -0
  15. package/dist/modules/phone_calls/acl.js.map +7 -0
  16. package/dist/modules/phone_calls/api/calls/route.js +132 -0
  17. package/dist/modules/phone_calls/api/calls/route.js.map +7 -0
  18. package/dist/modules/phone_calls/api/openapi.js +23 -0
  19. package/dist/modules/phone_calls/api/openapi.js.map +7 -0
  20. package/dist/modules/phone_calls/backend/page.js +261 -0
  21. package/dist/modules/phone_calls/backend/page.js.map +7 -0
  22. package/dist/modules/phone_calls/backend/page.meta.js +16 -0
  23. package/dist/modules/phone_calls/backend/page.meta.js.map +7 -0
  24. package/dist/modules/phone_calls/commands/calls.js +125 -0
  25. package/dist/modules/phone_calls/commands/calls.js.map +7 -0
  26. package/dist/modules/phone_calls/commands/index.js +2 -0
  27. package/dist/modules/phone_calls/commands/index.js.map +7 -0
  28. package/dist/modules/phone_calls/data/entities.js +162 -0
  29. package/dist/modules/phone_calls/data/entities.js.map +7 -0
  30. package/dist/modules/phone_calls/data/validators.js +68 -0
  31. package/dist/modules/phone_calls/data/validators.js.map +7 -0
  32. package/dist/modules/phone_calls/di.js +12 -0
  33. package/dist/modules/phone_calls/di.js.map +7 -0
  34. package/dist/modules/phone_calls/encryption.js +28 -0
  35. package/dist/modules/phone_calls/encryption.js.map +7 -0
  36. package/dist/modules/phone_calls/events.js +18 -0
  37. package/dist/modules/phone_calls/events.js.map +7 -0
  38. package/dist/modules/phone_calls/index.js +15 -0
  39. package/dist/modules/phone_calls/index.js.map +7 -0
  40. package/dist/modules/phone_calls/migrations/Migration20260706124120_phone_calls.js +22 -0
  41. package/dist/modules/phone_calls/migrations/Migration20260706124120_phone_calls.js.map +7 -0
  42. package/dist/modules/phone_calls/migrations/Migration20260814124333_phone_calls.js +13 -0
  43. package/dist/modules/phone_calls/migrations/Migration20260814124333_phone_calls.js.map +7 -0
  44. package/dist/modules/phone_calls/setup.js +16 -0
  45. package/dist/modules/phone_calls/setup.js.map +7 -0
  46. package/generated/entities/phone_call/index.ts +25 -0
  47. package/generated/entities/phone_call_participant/index.ts +12 -0
  48. package/generated/entities.ids.generated.ts +5 -0
  49. package/generated/entity-fields-registry.ts +41 -0
  50. package/package.json +7 -7
  51. package/src/modules/auth/i18n/de.json +3 -0
  52. package/src/modules/auth/i18n/en.json +3 -0
  53. package/src/modules/auth/i18n/es.json +3 -0
  54. package/src/modules/auth/i18n/ko.json +3 -0
  55. package/src/modules/auth/i18n/pl.json +3 -0
  56. package/src/modules/configs/lib/upgrade-actions.ts +20 -0
  57. package/src/modules/entities/migrations/Migration20260822120000.ts +56 -0
  58. package/src/modules/phone_calls/acl.ts +11 -0
  59. package/src/modules/phone_calls/api/calls/route.ts +150 -0
  60. package/src/modules/phone_calls/api/openapi.ts +25 -0
  61. package/src/modules/phone_calls/backend/page.meta.ts +12 -0
  62. package/src/modules/phone_calls/backend/page.tsx +303 -0
  63. package/src/modules/phone_calls/commands/calls.ts +156 -0
  64. package/src/modules/phone_calls/commands/index.ts +1 -0
  65. package/src/modules/phone_calls/data/entities.ts +144 -0
  66. package/src/modules/phone_calls/data/validators.ts +69 -0
  67. package/src/modules/phone_calls/di.ts +10 -0
  68. package/src/modules/phone_calls/encryption.ts +26 -0
  69. package/src/modules/phone_calls/events.ts +25 -0
  70. package/src/modules/phone_calls/i18n/de.json +42 -0
  71. package/src/modules/phone_calls/i18n/en.json +42 -0
  72. package/src/modules/phone_calls/i18n/es.json +42 -0
  73. package/src/modules/phone_calls/i18n/ko.json +42 -0
  74. package/src/modules/phone_calls/i18n/pl.json +42 -0
  75. package/src/modules/phone_calls/index.ts +13 -0
  76. package/src/modules/phone_calls/migrations/.snapshot-open-mercato.json +772 -0
  77. package/src/modules/phone_calls/migrations/Migration20260706124120_phone_calls.ts +23 -0
  78. package/src/modules/phone_calls/migrations/Migration20260814124333_phone_calls.ts +13 -0
  79. package/src/modules/phone_calls/setup.ts +14 -0
@@ -0,0 +1,303 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import type { SortingState } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
8
+ import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
9
+ import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge'
10
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
11
+ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
12
+ import { useProgressPoll } from '@open-mercato/ui/backend/progress/useProgressPoll'
13
+ import { formatDateTime } from '@open-mercato/shared/lib/time'
14
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
15
+ import { PHONE_CALL_RESOURCE_KIND } from '@open-mercato/shared/modules/phone_calls/types'
16
+
17
+ type PhoneCallRow = {
18
+ id: string
19
+ provider_key: string | null
20
+ external_call_id: string | null
21
+ direction: string | null
22
+ status: string | null
23
+ started_at: string | null
24
+ duration_seconds: number | null
25
+ ingest_status: string | null
26
+ }
27
+
28
+ type PhoneCallListResponse = {
29
+ items?: PhoneCallRow[]
30
+ total?: number
31
+ page?: number
32
+ totalPages?: number
33
+ totalIsCapped?: boolean
34
+ }
35
+
36
+ const DIRECTION_VALUES = ['inbound', 'outbound', 'internal', 'unknown'] as const
37
+ const STATUS_VALUES = ['new', 'ringing', 'answered', 'missed', 'failed', 'completed', 'unknown'] as const
38
+
39
+ type PhoneCallStatusValue = typeof STATUS_VALUES[number]
40
+
41
+ function isPhoneCallStatus(value: string): value is PhoneCallStatusValue {
42
+ return (STATUS_VALUES as readonly string[]).includes(value)
43
+ }
44
+
45
+ // Column id (snake_case accessorKey) -> API sortField key (camelCase, matches
46
+ // the route's sortFieldMap). Columns absent from this map are not sortable.
47
+ const SORT_FIELDS: Record<string, string> = {
48
+ external_call_id: 'externalCallId',
49
+ direction: 'direction',
50
+ status: 'status',
51
+ provider_key: 'providerKey',
52
+ started_at: 'startedAt',
53
+ duration_seconds: 'durationSeconds',
54
+ ingest_status: 'ingestStatus',
55
+ }
56
+
57
+ const STATUS_TONE: StatusMap<PhoneCallStatusValue> = {
58
+ completed: 'success',
59
+ answered: 'success',
60
+ ringing: 'warning',
61
+ new: 'neutral',
62
+ missed: 'error',
63
+ failed: 'error',
64
+ unknown: 'neutral',
65
+ }
66
+
67
+ function formatDuration(seconds: number | null): string {
68
+ if (seconds == null || Number.isNaN(seconds)) return '—'
69
+ const total = Math.max(0, Math.floor(seconds))
70
+ const mins = Math.floor(total / 60)
71
+ const secs = total % 60
72
+ return `${mins}:${String(secs).padStart(2, '0')}`
73
+ }
74
+
75
+ export default function PhoneCallsPage() {
76
+ const t = useT()
77
+ const [rows, setRows] = React.useState<PhoneCallRow[]>([])
78
+ const [total, setTotal] = React.useState(0)
79
+ const [totalPages, setTotalPages] = React.useState(1)
80
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
81
+ const [page, setPage] = React.useState(1)
82
+ const [pageSize, setPageSize] = React.useState(50)
83
+ const [search, setSearch] = React.useState('')
84
+ const [sorting, setSorting] = React.useState<SortingState>([{ id: 'started_at', desc: true }])
85
+ const [filterValues, setFilterValues] = React.useState<FilterValues>({})
86
+
87
+ const handleSortingChange = React.useCallback((next: SortingState) => {
88
+ setSorting(next)
89
+ setPage(1)
90
+ }, [])
91
+ const [isLoading, setIsLoading] = React.useState(true)
92
+ const [error, setError] = React.useState<string | null>(null)
93
+ const [reloadToken, setReloadToken] = React.useState(0)
94
+
95
+ // Ingest runs in a provider worker, so the rows land after the page did. Any job that
96
+ // declares this resource kind wrote calls, whichever provider queued it.
97
+ useAppEvent('progress.job.completed', (event) => {
98
+ const meta = (event.payload as { meta?: Record<string, unknown> | null } | undefined)?.meta
99
+ if (meta?.resourceKind !== PHONE_CALL_RESOURCE_KIND) return
100
+ setReloadToken((token) => token + 1)
101
+ }, [])
102
+
103
+ // The event above is a live subscription with no replay: one emitted while the stream is
104
+ // reconnecting is gone, and the grid keeps showing a range the ingest already filled. Polling
105
+ // asks for state instead of waiting to be told, keyed by job id so nothing reloads twice.
106
+ const { recentlyCompleted } = useProgressPoll()
107
+ const seenJobIds = React.useRef<Set<string> | null>(null)
108
+ React.useEffect(() => {
109
+ const ingestJobs = recentlyCompleted.filter(
110
+ (job) => (job.meta as { resourceKind?: string } | null | undefined)?.resourceKind === PHONE_CALL_RESOURCE_KIND,
111
+ )
112
+ // Jobs that finished before this page mounted are recorded without a reload; the initial
113
+ // load already covers them.
114
+ if (seenJobIds.current === null) {
115
+ seenJobIds.current = new Set(ingestJobs.map((job) => job.id))
116
+ return
117
+ }
118
+ const unseen = ingestJobs.filter((job) => !seenJobIds.current!.has(job.id))
119
+ if (!unseen.length) return
120
+ for (const job of unseen) seenJobIds.current.add(job.id)
121
+ setReloadToken((token) => token + 1)
122
+ }, [recentlyCompleted])
123
+
124
+ const queryString = React.useMemo(() => {
125
+ const params = new URLSearchParams()
126
+ params.set('page', String(page))
127
+ params.set('pageSize', String(pageSize))
128
+ if (search.trim()) params.set('q', search.trim())
129
+ if (typeof filterValues.direction === 'string' && filterValues.direction) params.set('direction', filterValues.direction)
130
+ if (typeof filterValues.status === 'string' && filterValues.status) params.set('status', filterValues.status)
131
+ if (typeof filterValues.providerKey === 'string' && filterValues.providerKey.trim()) params.set('providerKey', filterValues.providerKey.trim())
132
+ const started = filterValues.started as { from?: string; to?: string } | undefined
133
+ if (started?.from) params.set('startedFrom', started.from)
134
+ if (started?.to) params.set('startedTo', started.to)
135
+ const activeSort = sorting[0]
136
+ if (activeSort) {
137
+ const sortField = SORT_FIELDS[activeSort.id]
138
+ if (sortField) {
139
+ params.set('sortField', sortField)
140
+ params.set('sortDir', activeSort.desc ? 'desc' : 'asc')
141
+ }
142
+ }
143
+ return params.toString()
144
+ }, [page, pageSize, search, filterValues, sorting])
145
+
146
+ React.useEffect(() => {
147
+ let cancelled = false
148
+ async function load() {
149
+ setIsLoading(true)
150
+ setError(null)
151
+ const response = await apiCall<PhoneCallListResponse>(`/api/phone_calls/calls?${queryString}`).catch((err: unknown) => ({
152
+ ok: false as const,
153
+ result: { error: err instanceof Error ? err.message : undefined },
154
+ }))
155
+ if (cancelled) return
156
+ if (!response.ok) {
157
+ const errBody = response.result as { error?: string } | undefined
158
+ setError(errBody?.error ?? t('phone_calls.calls.list.error', 'Failed to load phone calls'))
159
+ setRows([])
160
+ setTotal(0)
161
+ setTotalPages(1)
162
+ setTotalIsCapped(false)
163
+ } else {
164
+ const data = (response.result ?? {}) as PhoneCallListResponse
165
+ setRows(Array.isArray(data.items) ? data.items : [])
166
+ setTotal(typeof data.total === 'number' ? data.total : 0)
167
+ setTotalPages(typeof data.totalPages === 'number' ? data.totalPages : 1)
168
+ setTotalIsCapped(data.totalIsCapped === true)
169
+ }
170
+ setIsLoading(false)
171
+ }
172
+ void load()
173
+ return () => { cancelled = true }
174
+ }, [queryString, reloadToken, t])
175
+
176
+ const filters = React.useMemo<FilterDef[]>(() => [
177
+ {
178
+ id: 'direction',
179
+ label: t('phone_calls.calls.filters.direction', 'Direction'),
180
+ type: 'select',
181
+ options: DIRECTION_VALUES.map((value) => ({
182
+ value,
183
+ label: t(`phone_calls.calls.direction.${value}`, value),
184
+ })),
185
+ },
186
+ {
187
+ id: 'status',
188
+ label: t('phone_calls.calls.filters.status', 'Status'),
189
+ type: 'select',
190
+ options: STATUS_VALUES.map((value) => ({
191
+ value,
192
+ label: t(`phone_calls.calls.status.${value}`, value),
193
+ })),
194
+ },
195
+ {
196
+ id: 'providerKey',
197
+ label: t('phone_calls.calls.filters.provider', 'Provider'),
198
+ type: 'text',
199
+ placeholder: t('phone_calls.calls.filters.providerPlaceholder', 'Provider key'),
200
+ },
201
+ {
202
+ id: 'started',
203
+ label: t('phone_calls.calls.filters.started', 'Started'),
204
+ type: 'dateRange',
205
+ },
206
+ ], [t])
207
+
208
+ const columns = React.useMemo<ColumnDef<PhoneCallRow>[]>(() => [
209
+ {
210
+ accessorKey: 'external_call_id',
211
+ header: t('phone_calls.calls.columns.callId', 'Call ID'),
212
+ meta: { truncate: true, maxWidth: '240px' },
213
+ cell: ({ row }) => (
214
+ <span className="text-sm font-medium">{row.original.external_call_id || '—'}</span>
215
+ ),
216
+ },
217
+ {
218
+ accessorKey: 'direction',
219
+ header: t('phone_calls.calls.columns.direction', 'Direction'),
220
+ cell: ({ row }) => {
221
+ const value = row.original.direction
222
+ return <span className="text-sm">{value ? t(`phone_calls.calls.direction.${value}`, value) : '—'}</span>
223
+ },
224
+ },
225
+ {
226
+ accessorKey: 'status',
227
+ header: t('phone_calls.calls.columns.status', 'Status'),
228
+ cell: ({ row }) => {
229
+ const value = row.original.status
230
+ if (!value) return <span className="text-sm text-muted-foreground">—</span>
231
+ return (
232
+ <StatusBadge variant={STATUS_TONE[isPhoneCallStatus(value) ? value : 'unknown']} dot>
233
+ {t(`phone_calls.calls.status.${value}`, value)}
234
+ </StatusBadge>
235
+ )
236
+ },
237
+ },
238
+ {
239
+ accessorKey: 'provider_key',
240
+ header: t('phone_calls.calls.columns.provider', 'Provider'),
241
+ cell: ({ row }) => <span className="text-sm">{row.original.provider_key || '—'}</span>,
242
+ },
243
+ {
244
+ accessorKey: 'started_at',
245
+ header: t('phone_calls.calls.columns.started', 'Started'),
246
+ cell: ({ row }) => <span className="text-sm text-muted-foreground">{formatDateTime(row.original.started_at) ?? '—'}</span>,
247
+ },
248
+ {
249
+ accessorKey: 'duration_seconds',
250
+ header: t('phone_calls.calls.columns.duration', 'Duration'),
251
+ cell: ({ row }) => <span className="text-sm tabular-nums">{formatDuration(row.original.duration_seconds)}</span>,
252
+ },
253
+ {
254
+ accessorKey: 'ingest_status',
255
+ header: t('phone_calls.calls.columns.ingestStatus', 'Ingest'),
256
+ cell: ({ row }) => {
257
+ const value = row.original.ingest_status
258
+ return (
259
+ <span className="text-sm text-muted-foreground">
260
+ {value ? t(`phone_calls.calls.ingestStatus.${value}`, value) : '—'}
261
+ </span>
262
+ )
263
+ },
264
+ },
265
+ ], [t])
266
+
267
+ return (
268
+ <Page>
269
+ <PageBody>
270
+ <DataTable<PhoneCallRow>
271
+ title={t('phone_calls.calls.list.title', 'Phone Calls')}
272
+ extensionTableId="phone_calls.calls"
273
+ columns={columns}
274
+ data={rows}
275
+ isLoading={isLoading}
276
+ error={error}
277
+ sortable
278
+ manualSorting
279
+ sorting={sorting}
280
+ onSortingChange={handleSortingChange}
281
+ searchValue={search}
282
+ onSearchChange={(value) => { setSearch(value); setPage(1) }}
283
+ searchPlaceholder={t('phone_calls.calls.list.searchPlaceholder', 'Search by call, conversation or provider')}
284
+ filters={filters}
285
+ filterValues={filterValues}
286
+ onFiltersApply={(values) => { setFilterValues(values); setPage(1) }}
287
+ onFiltersClear={() => { setFilterValues({}); setPage(1) }}
288
+ emptyState={t('phone_calls.calls.list.empty', 'No phone calls yet. Calls appear here once they are ingested from a provider.')}
289
+ pagination={{
290
+ page,
291
+ pageSize,
292
+ total,
293
+ totalPages,
294
+ totalIsCapped,
295
+ onPageChange: setPage,
296
+ pageSizeOptions: [10, 25, 50, 100],
297
+ onPageSizeChange: (size) => { setPageSize(size); setPage(1) },
298
+ }}
299
+ />
300
+ </PageBody>
301
+ </Page>
302
+ )
303
+ }
@@ -0,0 +1,156 @@
1
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
2
+ import type { CommandHandler } from '@open-mercato/shared/lib/commands'
3
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
4
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
5
+ import { ensureOrganizationScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
6
+ import type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
7
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
8
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
+ import { PHONE_CALL_RESOURCE_KIND } from '@open-mercato/shared/modules/phone_calls/types'
10
+ import type { EntityManager } from '@mikro-orm/postgresql'
11
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
+ import { E } from '#generated/entities.ids.generated'
13
+ import { PhoneCall, PhoneCallParticipant } from '../data/entities'
14
+ import {
15
+ ingestPhoneCallSchema,
16
+ type IngestPhoneCallInput,
17
+ } from '../data/validators'
18
+ import { emitPhoneCallsEvent } from '../events'
19
+
20
+ export const PHONE_CALLS_CALL_INGEST_COMMAND_ID = 'phone_calls.call.ingest'
21
+ export { PHONE_CALL_RESOURCE_KIND }
22
+
23
+ const phoneCallCrudIndexer: CrudIndexerConfig<PhoneCall> = {
24
+ entityType: E.phone_calls.phone_call,
25
+ }
26
+
27
+ // The list route derives its cache tag from the entity class name, and the tag the bus
28
+ // derives from this command id is a different one, so without the alias the cached list
29
+ // pages survive an ingest.
30
+ const PHONE_CALL_CACHE_ALIASES = [PhoneCall.name]
31
+
32
+ export type IngestPhoneCallResult = { phoneCallId: string; created: boolean }
33
+
34
+ function applyScalarFields(call: PhoneCall, input: IngestPhoneCallInput, ingestedAt: Date): void {
35
+ call.integrationId = input.integrationId ?? null
36
+ call.externalConversationId = input.externalConversationId ?? null
37
+ call.direction = input.direction
38
+ call.status = input.status
39
+ call.startedAt = input.startedAt ?? null
40
+ call.answeredAt = input.answeredAt ?? null
41
+ call.endedAt = input.endedAt ?? null
42
+ call.durationSeconds = input.durationSeconds ?? null
43
+ call.recordingUrl = input.recording?.url ?? null
44
+ call.providerFacts = input.providerFacts ?? null
45
+ call.rawSnapshot = input.rawPayload
46
+ call.ingestStatus = 'complete'
47
+ call.lastIngestedAt = ingestedAt
48
+ }
49
+
50
+ async function syncParticipants(
51
+ em: EntityManager,
52
+ call: PhoneCall,
53
+ input: IngestPhoneCallInput,
54
+ ): Promise<void> {
55
+ // Replace the whole participant set on every ingest: participants have no reliable
56
+ // natural key (providerParticipantId/phoneNumber are often null), so a diff/upsert would
57
+ // be guesswork. Delete-all + insert stays deterministic and idempotent. Revisit this if
58
+ // anything starts referencing participant.id externally (re-pull rotates these ids).
59
+ await em.nativeDelete(PhoneCallParticipant, { phoneCallId: call.id })
60
+ for (const participant of input.participants) {
61
+ em.persist(em.create(PhoneCallParticipant, {
62
+ organizationId: call.organizationId,
63
+ tenantId: call.tenantId,
64
+ phoneCallId: call.id,
65
+ providerParticipantId: participant.providerParticipantId ?? null,
66
+ role: participant.role,
67
+ phoneNumber: participant.phoneNumber ?? null,
68
+ displayName: participant.displayName ?? null,
69
+ email: participant.email ?? null,
70
+ metadata: participant.metadata ?? null,
71
+ }))
72
+ }
73
+ }
74
+
75
+ const ingestPhoneCallCommand: CommandHandler<IngestPhoneCallInput, IngestPhoneCallResult> = {
76
+ id: PHONE_CALLS_CALL_INGEST_COMMAND_ID,
77
+ async execute(rawInput, ctx) {
78
+ const input = ingestPhoneCallSchema.parse(rawInput)
79
+ ensureTenantScope(ctx, input.tenantId)
80
+ ensureOrganizationScope(ctx, input.organizationId)
81
+
82
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
83
+ const existing = await findOneWithDecryption(
84
+ em,
85
+ PhoneCall,
86
+ {
87
+ providerKey: input.providerKey,
88
+ externalCallId: input.externalCallId,
89
+ tenantId: input.tenantId,
90
+ organizationId: input.organizationId,
91
+ },
92
+ undefined,
93
+ { tenantId: input.tenantId, organizationId: input.organizationId },
94
+ )
95
+ const created = !existing
96
+ const call = existing ?? em.create(PhoneCall, {
97
+ organizationId: input.organizationId,
98
+ tenantId: input.tenantId,
99
+ providerKey: input.providerKey,
100
+ externalCallId: input.externalCallId,
101
+ direction: input.direction,
102
+ status: input.status,
103
+ })
104
+ if (created) em.persist(call)
105
+ const ingestedAt = new Date()
106
+
107
+ await withAtomicFlush(
108
+ em,
109
+ [
110
+ () => applyScalarFields(call, input, ingestedAt),
111
+ () => syncParticipants(em, call, input),
112
+ ],
113
+ { transaction: true, label: PHONE_CALLS_CALL_INGEST_COMMAND_ID },
114
+ )
115
+
116
+ // The module emits its own declared events, so only the indexer is handed to the
117
+ // shared side effects: the query index and the read caches have to see the write.
118
+ await emitCrudSideEffects({
119
+ dataEngine: ctx.container.resolve('dataEngine') as DataEngine,
120
+ action: created ? 'created' : 'updated',
121
+ entity: call,
122
+ identifiers: {
123
+ id: call.id,
124
+ organizationId: call.organizationId,
125
+ tenantId: call.tenantId,
126
+ },
127
+ indexer: phoneCallCrudIndexer,
128
+ })
129
+
130
+ await emitPhoneCallsEvent(
131
+ created ? 'phone_calls.call.ingested' : 'phone_calls.call.updated',
132
+ { id: call.id, organizationId: call.organizationId, tenantId: call.tenantId },
133
+ ).catch(() => undefined)
134
+
135
+ return { phoneCallId: call.id, created }
136
+ },
137
+ // Calls carry PII (numbers, names, recordings), so the audit entry stays at identifiers
138
+ // and the ingest outcome. No snapshot, and the command is not undoable.
139
+ buildLog: async ({ input, result }) => {
140
+ const { translate } = await resolveTranslations()
141
+ return {
142
+ actionLabel: result.created
143
+ ? translate('phone_calls.audit.calls.ingested', 'Ingest phone call')
144
+ : translate('phone_calls.audit.calls.updated', 'Update ingested phone call'),
145
+ resourceKind: PHONE_CALL_RESOURCE_KIND,
146
+ resourceId: result.phoneCallId,
147
+ tenantId: input.tenantId,
148
+ organizationId: input.organizationId,
149
+ context: { cacheAliases: PHONE_CALL_CACHE_ALIASES },
150
+ }
151
+ },
152
+ }
153
+
154
+ registerCommand(ingestPhoneCallCommand)
155
+
156
+ export { ingestPhoneCallCommand }
@@ -0,0 +1 @@
1
+ import './calls'
@@ -0,0 +1,144 @@
1
+ import { OptionalProps } from '@mikro-orm/core'
2
+ import { Entity, Index, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
3
+ import type {
4
+ PhoneCallDirection,
5
+ PhoneCallStatus,
6
+ PhoneCallParticipantRole,
7
+ } from '@open-mercato/shared/modules/phone_calls/types'
8
+
9
+ export type PhoneCallIngestStatus = 'pending' | 'complete' | 'failed'
10
+
11
+ @Entity({ tableName: 'phone_calls' })
12
+ @Index({ name: 'phone_calls_org_tenant_idx', properties: ['organizationId', 'tenantId'] })
13
+ @Unique({
14
+ name: 'phone_calls_provider_external_scope_uq',
15
+ properties: ['providerKey', 'externalCallId', 'tenantId', 'organizationId'],
16
+ })
17
+ @Index({ name: 'phone_calls_org_started_at_idx', properties: ['organizationId', 'startedAt'] })
18
+ @Index({ name: 'phone_calls_org_status_idx', properties: ['organizationId', 'status'] })
19
+ @Index({
20
+ name: 'phone_calls_communication_projection_id_idx',
21
+ expression:
22
+ 'create index "phone_calls_communication_projection_id_idx" on "phone_calls" ("communication_projection_id") where "communication_projection_id" is not null',
23
+ })
24
+ export class PhoneCall {
25
+ [OptionalProps]?: 'ingestStatus' | 'createdAt' | 'updatedAt' | 'deletedAt'
26
+
27
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
28
+ id!: string
29
+
30
+ @Property({ name: 'organization_id', type: 'uuid' })
31
+ organizationId!: string
32
+
33
+ @Property({ name: 'tenant_id', type: 'uuid' })
34
+ tenantId!: string
35
+
36
+ @Property({ name: 'provider_key', type: 'text' })
37
+ providerKey!: string
38
+
39
+ @Property({ name: 'integration_id', type: 'text', nullable: true })
40
+ integrationId?: string | null
41
+
42
+ @Property({ name: 'external_call_id', type: 'text' })
43
+ externalCallId!: string
44
+
45
+ @Property({ name: 'external_conversation_id', type: 'text', nullable: true })
46
+ externalConversationId?: string | null
47
+
48
+ @Property({ name: 'direction', type: 'text' })
49
+ direction!: PhoneCallDirection
50
+
51
+ @Property({ name: 'status', type: 'text' })
52
+ status!: PhoneCallStatus
53
+
54
+ @Property({ name: 'started_at', type: Date, nullable: true })
55
+ startedAt?: Date | null
56
+
57
+ @Property({ name: 'answered_at', type: Date, nullable: true })
58
+ answeredAt?: Date | null
59
+
60
+ @Property({ name: 'ended_at', type: Date, nullable: true })
61
+ endedAt?: Date | null
62
+
63
+ @Property({ name: 'duration_seconds', type: 'integer', nullable: true })
64
+ durationSeconds?: number | null
65
+
66
+ @Property({ name: 'recording_url', type: 'text', nullable: true })
67
+ recordingUrl?: string | null
68
+
69
+ @Property({ name: 'recording_attachment_id', type: 'uuid', nullable: true })
70
+ recordingAttachmentId?: string | null
71
+
72
+ @Property({ name: 'active_transcript_version_id', type: 'uuid', nullable: true })
73
+ activeTranscriptVersionId?: string | null
74
+
75
+ @Property({ name: 'active_summary_version_id', type: 'uuid', nullable: true })
76
+ activeSummaryVersionId?: string | null
77
+
78
+ @Property({ name: 'communication_projection_id', type: 'uuid', nullable: true })
79
+ communicationProjectionId?: string | null
80
+
81
+ @Property({ name: 'provider_facts', type: 'jsonb', nullable: true })
82
+ providerFacts?: Record<string, unknown> | null
83
+
84
+ @Property({ name: 'raw_snapshot', type: 'jsonb', nullable: true })
85
+ rawSnapshot?: Record<string, unknown> | null
86
+
87
+ @Property({ name: 'ingest_status', type: 'text', default: 'pending' })
88
+ ingestStatus: PhoneCallIngestStatus = 'pending'
89
+
90
+ @Property({ name: 'last_ingested_at', type: Date, nullable: true })
91
+ lastIngestedAt?: Date | null
92
+
93
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
94
+ createdAt: Date = new Date()
95
+
96
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
97
+ updatedAt: Date = new Date()
98
+
99
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
100
+ deletedAt?: Date | null
101
+ }
102
+
103
+ @Entity({ tableName: 'phone_call_participants' })
104
+ @Index({ name: 'phone_call_participants_call_idx', properties: ['phoneCallId'] })
105
+ @Index({ name: 'phone_call_participants_org_tenant_idx', properties: ['organizationId', 'tenantId'] })
106
+ export class PhoneCallParticipant {
107
+ [OptionalProps]?: 'createdAt' | 'updatedAt'
108
+
109
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
110
+ id!: string
111
+
112
+ @Property({ name: 'organization_id', type: 'uuid' })
113
+ organizationId!: string
114
+
115
+ @Property({ name: 'tenant_id', type: 'uuid' })
116
+ tenantId!: string
117
+
118
+ @ManyToOne(() => PhoneCall, { fieldName: 'phone_call_id', deleteRule: 'cascade', mapToPk: true })
119
+ phoneCallId!: string
120
+
121
+ @Property({ name: 'provider_participant_id', type: 'text', nullable: true })
122
+ providerParticipantId?: string | null
123
+
124
+ @Property({ name: 'role', type: 'text' })
125
+ role!: PhoneCallParticipantRole
126
+
127
+ @Property({ name: 'phone_number', type: 'text', nullable: true })
128
+ phoneNumber?: string | null
129
+
130
+ @Property({ name: 'display_name', type: 'text', nullable: true })
131
+ displayName?: string | null
132
+
133
+ @Property({ name: 'email', type: 'text', nullable: true })
134
+ email?: string | null
135
+
136
+ @Property({ name: 'metadata', type: 'jsonb', nullable: true })
137
+ metadata?: Record<string, unknown> | null
138
+
139
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
140
+ createdAt: Date = new Date()
141
+
142
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
143
+ updatedAt: Date = new Date()
144
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod'
2
+
3
+ const uuid = () => z.string().uuid()
4
+
5
+ export const scopedSchema = z.object({
6
+ organizationId: uuid(),
7
+ tenantId: uuid(),
8
+ })
9
+
10
+ export const phoneCallDirectionSchema = z.enum([
11
+ 'inbound',
12
+ 'outbound',
13
+ 'internal',
14
+ 'unknown',
15
+ ])
16
+
17
+ export const phoneCallStatusSchema = z.enum([
18
+ 'new',
19
+ 'ringing',
20
+ 'answered',
21
+ 'missed',
22
+ 'failed',
23
+ 'completed',
24
+ 'unknown',
25
+ ])
26
+
27
+ export const phoneCallParticipantRoleSchema = z.enum([
28
+ 'caller',
29
+ 'callee',
30
+ 'agent',
31
+ 'unknown',
32
+ ])
33
+
34
+ export const ingestParticipantSchema = z.object({
35
+ role: phoneCallParticipantRoleSchema,
36
+ providerParticipantId: z.string().nullish(),
37
+ phoneNumber: z.string().nullish(),
38
+ displayName: z.string().nullish(),
39
+ email: z.string().nullish(),
40
+ metadata: z.record(z.string(), z.unknown()).optional(),
41
+ })
42
+
43
+ export const ingestRecordingSchema = z.object({
44
+ url: z.string().nullish(),
45
+ providerRecordingId: z.string().nullish(),
46
+ mimeType: z.string().nullish(),
47
+ durationSeconds: z.number().int().nullish(),
48
+ metadata: z.record(z.string(), z.unknown()).optional(),
49
+ })
50
+
51
+ export const ingestPhoneCallSchema = scopedSchema.extend({
52
+ providerKey: z.string().min(1),
53
+ integrationId: z.string().nullish(),
54
+ externalCallId: z.string().min(1),
55
+ externalConversationId: z.string().nullish(),
56
+ direction: phoneCallDirectionSchema,
57
+ status: phoneCallStatusSchema,
58
+ participants: z.array(ingestParticipantSchema).default([]),
59
+ recording: ingestRecordingSchema.nullish(),
60
+ startedAt: z.coerce.date().nullish(),
61
+ answeredAt: z.coerce.date().nullish(),
62
+ endedAt: z.coerce.date().nullish(),
63
+ durationSeconds: z.number().int().nullish(),
64
+ providerFacts: z.record(z.string(), z.unknown()).optional(),
65
+ rawPayload: z.record(z.string(), z.unknown()),
66
+ })
67
+
68
+ export type IngestPhoneCallInput = z.infer<typeof ingestPhoneCallSchema>
69
+ export type IngestParticipantInput = z.infer<typeof ingestParticipantSchema>
@@ -0,0 +1,10 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { PhoneCall, PhoneCallParticipant } from './data/entities'
4
+
5
+ export function register(container: AppContainer) {
6
+ container.register({
7
+ PhoneCall: asValue(PhoneCall),
8
+ PhoneCallParticipant: asValue(PhoneCallParticipant),
9
+ })
10
+ }