@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.
- package/dist/modules/catalog/inbox-actions.js +51 -0
- package/dist/modules/catalog/inbox-actions.js.map +7 -0
- package/dist/modules/customers/inbox-actions.js +230 -0
- package/dist/modules/customers/inbox-actions.js.map +7 -0
- package/dist/modules/inbox_ops/api/emails/[id]/route.js +40 -1
- package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/extract/route.js +87 -0
- package/dist/modules/inbox_ops/api/extract/route.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js +6 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js +40 -14
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js +161 -79
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +109 -62
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +3 -3
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +36 -14
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/ActionCard.js +65 -10
- package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +58 -10
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/inbox_ops/lib/constants.js.map +2 -2
- package/dist/modules/inbox_ops/lib/contactValidation.js +40 -0
- package/dist/modules/inbox_ops/lib/contactValidation.js.map +7 -0
- package/dist/modules/inbox_ops/lib/executionEngine.js +31 -826
- package/dist/modules/inbox_ops/lib/executionEngine.js.map +3 -3
- package/dist/modules/inbox_ops/lib/executionHelpers.js +368 -0
- package/dist/modules/inbox_ops/lib/executionHelpers.js.map +7 -0
- package/dist/modules/inbox_ops/lib/extractionPrompt.js +28 -35
- package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +3 -3
- package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js +1 -0
- package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js.map +7 -0
- package/dist/modules/inbox_ops/lib/translationProvider.js +15 -10
- package/dist/modules/inbox_ops/lib/translationProvider.js.map +2 -2
- package/dist/modules/inbox_ops/subscribers/extractionWorker.js +16 -16
- package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
- package/dist/modules/sales/inbox-actions.js +278 -0
- package/dist/modules/sales/inbox-actions.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/jest.mocks/inbox-actions.generated.js +5 -0
- package/package.json +2 -2
- package/src/modules/catalog/inbox-actions.ts +60 -0
- package/src/modules/customers/inbox-actions.ts +285 -0
- package/src/modules/inbox_ops/api/emails/[id]/route.ts +44 -0
- package/src/modules/inbox_ops/api/extract/route.ts +94 -0
- package/src/modules/inbox_ops/api/proposals/[id]/translate/route.ts +6 -1
- package/src/modules/inbox_ops/api/proposals/counts/route.ts +2 -0
- package/src/modules/inbox_ops/backend/inbox-ops/log/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/log/page.tsx +43 -13
- package/src/modules/inbox_ops/backend/inbox-ops/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +176 -81
- package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +122 -68
- package/src/modules/inbox_ops/backend/inbox-ops/settings/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +36 -14
- package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +91 -7
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +64 -12
- package/src/modules/inbox_ops/lib/constants.ts +9 -0
- package/src/modules/inbox_ops/lib/contactValidation.ts +54 -0
- package/src/modules/inbox_ops/lib/executionEngine.ts +47 -1060
- package/src/modules/inbox_ops/lib/executionHelpers.ts +527 -0
- package/src/modules/inbox_ops/lib/extractionPrompt.ts +45 -34
- package/src/modules/inbox_ops/lib/inbox-actions-generated.d.ts +11 -0
- package/src/modules/inbox_ops/lib/translationProvider.ts +16 -10
- package/src/modules/inbox_ops/subscribers/extractionWorker.ts +16 -18
- package/src/modules/sales/inbox-actions.ts +359 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { InboxActionDefinition, InboxActionExecutionContext } from '@open-mercato/shared/modules/inbox-actions'
|
|
2
|
+
import { createProductPayloadSchema } from '../inbox_ops/data/validators'
|
|
3
|
+
import type { CreateProductPayload } from '../inbox_ops/data/validators'
|
|
4
|
+
import {
|
|
5
|
+
asHelperContext,
|
|
6
|
+
ExecutionError,
|
|
7
|
+
executeCommand,
|
|
8
|
+
resolveProductDiscrepanciesInProposal,
|
|
9
|
+
} from '../inbox_ops/lib/executionHelpers'
|
|
10
|
+
|
|
11
|
+
async function executeCreateProductAction(
|
|
12
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
13
|
+
ctx: InboxActionExecutionContext,
|
|
14
|
+
): Promise<{ createdEntityId?: string | null; createdEntityType?: string | null }> {
|
|
15
|
+
const hCtx = asHelperContext(ctx)
|
|
16
|
+
const payload = action.payload as CreateProductPayload
|
|
17
|
+
|
|
18
|
+
const createInput: Record<string, unknown> = {
|
|
19
|
+
organizationId: hCtx.organizationId,
|
|
20
|
+
tenantId: hCtx.tenantId,
|
|
21
|
+
title: payload.title,
|
|
22
|
+
productType: 'simple',
|
|
23
|
+
isActive: true,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (payload.sku) createInput.sku = payload.sku
|
|
27
|
+
if (payload.description) createInput.description = payload.description
|
|
28
|
+
if (payload.currencyCode) createInput.primaryCurrencyCode = payload.currencyCode
|
|
29
|
+
|
|
30
|
+
const result = await executeCommand<Record<string, unknown>, { productId?: string }>(
|
|
31
|
+
hCtx,
|
|
32
|
+
'catalog.products.create',
|
|
33
|
+
createInput,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if (!result.productId) {
|
|
37
|
+
throw new ExecutionError('Product creation did not return a product ID', 500)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await resolveProductDiscrepanciesInProposal(hCtx.em, action.proposalId, payload.title, result.productId, {
|
|
41
|
+
tenantId: hCtx.tenantId,
|
|
42
|
+
organizationId: hCtx.organizationId,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return { createdEntityId: result.productId, createdEntityType: 'catalog_product' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const inboxActions: InboxActionDefinition[] = [
|
|
49
|
+
{
|
|
50
|
+
type: 'create_product',
|
|
51
|
+
requiredFeature: 'catalog.products.manage',
|
|
52
|
+
payloadSchema: createProductPayloadSchema,
|
|
53
|
+
label: 'Create Product',
|
|
54
|
+
promptSchema: `create_product payload:
|
|
55
|
+
{ title: string, sku?: string, unitPrice?: string, currencyCode?: string (3-letter ISO), kind?: "product"|"service", description?: string }`,
|
|
56
|
+
execute: executeCreateProductAction,
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
export default inboxActions
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { InboxActionDefinition, InboxActionExecutionContext } from '@open-mercato/shared/modules/inbox-actions'
|
|
2
|
+
import {
|
|
3
|
+
createContactPayloadSchema,
|
|
4
|
+
linkContactPayloadSchema,
|
|
5
|
+
logActivityPayloadSchema,
|
|
6
|
+
draftReplyPayloadSchema,
|
|
7
|
+
} from '../inbox_ops/data/validators'
|
|
8
|
+
import type {
|
|
9
|
+
CreateContactPayload,
|
|
10
|
+
LinkContactPayload,
|
|
11
|
+
LogActivityPayload,
|
|
12
|
+
DraftReplyPayload,
|
|
13
|
+
} from '../inbox_ops/data/validators'
|
|
14
|
+
import {
|
|
15
|
+
asHelperContext,
|
|
16
|
+
ExecutionError,
|
|
17
|
+
executeCommand,
|
|
18
|
+
resolveEntityClass,
|
|
19
|
+
resolveCustomerEntityIdByEmail,
|
|
20
|
+
resolveContactIdByNameAndType,
|
|
21
|
+
} from '../inbox_ops/lib/executionHelpers'
|
|
22
|
+
import { splitPersonName } from '../inbox_ops/lib/contactValidation'
|
|
23
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// create_contact
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
async function executeCreateContactAction(
|
|
30
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
31
|
+
ctx: InboxActionExecutionContext,
|
|
32
|
+
): Promise<{ createdEntityId?: string | null; createdEntityType?: string | null; matchedEntityId?: string | null; matchedEntityType?: string | null }> {
|
|
33
|
+
const hCtx = asHelperContext(ctx)
|
|
34
|
+
const payload = action.payload as CreateContactPayload
|
|
35
|
+
|
|
36
|
+
const CustomerEntityClass = resolveEntityClass(hCtx, 'CustomerEntity')
|
|
37
|
+
if (payload.email && CustomerEntityClass) {
|
|
38
|
+
const emailLower = payload.email.trim().toLowerCase()
|
|
39
|
+
let existingContact = await findOneWithDecryption(
|
|
40
|
+
hCtx.em,
|
|
41
|
+
CustomerEntityClass,
|
|
42
|
+
{
|
|
43
|
+
primaryEmail: emailLower,
|
|
44
|
+
tenantId: hCtx.tenantId,
|
|
45
|
+
organizationId: hCtx.organizationId,
|
|
46
|
+
deletedAt: null,
|
|
47
|
+
},
|
|
48
|
+
undefined,
|
|
49
|
+
{ tenantId: hCtx.tenantId, organizationId: hCtx.organizationId },
|
|
50
|
+
)
|
|
51
|
+
if (!existingContact) {
|
|
52
|
+
const candidates = await findWithDecryption(
|
|
53
|
+
hCtx.em,
|
|
54
|
+
CustomerEntityClass,
|
|
55
|
+
{
|
|
56
|
+
tenantId: hCtx.tenantId,
|
|
57
|
+
organizationId: hCtx.organizationId,
|
|
58
|
+
deletedAt: null,
|
|
59
|
+
},
|
|
60
|
+
{ limit: 100, orderBy: { createdAt: 'DESC' } },
|
|
61
|
+
{ tenantId: hCtx.tenantId, organizationId: hCtx.organizationId },
|
|
62
|
+
)
|
|
63
|
+
existingContact = candidates.find(
|
|
64
|
+
(e) => e.primaryEmail && e.primaryEmail.toLowerCase() === emailLower,
|
|
65
|
+
) ?? null
|
|
66
|
+
}
|
|
67
|
+
if (existingContact) {
|
|
68
|
+
const isCompany = existingContact.kind === 'company'
|
|
69
|
+
return {
|
|
70
|
+
createdEntityId: existingContact.id,
|
|
71
|
+
createdEntityType: isCompany ? 'customer_company' : 'customer_person',
|
|
72
|
+
matchedEntityId: existingContact.id,
|
|
73
|
+
matchedEntityType: isCompany ? 'company' : 'person',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (payload.type === 'company') {
|
|
79
|
+
const result = await executeCommand<Record<string, unknown>, { entityId?: string }>(
|
|
80
|
+
hCtx,
|
|
81
|
+
'customers.companies.create',
|
|
82
|
+
{
|
|
83
|
+
organizationId: hCtx.organizationId,
|
|
84
|
+
tenantId: hCtx.tenantId,
|
|
85
|
+
displayName: payload.name,
|
|
86
|
+
legalName: payload.companyName ?? payload.name,
|
|
87
|
+
primaryEmail: payload.email,
|
|
88
|
+
primaryPhone: payload.phone,
|
|
89
|
+
source: payload.source,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
if (!result.entityId) {
|
|
93
|
+
throw new ExecutionError('Company creation did not return an entity ID', 500)
|
|
94
|
+
}
|
|
95
|
+
return { createdEntityId: result.entityId, createdEntityType: 'customer_company' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { firstName, lastName } = splitPersonName(payload.name, payload.email)
|
|
99
|
+
const result = await executeCommand<Record<string, unknown>, { entityId?: string }>(
|
|
100
|
+
hCtx,
|
|
101
|
+
'customers.people.create',
|
|
102
|
+
{
|
|
103
|
+
organizationId: hCtx.organizationId,
|
|
104
|
+
tenantId: hCtx.tenantId,
|
|
105
|
+
displayName: payload.name,
|
|
106
|
+
firstName,
|
|
107
|
+
lastName,
|
|
108
|
+
primaryEmail: payload.email,
|
|
109
|
+
primaryPhone: payload.phone,
|
|
110
|
+
jobTitle: payload.role,
|
|
111
|
+
source: payload.source,
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (!result.entityId) {
|
|
116
|
+
throw new ExecutionError('Person creation did not return an entity ID', 500)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { createdEntityId: result.entityId, createdEntityType: 'customer_person' }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// link_contact
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function executeLinkContactAction(
|
|
127
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
128
|
+
): { createdEntityId?: string | null; createdEntityType?: string | null; matchedEntityId?: string | null; matchedEntityType?: string | null } {
|
|
129
|
+
const payload = action.payload as LinkContactPayload
|
|
130
|
+
return {
|
|
131
|
+
createdEntityId: payload.contactId,
|
|
132
|
+
createdEntityType: payload.contactType === 'company' ? 'customer_company' : 'customer_person',
|
|
133
|
+
matchedEntityId: payload.contactId,
|
|
134
|
+
matchedEntityType: payload.contactType,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// log_activity
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
async function executeLogActivityAction(
|
|
143
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
144
|
+
ctx: InboxActionExecutionContext,
|
|
145
|
+
): Promise<{ createdEntityId?: string | null; createdEntityType?: string | null }> {
|
|
146
|
+
const hCtx = asHelperContext(ctx)
|
|
147
|
+
let payload = action.payload as LogActivityPayload
|
|
148
|
+
|
|
149
|
+
if (!payload.contactId) {
|
|
150
|
+
const resolved = await resolveContactIdByNameAndType(hCtx, payload.contactName, payload.contactType)
|
|
151
|
+
if (resolved) {
|
|
152
|
+
payload = { ...payload, contactId: resolved }
|
|
153
|
+
} else {
|
|
154
|
+
throw new ExecutionError(
|
|
155
|
+
`log_activity requires contactId — could not resolve contact "${payload.contactName}" (${payload.contactType})`,
|
|
156
|
+
400,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result = await executeCommand<Record<string, unknown>, { activityId?: string }>(
|
|
162
|
+
hCtx,
|
|
163
|
+
'customers.activities.create',
|
|
164
|
+
{
|
|
165
|
+
organizationId: hCtx.organizationId,
|
|
166
|
+
tenantId: hCtx.tenantId,
|
|
167
|
+
entityId: payload.contactId,
|
|
168
|
+
activityType: payload.activityType,
|
|
169
|
+
subject: payload.subject,
|
|
170
|
+
body: payload.body,
|
|
171
|
+
authorUserId: hCtx.userId,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if (!result.activityId) {
|
|
176
|
+
throw new ExecutionError('Activity creation did not return an activity ID', 500)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { createdEntityId: result.activityId, createdEntityType: 'customer_activity' }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// draft_reply
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
async function executeDraftReplyAction(
|
|
187
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
188
|
+
ctx: InboxActionExecutionContext,
|
|
189
|
+
): Promise<{ createdEntityId?: string | null; createdEntityType?: string | null }> {
|
|
190
|
+
const hCtx = asHelperContext(ctx)
|
|
191
|
+
const payload = action.payload as DraftReplyPayload
|
|
192
|
+
const payloadRecord = action.payload as Record<string, unknown>
|
|
193
|
+
const explicitContactId = typeof payloadRecord.contactId === 'string' ? payloadRecord.contactId : null
|
|
194
|
+
const contactId = explicitContactId ?? (await resolveCustomerEntityIdByEmail(hCtx, payload.to))
|
|
195
|
+
|
|
196
|
+
if (!contactId) {
|
|
197
|
+
throw new ExecutionError(
|
|
198
|
+
`No matching contact found for "${payload.to}". Create the contact first or link an existing one.`,
|
|
199
|
+
400,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const details = [
|
|
204
|
+
payload.body.trim(),
|
|
205
|
+
'',
|
|
206
|
+
'---',
|
|
207
|
+
`Draft reply target: ${payload.to}`,
|
|
208
|
+
`Subject: ${payload.subject}`,
|
|
209
|
+
payload.context ? `Context: ${payload.context}` : null,
|
|
210
|
+
`InboxOps Proposal: ${action.proposalId}`,
|
|
211
|
+
`InboxOps Action: ${action.id}`,
|
|
212
|
+
]
|
|
213
|
+
.filter((line) => typeof line === 'string' && line.length > 0)
|
|
214
|
+
.join('\n')
|
|
215
|
+
|
|
216
|
+
const result = await executeCommand<Record<string, unknown>, { activityId?: string }>(
|
|
217
|
+
hCtx,
|
|
218
|
+
'customers.activities.create',
|
|
219
|
+
{
|
|
220
|
+
organizationId: hCtx.organizationId,
|
|
221
|
+
tenantId: hCtx.tenantId,
|
|
222
|
+
entityId: contactId,
|
|
223
|
+
activityType: 'email',
|
|
224
|
+
subject: payload.subject,
|
|
225
|
+
body: details,
|
|
226
|
+
authorUserId: hCtx.userId,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if (!result.activityId) {
|
|
231
|
+
throw new ExecutionError('Draft reply activity did not return an activity ID', 500)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { createdEntityId: result.activityId, createdEntityType: 'customer_activity' }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Exported action definitions
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
export const inboxActions: InboxActionDefinition[] = [
|
|
242
|
+
{
|
|
243
|
+
type: 'create_contact',
|
|
244
|
+
requiredFeature: 'customers.people.manage',
|
|
245
|
+
payloadSchema: createContactPayloadSchema,
|
|
246
|
+
label: 'Create Contact',
|
|
247
|
+
promptSchema: `create_contact payload:
|
|
248
|
+
{ type: "person"|"company", name: string, email?: string, phone?: string, companyName?: string, role?: string, source: "inbox_ops" }`,
|
|
249
|
+
promptRules: [
|
|
250
|
+
'For create_contact: always include email when available from the thread. Set source to "inbox_ops", type must be lowercase "person" or "company".',
|
|
251
|
+
'For create_contact with type "person": if the sender\'s display name is not available in the email header or signature, attempt to derive a human-readable name from the email address (e.g., john.doe@company.com -> "John Doe", m.smith@corp.net -> "M Smith"). If the email address does not contain a derivable name (e.g., info@, noreply@), use the full email address as the name. Always aim to provide both a first and last name when possible.',
|
|
252
|
+
],
|
|
253
|
+
execute: executeCreateContactAction,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
type: 'link_contact',
|
|
257
|
+
requiredFeature: 'customers.people.manage',
|
|
258
|
+
payloadSchema: linkContactPayloadSchema,
|
|
259
|
+
label: 'Link Contact',
|
|
260
|
+
promptSchema: `link_contact payload:
|
|
261
|
+
{ emailAddress: string (email), contactId: uuid, contactType: "person"|"company", contactName: string }`,
|
|
262
|
+
execute: (action) => Promise.resolve(executeLinkContactAction(action)),
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
type: 'log_activity',
|
|
266
|
+
requiredFeature: 'customers.activities.manage',
|
|
267
|
+
payloadSchema: logActivityPayloadSchema,
|
|
268
|
+
label: 'Log Activity',
|
|
269
|
+
promptSchema: `log_activity payload:
|
|
270
|
+
{ contactId?: uuid, contactType: "person"|"company", contactName: string, activityType: "email"|"call"|"meeting"|"note", subject: string, body: string }`,
|
|
271
|
+
execute: executeLogActivityAction,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
type: 'draft_reply',
|
|
275
|
+
requiredFeature: 'inbox_ops.replies.send',
|
|
276
|
+
payloadSchema: draftReplyPayloadSchema,
|
|
277
|
+
label: 'Draft Reply',
|
|
278
|
+
promptSchema: `draft_reply payload:
|
|
279
|
+
{ to: string (email), toName?: string, subject: string, body: string, context?: string }`,
|
|
280
|
+
promptRules: ['For draft_reply: include ERP context when available.'],
|
|
281
|
+
execute: executeDraftReplyAction,
|
|
282
|
+
},
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
export default inboxActions
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
|
|
11
11
|
export const metadata = {
|
|
12
12
|
GET: { requireAuth: true, requireFeatures: ['inbox_ops.log.view'] },
|
|
13
|
+
DELETE: { requireAuth: true, requireFeatures: ['inbox_ops.proposals.manage'] },
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export async function GET(req: Request) {
|
|
@@ -50,6 +51,42 @@ export async function GET(req: Request) {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
export async function DELETE(req: Request) {
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(req.url)
|
|
57
|
+
const id = extractPathSegment(url, 'emails')
|
|
58
|
+
|
|
59
|
+
if (!id) {
|
|
60
|
+
return NextResponse.json({ error: 'Missing email ID' }, { status: 400 })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ctx = await resolveRequestContext(req)
|
|
64
|
+
|
|
65
|
+
const updated = await ctx.em.nativeUpdate(
|
|
66
|
+
InboxEmail,
|
|
67
|
+
{
|
|
68
|
+
id,
|
|
69
|
+
organizationId: ctx.organizationId,
|
|
70
|
+
tenantId: ctx.tenantId,
|
|
71
|
+
deletedAt: null,
|
|
72
|
+
},
|
|
73
|
+
{ deletedAt: new Date() },
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (updated === 0) {
|
|
77
|
+
return NextResponse.json({ error: 'Email not found' }, { status: 404 })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return NextResponse.json({ ok: true })
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err instanceof UnauthorizedError) {
|
|
83
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
84
|
+
}
|
|
85
|
+
console.error('[inbox_ops:emails:delete] Error:', err)
|
|
86
|
+
return NextResponse.json({ error: 'Failed to delete email' }, { status: 500 })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
53
90
|
export const openApi: OpenApiRouteDoc = {
|
|
54
91
|
tag: 'InboxOps',
|
|
55
92
|
summary: 'Email detail',
|
|
@@ -61,5 +98,12 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
61
98
|
{ status: 404, description: 'Email not found' },
|
|
62
99
|
],
|
|
63
100
|
},
|
|
101
|
+
DELETE: {
|
|
102
|
+
summary: 'Soft-delete an inbox email',
|
|
103
|
+
responses: [
|
|
104
|
+
{ status: 200, description: 'Email deleted' },
|
|
105
|
+
{ status: 404, description: 'Email not found' },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
64
108
|
},
|
|
65
109
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { InboxEmail } from '../../data/entities'
|
|
5
|
+
import { emitInboxOpsEvent } from '../../events'
|
|
6
|
+
import { resolveRequestContext, handleRouteError } from '../routeHelpers'
|
|
7
|
+
|
|
8
|
+
export const metadata = {
|
|
9
|
+
POST: { requireAuth: true, requireFeatures: ['inbox_ops.proposals.manage'] },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const extractRequestSchema = z.object({
|
|
13
|
+
text: z.string().min(1, 'Text is required').max(100_000, 'Text exceeds maximum length'),
|
|
14
|
+
title: z.string().max(500).optional(),
|
|
15
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export async function POST(req: Request) {
|
|
19
|
+
try {
|
|
20
|
+
const ctx = await resolveRequestContext(req)
|
|
21
|
+
|
|
22
|
+
let body: unknown
|
|
23
|
+
try {
|
|
24
|
+
body = await req.json()
|
|
25
|
+
} catch {
|
|
26
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parsed = extractRequestSchema.safeParse(body)
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
const errors = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')
|
|
32
|
+
return NextResponse.json({ error: errors }, { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { text, title, metadata: inputMetadata } = parsed.data
|
|
36
|
+
|
|
37
|
+
const maxTextSize = parseInt(process.env.INBOX_OPS_MAX_TEXT_SIZE || '204800', 10)
|
|
38
|
+
const truncatedText = text.slice(0, maxTextSize)
|
|
39
|
+
|
|
40
|
+
const email = ctx.em.create(InboxEmail, {
|
|
41
|
+
forwardedByAddress: ctx.userId,
|
|
42
|
+
forwardedByName: null,
|
|
43
|
+
toAddress: 'text-extract',
|
|
44
|
+
subject: title || 'Text extraction',
|
|
45
|
+
cleanedText: truncatedText,
|
|
46
|
+
rawText: truncatedText,
|
|
47
|
+
receivedAt: new Date(),
|
|
48
|
+
status: 'received' as const,
|
|
49
|
+
isActive: true,
|
|
50
|
+
organizationId: ctx.organizationId,
|
|
51
|
+
tenantId: ctx.tenantId,
|
|
52
|
+
metadata: {
|
|
53
|
+
...inputMetadata,
|
|
54
|
+
source: 'text_extract',
|
|
55
|
+
submittedByUserId: ctx.userId,
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
ctx.em.persist(email)
|
|
60
|
+
await ctx.em.flush()
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await emitInboxOpsEvent('inbox_ops.email.received', {
|
|
64
|
+
emailId: email.id,
|
|
65
|
+
tenantId: ctx.tenantId,
|
|
66
|
+
organizationId: ctx.organizationId,
|
|
67
|
+
forwardedByAddress: ctx.userId,
|
|
68
|
+
subject: title || 'Text extraction',
|
|
69
|
+
})
|
|
70
|
+
} catch (eventError) {
|
|
71
|
+
console.error('[inbox_ops:extract] Failed to emit email.received event:', eventError)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return NextResponse.json({ ok: true, emailId: email.id })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return handleRouteError(err, 'extract text')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const openApi: OpenApiRouteDoc = {
|
|
81
|
+
tag: 'InboxOps',
|
|
82
|
+
summary: 'Extract actions from raw text',
|
|
83
|
+
methods: {
|
|
84
|
+
POST: {
|
|
85
|
+
summary: 'Submit raw text for LLM extraction',
|
|
86
|
+
description: 'Creates an InboxEmail record from raw text and triggers the extraction pipeline. The extraction runs asynchronously.',
|
|
87
|
+
responses: [
|
|
88
|
+
{ status: 200, description: 'Extraction queued successfully' },
|
|
89
|
+
{ status: 400, description: 'Invalid request body' },
|
|
90
|
+
{ status: 401, description: 'Unauthorized' },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
}
|
|
@@ -22,7 +22,12 @@ export async function POST(req: Request) {
|
|
|
22
22
|
const proposal = await resolveProposal(new URL(req.url), ctx)
|
|
23
23
|
if (isErrorResponse(proposal)) return proposal
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
let body: unknown
|
|
26
|
+
try {
|
|
27
|
+
body = await req.json()
|
|
28
|
+
} catch {
|
|
29
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
30
|
+
}
|
|
26
31
|
const parsed = translateProposalSchema.safeParse(body)
|
|
27
32
|
if (!parsed.success) {
|
|
28
33
|
return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 })
|
|
@@ -18,6 +18,8 @@ export async function GET(req: Request) {
|
|
|
18
18
|
isActive: true,
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// em.count() is safe here — filter fields (status, organizationId, tenantId,
|
|
22
|
+
// deletedAt, isActive) are not encrypted, so decryption helpers are not needed.
|
|
21
23
|
const [pending, partial, accepted, rejected] = await Promise.all([
|
|
22
24
|
ctx.em.count(InboxProposal, { ...scope, status: 'pending' }),
|
|
23
25
|
ctx.em.count(InboxProposal, { ...scope, status: 'partial' }),
|
|
@@ -3,12 +3,12 @@ export const metadata = {
|
|
|
3
3
|
requireFeatures: ['inbox_ops.log.view'],
|
|
4
4
|
pageTitle: 'Processing Log',
|
|
5
5
|
pageTitleKey: 'inbox_ops.nav.log',
|
|
6
|
-
pageGroup: '
|
|
6
|
+
pageGroup: 'AI Inbox Actions',
|
|
7
7
|
pageGroupKey: 'inbox_ops.nav.group',
|
|
8
8
|
pageOrder: 910,
|
|
9
9
|
navHidden: true,
|
|
10
10
|
breadcrumb: [
|
|
11
|
-
{ label: '
|
|
11
|
+
{ label: 'AI Inbox Actions', labelKey: 'inbox_ops.nav.group', href: '/backend/inbox-ops' },
|
|
12
12
|
{ label: 'Processing Log', labelKey: 'inbox_ops.nav.log' },
|
|
13
13
|
],
|
|
14
14
|
}
|
|
@@ -8,6 +8,8 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|
|
8
8
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
9
9
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
10
10
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
11
|
+
import { ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
12
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
11
13
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
12
14
|
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
|
13
15
|
|
|
@@ -44,41 +46,55 @@ export default function ProcessingLogPage() {
|
|
|
44
46
|
const [pageSize] = React.useState(25)
|
|
45
47
|
const [statusFilter, setStatusFilter] = React.useState<string | undefined>()
|
|
46
48
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
49
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
47
50
|
const [retryingEmailId, setRetryingEmailId] = React.useState<string | null>(null)
|
|
51
|
+
const { runMutation } = useGuardedMutation<Record<string, unknown>>({
|
|
52
|
+
contextId: 'inbox-ops-log',
|
|
53
|
+
})
|
|
48
54
|
|
|
49
55
|
const loadEmails = React.useCallback(async () => {
|
|
50
56
|
setIsLoading(true)
|
|
57
|
+
setError(null)
|
|
51
58
|
const params = new URLSearchParams()
|
|
52
59
|
params.set('page', String(page))
|
|
53
60
|
params.set('pageSize', String(pageSize))
|
|
54
61
|
if (statusFilter) params.set('status', statusFilter)
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
try {
|
|
64
|
+
const result = await apiCall<EmailListResponse>(`/api/inbox_ops/emails?${params}`)
|
|
65
|
+
if (result?.ok && result.result?.items) {
|
|
66
|
+
setItems(result.result.items)
|
|
67
|
+
setTotal(result.result.total || 0)
|
|
68
|
+
} else {
|
|
69
|
+
setError(t('inbox_ops.log.load_failed', 'Failed to load processing log'))
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
setError(t('inbox_ops.log.load_failed', 'Failed to load processing log'))
|
|
60
73
|
}
|
|
61
74
|
setIsLoading(false)
|
|
62
|
-
}, [page, pageSize, statusFilter])
|
|
75
|
+
}, [page, pageSize, statusFilter, t])
|
|
63
76
|
|
|
64
77
|
React.useEffect(() => { loadEmails() }, [loadEmails])
|
|
65
78
|
|
|
66
79
|
const handleRetryEmail = React.useCallback(async (emailId: string) => {
|
|
67
80
|
setRetryingEmailId(emailId)
|
|
68
|
-
const result = await
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
const result = await runMutation({
|
|
82
|
+
operation: () => apiCall<{ ok: boolean; error?: string }>(
|
|
83
|
+
`/api/inbox_ops/emails/${emailId}/reprocess`,
|
|
84
|
+
{ method: 'POST' },
|
|
85
|
+
),
|
|
86
|
+
context: {},
|
|
87
|
+
})
|
|
72
88
|
|
|
73
89
|
if (result?.ok && result.result?.ok) {
|
|
74
|
-
flash(
|
|
90
|
+
flash(t('inbox_ops.flash.reprocessing_started', 'Reprocessing started'), 'success')
|
|
75
91
|
await loadEmails()
|
|
76
92
|
} else {
|
|
77
93
|
flash(result?.result?.error || t('inbox_ops.extraction_failed', 'Extraction failed'), 'error')
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
setRetryingEmailId(null)
|
|
81
|
-
}, [loadEmails, t])
|
|
97
|
+
}, [loadEmails, t, runMutation])
|
|
82
98
|
|
|
83
99
|
const columns: ColumnDef<EmailRow>[] = React.useMemo(() => [
|
|
84
100
|
{
|
|
@@ -101,8 +117,16 @@ export default function ProcessingLogPage() {
|
|
|
101
117
|
accessorKey: 'status',
|
|
102
118
|
header: t('inbox_ops.log.status', 'Status'),
|
|
103
119
|
cell: ({ row }) => {
|
|
120
|
+
const statusLabels: Record<string, string> = {
|
|
121
|
+
received: t('inbox_ops.log.tab_received', 'Received'),
|
|
122
|
+
processing: t('inbox_ops.log.tab_processing', 'Processing'),
|
|
123
|
+
processed: t('inbox_ops.log.tab_processed', 'Processed'),
|
|
124
|
+
needs_review: t('inbox_ops.log.tab_needs_review', 'Needs Review'),
|
|
125
|
+
failed: t('inbox_ops.log.tab_failed', 'Failed'),
|
|
126
|
+
}
|
|
104
127
|
const color = STATUS_COLORS[row.original.status] || 'bg-gray-100 text-gray-800'
|
|
105
|
-
|
|
128
|
+
const label = statusLabels[row.original.status] || row.original.status
|
|
129
|
+
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${color}`}>{label}</span>
|
|
106
130
|
},
|
|
107
131
|
},
|
|
108
132
|
{
|
|
@@ -134,6 +158,7 @@ export default function ProcessingLogPage() {
|
|
|
134
158
|
const isRetrying = retryingEmailId === row.original.id
|
|
135
159
|
return (
|
|
136
160
|
<Button
|
|
161
|
+
type="button"
|
|
137
162
|
variant="outline"
|
|
138
163
|
size="sm"
|
|
139
164
|
className="h-8"
|
|
@@ -161,7 +186,7 @@ export default function ProcessingLogPage() {
|
|
|
161
186
|
<Page>
|
|
162
187
|
<div className="flex items-center gap-3 px-3 py-3 md:px-6 md:py-4">
|
|
163
188
|
<Link href="/backend/inbox-ops">
|
|
164
|
-
<Button variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
|
|
189
|
+
<Button type="button" variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
|
|
165
190
|
</Link>
|
|
166
191
|
<h1 className="text-lg font-semibold">{t('inbox_ops.processing_log', 'Processing Log')}</h1>
|
|
167
192
|
</div>
|
|
@@ -170,6 +195,7 @@ export default function ProcessingLogPage() {
|
|
|
170
195
|
<div className="flex items-center gap-2 px-3 py-2 md:px-0 overflow-x-auto">
|
|
171
196
|
{tabs.map((tab) => (
|
|
172
197
|
<Button
|
|
198
|
+
type="button"
|
|
173
199
|
key={tab.value ?? 'all'}
|
|
174
200
|
variant={statusFilter === tab.value ? 'default' : 'outline'}
|
|
175
201
|
size="sm"
|
|
@@ -180,6 +206,9 @@ export default function ProcessingLogPage() {
|
|
|
180
206
|
))}
|
|
181
207
|
</div>
|
|
182
208
|
|
|
209
|
+
{error ? (
|
|
210
|
+
<ErrorMessage label={error} />
|
|
211
|
+
) : (
|
|
183
212
|
<div className="overflow-auto">
|
|
184
213
|
<div className="min-w-[640px]">
|
|
185
214
|
<DataTable
|
|
@@ -196,6 +225,7 @@ export default function ProcessingLogPage() {
|
|
|
196
225
|
/>
|
|
197
226
|
</div>
|
|
198
227
|
</div>
|
|
228
|
+
)}
|
|
199
229
|
</PageBody>
|
|
200
230
|
</Page>
|
|
201
231
|
)
|