@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/phone_call/index.js +53 -0
- package/dist/generated/entities/phone_call/index.js.map +7 -0
- package/dist/generated/entities/phone_call_participant/index.js +27 -0
- package/dist/generated/entities/phone_call_participant/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +5 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +41 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/configs/lib/upgrade-actions.js +20 -0
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/entities/migrations/Migration20260822120000.js +44 -0
- package/dist/modules/entities/migrations/Migration20260822120000.js.map +7 -0
- package/dist/modules/phone_calls/acl.js +15 -0
- package/dist/modules/phone_calls/acl.js.map +7 -0
- package/dist/modules/phone_calls/api/calls/route.js +132 -0
- package/dist/modules/phone_calls/api/calls/route.js.map +7 -0
- package/dist/modules/phone_calls/api/openapi.js +23 -0
- package/dist/modules/phone_calls/api/openapi.js.map +7 -0
- package/dist/modules/phone_calls/backend/page.js +261 -0
- package/dist/modules/phone_calls/backend/page.js.map +7 -0
- package/dist/modules/phone_calls/backend/page.meta.js +16 -0
- package/dist/modules/phone_calls/backend/page.meta.js.map +7 -0
- package/dist/modules/phone_calls/commands/calls.js +125 -0
- package/dist/modules/phone_calls/commands/calls.js.map +7 -0
- package/dist/modules/phone_calls/commands/index.js +2 -0
- package/dist/modules/phone_calls/commands/index.js.map +7 -0
- package/dist/modules/phone_calls/data/entities.js +162 -0
- package/dist/modules/phone_calls/data/entities.js.map +7 -0
- package/dist/modules/phone_calls/data/validators.js +68 -0
- package/dist/modules/phone_calls/data/validators.js.map +7 -0
- package/dist/modules/phone_calls/di.js +12 -0
- package/dist/modules/phone_calls/di.js.map +7 -0
- package/dist/modules/phone_calls/encryption.js +28 -0
- package/dist/modules/phone_calls/encryption.js.map +7 -0
- package/dist/modules/phone_calls/events.js +18 -0
- package/dist/modules/phone_calls/events.js.map +7 -0
- package/dist/modules/phone_calls/index.js +15 -0
- package/dist/modules/phone_calls/index.js.map +7 -0
- package/dist/modules/phone_calls/migrations/Migration20260706124120_phone_calls.js +22 -0
- package/dist/modules/phone_calls/migrations/Migration20260706124120_phone_calls.js.map +7 -0
- package/dist/modules/phone_calls/migrations/Migration20260814124333_phone_calls.js +13 -0
- package/dist/modules/phone_calls/migrations/Migration20260814124333_phone_calls.js.map +7 -0
- package/dist/modules/phone_calls/setup.js +16 -0
- package/dist/modules/phone_calls/setup.js.map +7 -0
- package/generated/entities/phone_call/index.ts +25 -0
- package/generated/entities/phone_call_participant/index.ts +12 -0
- package/generated/entities.ids.generated.ts +5 -0
- package/generated/entity-fields-registry.ts +41 -0
- package/package.json +7 -7
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/configs/lib/upgrade-actions.ts +20 -0
- package/src/modules/entities/migrations/Migration20260822120000.ts +56 -0
- package/src/modules/phone_calls/acl.ts +11 -0
- package/src/modules/phone_calls/api/calls/route.ts +150 -0
- package/src/modules/phone_calls/api/openapi.ts +25 -0
- package/src/modules/phone_calls/backend/page.meta.ts +12 -0
- package/src/modules/phone_calls/backend/page.tsx +303 -0
- package/src/modules/phone_calls/commands/calls.ts +156 -0
- package/src/modules/phone_calls/commands/index.ts +1 -0
- package/src/modules/phone_calls/data/entities.ts +144 -0
- package/src/modules/phone_calls/data/validators.ts +69 -0
- package/src/modules/phone_calls/di.ts +10 -0
- package/src/modules/phone_calls/encryption.ts +26 -0
- package/src/modules/phone_calls/events.ts +25 -0
- package/src/modules/phone_calls/i18n/de.json +42 -0
- package/src/modules/phone_calls/i18n/en.json +42 -0
- package/src/modules/phone_calls/i18n/es.json +42 -0
- package/src/modules/phone_calls/i18n/ko.json +42 -0
- package/src/modules/phone_calls/i18n/pl.json +42 -0
- package/src/modules/phone_calls/index.ts +13 -0
- package/src/modules/phone_calls/migrations/.snapshot-open-mercato.json +772 -0
- package/src/modules/phone_calls/migrations/Migration20260706124120_phone_calls.ts +23 -0
- package/src/modules/phone_calls/migrations/Migration20260814124333_phone_calls.ts +13 -0
- 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
|
+
}
|