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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/modules/catalog/inbox-actions.js +51 -0
  2. package/dist/modules/catalog/inbox-actions.js.map +7 -0
  3. package/dist/modules/customers/inbox-actions.js +230 -0
  4. package/dist/modules/customers/inbox-actions.js.map +7 -0
  5. package/dist/modules/inbox_ops/api/emails/[id]/route.js +40 -1
  6. package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
  7. package/dist/modules/inbox_ops/api/extract/route.js +87 -0
  8. package/dist/modules/inbox_ops/api/extract/route.js.map +7 -0
  9. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js +6 -1
  10. package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js.map +2 -2
  11. package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
  12. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js +40 -14
  13. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js.map +2 -2
  14. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js +2 -2
  15. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js.map +2 -2
  16. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +161 -79
  17. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  18. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js +2 -2
  19. package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js.map +2 -2
  20. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +109 -62
  21. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +3 -3
  22. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js +2 -2
  23. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js.map +2 -2
  24. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +36 -14
  25. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
  26. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js +2 -2
  27. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js.map +2 -2
  28. package/dist/modules/inbox_ops/components/proposals/ActionCard.js +65 -10
  29. package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
  30. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +58 -10
  31. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  32. package/dist/modules/inbox_ops/lib/constants.js.map +2 -2
  33. package/dist/modules/inbox_ops/lib/contactValidation.js +40 -0
  34. package/dist/modules/inbox_ops/lib/contactValidation.js.map +7 -0
  35. package/dist/modules/inbox_ops/lib/executionEngine.js +31 -826
  36. package/dist/modules/inbox_ops/lib/executionEngine.js.map +3 -3
  37. package/dist/modules/inbox_ops/lib/executionHelpers.js +368 -0
  38. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +7 -0
  39. package/dist/modules/inbox_ops/lib/extractionPrompt.js +28 -35
  40. package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +3 -3
  41. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js +1 -0
  42. package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js.map +7 -0
  43. package/dist/modules/inbox_ops/lib/translationProvider.js +15 -10
  44. package/dist/modules/inbox_ops/lib/translationProvider.js.map +2 -2
  45. package/dist/modules/inbox_ops/subscribers/extractionWorker.js +16 -16
  46. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
  47. package/dist/modules/sales/inbox-actions.js +278 -0
  48. package/dist/modules/sales/inbox-actions.js.map +7 -0
  49. package/jest.config.cjs +1 -0
  50. package/jest.mocks/inbox-actions.generated.js +5 -0
  51. package/package.json +2 -2
  52. package/src/modules/catalog/inbox-actions.ts +60 -0
  53. package/src/modules/customers/inbox-actions.ts +285 -0
  54. package/src/modules/inbox_ops/api/emails/[id]/route.ts +44 -0
  55. package/src/modules/inbox_ops/api/extract/route.ts +94 -0
  56. package/src/modules/inbox_ops/api/proposals/[id]/translate/route.ts +6 -1
  57. package/src/modules/inbox_ops/api/proposals/counts/route.ts +2 -0
  58. package/src/modules/inbox_ops/backend/inbox-ops/log/page.meta.ts +2 -2
  59. package/src/modules/inbox_ops/backend/inbox-ops/log/page.tsx +43 -13
  60. package/src/modules/inbox_ops/backend/inbox-ops/page.meta.ts +2 -2
  61. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +176 -81
  62. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.ts +2 -2
  63. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +122 -68
  64. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.meta.ts +2 -2
  65. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +36 -14
  66. package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +91 -7
  67. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +64 -12
  68. package/src/modules/inbox_ops/lib/constants.ts +9 -0
  69. package/src/modules/inbox_ops/lib/contactValidation.ts +54 -0
  70. package/src/modules/inbox_ops/lib/executionEngine.ts +47 -1060
  71. package/src/modules/inbox_ops/lib/executionHelpers.ts +527 -0
  72. package/src/modules/inbox_ops/lib/extractionPrompt.ts +45 -34
  73. package/src/modules/inbox_ops/lib/inbox-actions-generated.d.ts +11 -0
  74. package/src/modules/inbox_ops/lib/translationProvider.ts +16 -10
  75. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +16 -18
  76. package/src/modules/sales/inbox-actions.ts +359 -0
@@ -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
- const body = await req.json()
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: 'InboxOps',
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: 'InboxOps', labelKey: 'inbox_ops.nav.group', href: '/backend/inbox-ops' },
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
- const result = await apiCall<EmailListResponse>(`/api/inbox_ops/emails?${params}`)
57
- if (result?.ok && result.result?.items) {
58
- setItems(result.result.items)
59
- setTotal(result.result.total || 0)
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 apiCall<{ ok: boolean; error?: string }>(
69
- `/api/inbox_ops/emails/${emailId}/reprocess`,
70
- { method: 'POST' },
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(`${t('inbox_ops.action.retry', 'Retry')} ${t('inbox_ops.status.processing', 'Processing')}`, 'success')
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
- return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${color}`}>{row.original.status}</span>
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
  )