@open-mercato/core 0.4.8-develop-15259be22b → 0.4.8-develop-280c02b529

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 (110) hide show
  1. package/dist/generated/entities/inbox_proposal/index.js +2 -0
  2. package/dist/generated/entities/inbox_proposal/index.js.map +2 -2
  3. package/dist/modules/catalog/inbox-actions.js +49 -0
  4. package/dist/modules/catalog/inbox-actions.js.map +2 -2
  5. package/dist/modules/customers/inbox-actions.js +69 -27
  6. package/dist/modules/customers/inbox-actions.js.map +3 -3
  7. package/dist/modules/inbox_ops/ai-tools.js +346 -0
  8. package/dist/modules/inbox_ops/ai-tools.js.map +7 -0
  9. package/dist/modules/inbox_ops/api/extract/route.js +3 -2
  10. package/dist/modules/inbox_ops/api/extract/route.js.map +2 -2
  11. package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js +4 -0
  12. package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js.map +2 -2
  13. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/accept/route.js +4 -0
  14. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/accept/route.js.map +2 -2
  15. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/complete/route.js +4 -0
  16. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/complete/route.js.map +2 -2
  17. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/reject/route.js +4 -0
  18. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/reject/route.js.map +2 -2
  19. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +4 -0
  20. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  21. package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js +59 -0
  22. package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js.map +7 -0
  23. package/dist/modules/inbox_ops/api/proposals/[id]/reject/route.js +4 -0
  24. package/dist/modules/inbox_ops/api/proposals/[id]/reject/route.js.map +2 -2
  25. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js +34 -14
  26. package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js.map +2 -2
  27. package/dist/modules/inbox_ops/api/proposals/counts/route.js +49 -4
  28. package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
  29. package/dist/modules/inbox_ops/api/proposals/route.js +13 -0
  30. package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
  31. package/dist/modules/inbox_ops/api/settings/route.js +33 -2
  32. package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
  33. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +28 -3
  34. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  35. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +103 -5
  36. package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +2 -2
  37. package/dist/modules/inbox_ops/components/messages/InboxEmailContent.js +24 -0
  38. package/dist/modules/inbox_ops/components/messages/InboxEmailContent.js.map +7 -0
  39. package/dist/modules/inbox_ops/components/messages/InboxEmailPreview.js +29 -0
  40. package/dist/modules/inbox_ops/components/messages/InboxEmailPreview.js.map +7 -0
  41. package/dist/modules/inbox_ops/components/proposals/CategoryBadge.js +59 -0
  42. package/dist/modules/inbox_ops/components/proposals/CategoryBadge.js.map +7 -0
  43. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +3 -1
  44. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  45. package/dist/modules/inbox_ops/data/entities.js +4 -0
  46. package/dist/modules/inbox_ops/data/entities.js.map +2 -2
  47. package/dist/modules/inbox_ops/data/validators.js +30 -5
  48. package/dist/modules/inbox_ops/data/validators.js.map +2 -2
  49. package/dist/modules/inbox_ops/lib/cache.js +53 -0
  50. package/dist/modules/inbox_ops/lib/cache.js.map +7 -0
  51. package/dist/modules/inbox_ops/lib/contactValidation.js +38 -3
  52. package/dist/modules/inbox_ops/lib/contactValidation.js.map +2 -2
  53. package/dist/modules/inbox_ops/lib/executionHelpers.js +28 -1
  54. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +2 -2
  55. package/dist/modules/inbox_ops/lib/extractionPrompt.js +2 -1
  56. package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +2 -2
  57. package/dist/modules/inbox_ops/lib/messageObjectPreviews.js +52 -0
  58. package/dist/modules/inbox_ops/lib/messageObjectPreviews.js.map +7 -0
  59. package/dist/modules/inbox_ops/lib/messagesIntegration.js +155 -0
  60. package/dist/modules/inbox_ops/lib/messagesIntegration.js.map +7 -0
  61. package/dist/modules/inbox_ops/message-objects.js +36 -0
  62. package/dist/modules/inbox_ops/message-objects.js.map +7 -0
  63. package/dist/modules/inbox_ops/message-types.js +38 -0
  64. package/dist/modules/inbox_ops/message-types.js.map +7 -0
  65. package/dist/modules/inbox_ops/migrations/Migration20260303173020.js +13 -0
  66. package/dist/modules/inbox_ops/migrations/Migration20260303173020.js.map +7 -0
  67. package/dist/modules/inbox_ops/migrations/Migration20260303173215.js +15 -0
  68. package/dist/modules/inbox_ops/migrations/Migration20260303173215.js.map +7 -0
  69. package/dist/modules/inbox_ops/search.js +5 -3
  70. package/dist/modules/inbox_ops/search.js.map +2 -2
  71. package/dist/modules/inbox_ops/subscribers/extractionWorker.js +65 -3
  72. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
  73. package/generated/entities/inbox_proposal/index.ts +1 -0
  74. package/package.json +3 -3
  75. package/src/modules/catalog/inbox-actions.ts +55 -0
  76. package/src/modules/customers/inbox-actions.ts +86 -27
  77. package/src/modules/inbox_ops/ai-tools.ts +451 -0
  78. package/src/modules/inbox_ops/api/extract/route.ts +3 -2
  79. package/src/modules/inbox_ops/api/proposals/[id]/accept-all/route.ts +5 -0
  80. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/accept/route.ts +5 -0
  81. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/complete/route.ts +5 -0
  82. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/reject/route.ts +5 -0
  83. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +5 -0
  84. package/src/modules/inbox_ops/api/proposals/[id]/categorize/route.ts +61 -0
  85. package/src/modules/inbox_ops/api/proposals/[id]/reject/route.ts +5 -0
  86. package/src/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.ts +36 -16
  87. package/src/modules/inbox_ops/api/proposals/counts/route.ts +60 -5
  88. package/src/modules/inbox_ops/api/proposals/route.ts +14 -1
  89. package/src/modules/inbox_ops/api/settings/route.ts +36 -2
  90. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +31 -3
  91. package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +103 -1
  92. package/src/modules/inbox_ops/components/messages/InboxEmailContent.tsx +45 -0
  93. package/src/modules/inbox_ops/components/messages/InboxEmailPreview.tsx +40 -0
  94. package/src/modules/inbox_ops/components/proposals/CategoryBadge.tsx +59 -0
  95. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +3 -1
  96. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  97. package/src/modules/inbox_ops/data/entities.ts +14 -1
  98. package/src/modules/inbox_ops/data/validators.ts +41 -5
  99. package/src/modules/inbox_ops/lib/cache.ts +60 -0
  100. package/src/modules/inbox_ops/lib/contactValidation.ts +31 -2
  101. package/src/modules/inbox_ops/lib/executionHelpers.ts +40 -0
  102. package/src/modules/inbox_ops/lib/extractionPrompt.ts +2 -1
  103. package/src/modules/inbox_ops/lib/messageObjectPreviews.ts +61 -0
  104. package/src/modules/inbox_ops/lib/messagesIntegration.ts +231 -0
  105. package/src/modules/inbox_ops/message-objects.ts +34 -0
  106. package/src/modules/inbox_ops/message-types.ts +36 -0
  107. package/src/modules/inbox_ops/migrations/Migration20260303173020.ts +13 -0
  108. package/src/modules/inbox_ops/migrations/Migration20260303173215.ts +15 -0
  109. package/src/modules/inbox_ops/search.ts +5 -3
  110. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +75 -1
@@ -0,0 +1,451 @@
1
+ import { z } from 'zod'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AwilixContainer } from 'awilix'
4
+ import { runWithCacheTenant } from '@open-mercato/cache'
5
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
+ import {
7
+ resolveOpenCodeModel,
8
+ resolveOpenCodeProviderApiKey,
9
+ } from '@open-mercato/shared/lib/ai/opencode-provider'
10
+ import { InboxProposal, InboxProposalAction, InboxDiscrepancy } from './data/entities'
11
+ import { inboxProposalCategoryEnum } from './data/validators'
12
+ import { executeAction } from './lib/executionEngine'
13
+ import { resolveExtractionProviderId, createStructuredModel, withTimeout } from './lib/llmProvider'
14
+ import { resolveOptionalEventBus } from './lib/eventBus'
15
+
16
+ type ToolContext = {
17
+ tenantId: string | null
18
+ organizationId: string | null
19
+ userId: string | null
20
+ container: AwilixContainer
21
+ userFeatures: string[]
22
+ isSuperAdmin: boolean
23
+ }
24
+
25
+ interface AiToolDefinition {
26
+ name: string
27
+ description: string
28
+ inputSchema: z.ZodType
29
+ requiredFeatures?: string[]
30
+ handler: (input: never, ctx: ToolContext) => Promise<unknown>
31
+ }
32
+
33
+ // =============================================================================
34
+ // Helpers
35
+ // =============================================================================
36
+
37
+ function requireTenantContext(ctx: ToolContext): { tenantId: string; organizationId: string } {
38
+ if (!ctx.tenantId || !ctx.organizationId) {
39
+ throw new Error('Tenant context is required')
40
+ }
41
+ return { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
42
+ }
43
+
44
+ function resolveCrossModuleEntities(container: ToolContext['container']) {
45
+ const entities: Record<string, unknown> = {}
46
+ const keys = [
47
+ 'CustomerEntity',
48
+ 'SalesOrder',
49
+ 'SalesShipment',
50
+ 'SalesChannel',
51
+ 'Dictionary',
52
+ 'DictionaryEntry',
53
+ ]
54
+ for (const key of keys) {
55
+ try {
56
+ entities[key] = container.resolve(key)
57
+ } catch {
58
+ /* module not available */
59
+ }
60
+ }
61
+ return entities
62
+ }
63
+
64
+ // =============================================================================
65
+ // inbox_ops_list_proposals — Query proposals by status, category, date range
66
+ // =============================================================================
67
+
68
+ const listProposalsTool = {
69
+ name: 'inbox_ops_list_proposals',
70
+ description: `List inbox proposals with optional filters by status, category, and date range.
71
+
72
+ Returns: total count and an array of proposals with id, summary, status, category, confidence, actionCount, and createdAt.`,
73
+ inputSchema: z.object({
74
+ status: z
75
+ .enum(['pending', 'partial', 'accepted', 'rejected'])
76
+ .optional()
77
+ .describe('Filter by proposal status'),
78
+ category: inboxProposalCategoryEnum
79
+ .optional()
80
+ .describe('Filter by email category'),
81
+ limit: z
82
+ .number()
83
+ .int()
84
+ .min(1)
85
+ .max(50)
86
+ .optional()
87
+ .default(10)
88
+ .describe('Maximum number of proposals to return (default: 10)'),
89
+ dateFrom: z
90
+ .string()
91
+ .optional()
92
+ .describe('Filter proposals created on or after this date (ISO 8601)'),
93
+ dateTo: z
94
+ .string()
95
+ .optional()
96
+ .describe('Filter proposals created on or before this date (ISO 8601)'),
97
+ }),
98
+ requiredFeatures: ['inbox_ops.proposals.view'],
99
+ handler: async (input: { status?: string; category?: string; limit?: number; dateFrom?: string; dateTo?: string }, ctx: ToolContext) => {
100
+ const scope = requireTenantContext(ctx)
101
+ const em = ctx.container.resolve<EntityManager>('em').fork()
102
+
103
+ const where: Record<string, unknown> = {
104
+ organizationId: scope.organizationId,
105
+ tenantId: scope.tenantId,
106
+ isActive: true,
107
+ deletedAt: null,
108
+ }
109
+
110
+ if (input.status) {
111
+ where.status = input.status
112
+ }
113
+ if (input.category) {
114
+ where.category = input.category
115
+ }
116
+ if (input.dateFrom || input.dateTo) {
117
+ const createdAt: Record<string, unknown> = {}
118
+ if (input.dateFrom) {
119
+ createdAt.$gte = new Date(input.dateFrom)
120
+ }
121
+ if (input.dateTo) {
122
+ createdAt.$lte = new Date(input.dateTo)
123
+ }
124
+ where.createdAt = createdAt
125
+ }
126
+
127
+ const proposals = await findWithDecryption(
128
+ em,
129
+ InboxProposal,
130
+ where,
131
+ { orderBy: { createdAt: 'DESC' }, limit: input.limit },
132
+ scope,
133
+ )
134
+
135
+ // Count actions per proposal in a single query
136
+ const proposalIds = proposals.map((p) => p.id)
137
+ const actionCountMap = new Map<string, number>()
138
+
139
+ if (proposalIds.length > 0) {
140
+ const actions = await findWithDecryption(
141
+ em,
142
+ InboxProposalAction,
143
+ {
144
+ proposalId: { $in: proposalIds },
145
+ deletedAt: null,
146
+ },
147
+ undefined,
148
+ scope,
149
+ )
150
+ for (const action of actions) {
151
+ actionCountMap.set(action.proposalId, (actionCountMap.get(action.proposalId) ?? 0) + 1)
152
+ }
153
+ }
154
+
155
+ // Get total count for the filter
156
+ const total = await em.count(InboxProposal, where)
157
+
158
+ return {
159
+ total,
160
+ proposals: proposals.map((p) => ({
161
+ id: p.id,
162
+ summary: p.summary,
163
+ status: p.status,
164
+ category: p.category ?? null,
165
+ confidence: Number(p.confidence),
166
+ actionCount: actionCountMap.get(p.id) ?? 0,
167
+ createdAt: p.createdAt.toISOString(),
168
+ })),
169
+ }
170
+ },
171
+ }
172
+
173
+ // =============================================================================
174
+ // inbox_ops_get_proposal — Fetch proposal detail with actions and discrepancies
175
+ // =============================================================================
176
+
177
+ const getProposalTool = {
178
+ name: 'inbox_ops_get_proposal',
179
+ description: `Get full details of an inbox proposal including its actions and discrepancies.
180
+
181
+ Returns: proposal with id, summary, status, category, confidence, actions array, and discrepancies array.`,
182
+ inputSchema: z.object({
183
+ proposalId: z.string().uuid().describe('The UUID of the proposal to retrieve'),
184
+ }),
185
+ requiredFeatures: ['inbox_ops.proposals.view'],
186
+ handler: async (input: { proposalId: string }, ctx: ToolContext) => {
187
+ const scope = requireTenantContext(ctx)
188
+ const em = ctx.container.resolve<EntityManager>('em').fork()
189
+
190
+ const proposal = await findOneWithDecryption(
191
+ em,
192
+ InboxProposal,
193
+ {
194
+ id: input.proposalId,
195
+ organizationId: scope.organizationId,
196
+ tenantId: scope.tenantId,
197
+ isActive: true,
198
+ deletedAt: null,
199
+ },
200
+ undefined,
201
+ scope,
202
+ )
203
+
204
+ if (!proposal) {
205
+ return { error: 'Proposal not found' }
206
+ }
207
+
208
+ const actions = await findWithDecryption(
209
+ em,
210
+ InboxProposalAction,
211
+ {
212
+ proposalId: proposal.id,
213
+ organizationId: scope.organizationId,
214
+ tenantId: scope.tenantId,
215
+ deletedAt: null,
216
+ },
217
+ { orderBy: { sortOrder: 'ASC' } },
218
+ scope,
219
+ )
220
+
221
+ const discrepancies = await findWithDecryption(
222
+ em,
223
+ InboxDiscrepancy,
224
+ {
225
+ proposalId: proposal.id,
226
+ organizationId: scope.organizationId,
227
+ tenantId: scope.tenantId,
228
+ },
229
+ undefined,
230
+ scope,
231
+ )
232
+
233
+ return {
234
+ proposal: {
235
+ id: proposal.id,
236
+ summary: proposal.summary,
237
+ status: proposal.status,
238
+ category: proposal.category ?? null,
239
+ confidence: Number(proposal.confidence),
240
+ actions: actions.map((a) => ({
241
+ id: a.id,
242
+ actionType: a.actionType,
243
+ description: a.description,
244
+ status: a.status,
245
+ confidence: Number(a.confidence),
246
+ requiredFeature: a.requiredFeature ?? null,
247
+ sortOrder: a.sortOrder,
248
+ createdEntityId: a.createdEntityId ?? null,
249
+ createdEntityType: a.createdEntityType ?? null,
250
+ })),
251
+ discrepancies: discrepancies.map((d) => ({
252
+ id: d.id,
253
+ type: d.type,
254
+ severity: d.severity,
255
+ description: d.description,
256
+ expectedValue: d.expectedValue ?? null,
257
+ foundValue: d.foundValue ?? null,
258
+ resolved: d.resolved,
259
+ })),
260
+ },
261
+ }
262
+ },
263
+ }
264
+
265
+ // =============================================================================
266
+ // inbox_ops_accept_action — Accept and execute a specific action
267
+ // =============================================================================
268
+
269
+ const acceptActionTool = {
270
+ name: 'inbox_ops_accept_action',
271
+ description: `Accept and execute a specific action from an inbox proposal. Creates the entity in the target module (e.g., order, contact).
272
+
273
+ Returns on success: { ok: true, createdEntityId, createdEntityType }
274
+ Returns on error: error message with appropriate detail.`,
275
+ inputSchema: z.object({
276
+ proposalId: z.string().uuid().describe('The UUID of the proposal'),
277
+ actionId: z.string().uuid().describe('The UUID of the action to accept'),
278
+ }),
279
+ requiredFeatures: ['inbox_ops.proposals.manage'],
280
+ handler: async (input: { proposalId: string; actionId: string }, ctx: ToolContext) => {
281
+ const scope = requireTenantContext(ctx)
282
+ if (!ctx.userId) {
283
+ throw new Error('User context is required')
284
+ }
285
+
286
+ const em = ctx.container.resolve<EntityManager>('em').fork()
287
+
288
+ const action = await findOneWithDecryption(
289
+ em,
290
+ InboxProposalAction,
291
+ {
292
+ id: input.actionId,
293
+ proposalId: input.proposalId,
294
+ organizationId: scope.organizationId,
295
+ tenantId: scope.tenantId,
296
+ deletedAt: null,
297
+ },
298
+ undefined,
299
+ scope,
300
+ )
301
+
302
+ if (!action) {
303
+ return { error: 'Action not found' }
304
+ }
305
+
306
+ // Check if action was already processed
307
+ if (action.status !== 'pending' && action.status !== 'failed') {
308
+ return { error: 'Action already processed', status: action.status }
309
+ }
310
+
311
+ // Check target module permission
312
+ if (action.requiredFeature) {
313
+ const hasFeature =
314
+ ctx.isSuperAdmin || ctx.userFeatures.includes(action.requiredFeature)
315
+ if (!hasFeature) {
316
+ return {
317
+ error: 'Insufficient permissions',
318
+ requiredFeature: action.requiredFeature,
319
+ }
320
+ }
321
+ }
322
+
323
+ const entities = resolveCrossModuleEntities(ctx.container)
324
+ const eventBus = resolveOptionalEventBus(ctx.container)
325
+
326
+ const result = await executeAction(action, {
327
+ em,
328
+ userId: ctx.userId,
329
+ tenantId: scope.tenantId,
330
+ organizationId: scope.organizationId,
331
+ eventBus,
332
+ container: ctx.container,
333
+ entities: entities as unknown as import('./lib/executionEngine').CrossModuleEntities,
334
+ })
335
+
336
+ if (!result.success) {
337
+ if (result.statusCode === 409) {
338
+ return { error: 'Action already processed', status: 'accepted' }
339
+ }
340
+ if (result.statusCode === 403) {
341
+ return {
342
+ error: 'Insufficient permissions',
343
+ requiredFeature: action.requiredFeature ?? 'unknown',
344
+ }
345
+ }
346
+ return { error: 'Execution failed', detail: result.error ?? 'Unknown error' }
347
+ }
348
+
349
+ try {
350
+ const { resolveCache, invalidateCountsCache } = await import('./lib/cache')
351
+ const cache = resolveCache(ctx.container)
352
+ if (cache && scope.tenantId) {
353
+ await runWithCacheTenant(scope.tenantId, () => invalidateCountsCache(cache, scope.tenantId))
354
+ }
355
+ } catch { /* cache invalidation is non-critical */ }
356
+
357
+ return {
358
+ ok: true,
359
+ createdEntityId: result.createdEntityId ?? null,
360
+ createdEntityType: result.createdEntityType ?? null,
361
+ }
362
+ },
363
+ }
364
+
365
+ // =============================================================================
366
+ // inbox_ops_categorize_email — Standalone LLM-based text categorization
367
+ // =============================================================================
368
+
369
+ const categorizeEmailSchema = z.object({
370
+ category: inboxProposalCategoryEnum,
371
+ confidence: z.number(),
372
+ reasoning: z.string(),
373
+ })
374
+
375
+ const categorizeEmailTool = {
376
+ name: 'inbox_ops_categorize_email',
377
+ description: `Categorize email or text content using AI. Classifies text into one of: rfq, order, order_update, complaint, shipping_update, inquiry, payment, other.
378
+
379
+ Returns: { category, confidence (0-1), reasoning }
380
+ Input text is limited to 10,000 characters for cost control.`,
381
+ inputSchema: z.object({
382
+ text: z
383
+ .string()
384
+ .min(1)
385
+ .max(10000)
386
+ .describe('Email or text content to categorize (max 10K chars)'),
387
+ }),
388
+ requiredFeatures: ['inbox_ops.proposals.view'],
389
+ handler: async (input: { text: string }, ctx: ToolContext) => {
390
+ requireTenantContext(ctx)
391
+
392
+ const providerId = resolveExtractionProviderId()
393
+ const apiKey = resolveOpenCodeProviderApiKey(providerId)
394
+ if (!apiKey) {
395
+ throw new Error(`Missing API key for provider "${providerId}"`)
396
+ }
397
+
398
+ const modelConfig = resolveOpenCodeModel(providerId, {})
399
+ const model = await createStructuredModel(providerId, apiKey, modelConfig.modelId)
400
+
401
+ const { generateObject } = await import('ai')
402
+
403
+ const result = await withTimeout(
404
+ generateObject({
405
+ model,
406
+ schema: categorizeEmailSchema,
407
+ system: `You are an email classification agent. Classify the given text into exactly one category:
408
+ - rfq: Request for quotation or pricing inquiry
409
+ - order: New purchase order or order placement
410
+ - order_update: Change or update to an existing order
411
+ - complaint: Customer complaint, dispute, or dissatisfaction
412
+ - shipping_update: Shipment status, tracking, or delivery information
413
+ - inquiry: General question or information request
414
+ - payment: Payment-related (invoice, receipt, payment terms)
415
+ - other: Does not fit any category above
416
+
417
+ Return a JSON object with:
418
+ - category: one of the categories above
419
+ - confidence: a number between 0 and 1 indicating how confident you are
420
+ - reasoning: a brief explanation (1-2 sentences) of why this category was chosen`,
421
+ prompt: input.text,
422
+ temperature: 0,
423
+ }),
424
+ 15000,
425
+ 'Email categorization timed out after 15s',
426
+ )
427
+
428
+ return {
429
+ category: result.object.category,
430
+ confidence: Math.round(result.object.confidence * 100) / 100,
431
+ reasoning: result.object.reasoning,
432
+ }
433
+ },
434
+ }
435
+
436
+ // =============================================================================
437
+ // Export
438
+ // =============================================================================
439
+
440
+ /**
441
+ * All AI tools exported by the inbox_ops module.
442
+ * Discovered by ai-assistant module's generator.
443
+ */
444
+ export const aiTools: AiToolDefinition[] = [
445
+ listProposalsTool,
446
+ getProposalTool,
447
+ acceptActionTool,
448
+ categorizeEmailTool,
449
+ ]
450
+
451
+ export default aiTools
@@ -37,8 +37,9 @@ export async function POST(req: Request) {
37
37
  const maxTextSize = parseInt(process.env.INBOX_OPS_MAX_TEXT_SIZE || '204800', 10)
38
38
  const truncatedText = text.slice(0, maxTextSize)
39
39
 
40
+ const submitterEmail = ctx.auth?.email || ctx.userId
40
41
  const email = ctx.em.create(InboxEmail, {
41
- forwardedByAddress: ctx.userId,
42
+ forwardedByAddress: submitterEmail,
42
43
  forwardedByName: null,
43
44
  toAddress: 'text-extract',
44
45
  subject: title || 'Text extraction',
@@ -64,7 +65,7 @@ export async function POST(req: Request) {
64
65
  emailId: email.id,
65
66
  tenantId: ctx.tenantId,
66
67
  organizationId: ctx.organizationId,
67
- forwardedByAddress: ctx.userId,
68
+ forwardedByAddress: submitterEmail,
68
69
  subject: title || 'Text extraction',
69
70
  })
70
71
  } catch (eventError) {
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
4
  import { acceptAllActions } from '../../../../lib/executionEngine'
5
+ import { resolveCache, invalidateCountsCache } from '../../../../lib/cache'
4
6
  import {
5
7
  resolveRequestContext,
6
8
  resolveProposal,
@@ -29,6 +31,9 @@ export async function POST(req: Request) {
29
31
  const succeeded = results.filter((r) => r.success).length
30
32
  const failed = results.filter((r) => !r.success).length
31
33
 
34
+ const cache = resolveCache(ctx.container)
35
+ await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
36
+
32
37
  return NextResponse.json({ ok: !stoppedOnFailure, succeeded, failed, stoppedOnFailure, results })
33
38
  } catch (err) {
34
39
  return handleRouteError(err, 'accept all actions')
@@ -1,8 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
4
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
5
  import { InboxProposal, InboxProposalAction } from '../../../../../../data/entities'
5
6
  import { executeAction } from '../../../../../../lib/executionEngine'
7
+ import { resolveCache, invalidateCountsCache } from '../../../../../../lib/cache'
6
8
  import {
7
9
  resolveRequestContext,
8
10
  resolveActionAndProposal,
@@ -48,6 +50,9 @@ export async function POST(req: Request) {
48
50
  ctx.scope,
49
51
  )
50
52
 
53
+ const cache = resolveCache(ctx.container)
54
+ await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
55
+
51
56
  return NextResponse.json({
52
57
  ok: true,
53
58
  action: freshAction ? {
@@ -1,10 +1,12 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
+ import { runWithCacheTenant } from '@open-mercato/cache'
4
5
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
6
  import { InboxProposal, InboxProposalAction } from '../../../../../../data/entities'
6
7
  import { recalculateProposalStatus } from '../../../../../../lib/executionEngine'
7
8
  import { formatZodErrors } from '../../../../../../lib/validation'
9
+ import { resolveCache, invalidateCountsCache } from '../../../../../../lib/cache'
8
10
  import {
9
11
  resolveRequestContext,
10
12
  resolveActionAndProposal,
@@ -78,6 +80,9 @@ export async function PATCH(req: Request) {
78
80
  ctx.scope,
79
81
  )
80
82
 
83
+ const cache = resolveCache(ctx.container)
84
+ await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
85
+
81
86
  return NextResponse.json({
82
87
  ok: true,
83
88
  action: {
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
4
  import { rejectAction } from '../../../../../../lib/executionEngine'
5
+ import { resolveCache, invalidateCountsCache } from '../../../../../../lib/cache'
4
6
  import {
5
7
  resolveRequestContext,
6
8
  resolveActionAndProposal,
@@ -25,6 +27,9 @@ export async function POST(req: Request) {
25
27
 
26
28
  await rejectAction(resolved.action, toExecutionContext(ctx))
27
29
 
30
+ const cache = resolveCache(ctx.container)
31
+ await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
32
+
28
33
  return NextResponse.json({ ok: true })
29
34
  } catch (err) {
30
35
  return handleRouteError(err, 'reject action')
@@ -1,7 +1,9 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
4
  import { actionEditSchema, validateActionPayloadForType } from '../../../../../data/validators'
4
5
  import { emitInboxOpsEvent } from '../../../../../events'
6
+ import { resolveCache, invalidateCountsCache } from '../../../../../lib/cache'
5
7
  import {
6
8
  resolveRequestContext,
7
9
  resolveActionAndProposal,
@@ -40,6 +42,9 @@ export async function PATCH(req: Request) {
40
42
  action.payload = mergedPayload
41
43
  await ctx.em.flush()
42
44
 
45
+ const cache = resolveCache(ctx.container)
46
+ await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
47
+
43
48
  try {
44
49
  await emitInboxOpsEvent('inbox_ops.action.edited', {
45
50
  actionId: action.id,
@@ -0,0 +1,61 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
4
+ import { categorizeProposalSchema } from '../../../../data/validators'
5
+ import { resolveCache, invalidateCountsCache } from '../../../../lib/cache'
6
+ import {
7
+ resolveRequestContext,
8
+ resolveProposal,
9
+ handleRouteError,
10
+ isErrorResponse,
11
+ } from '../../../routeHelpers'
12
+
13
+ export const metadata = {
14
+ POST: { requireAuth: true, requireFeatures: ['inbox_ops.proposals.manage'] },
15
+ }
16
+
17
+ export async function POST(req: Request) {
18
+ try {
19
+ const ctx = await resolveRequestContext(req)
20
+ const proposal = await resolveProposal(new URL(req.url), ctx)
21
+ if (isErrorResponse(proposal)) return proposal
22
+
23
+ const body = await req.json()
24
+ const parsed = categorizeProposalSchema.safeParse(body)
25
+ if (!parsed.success) {
26
+ const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')
27
+ return NextResponse.json({ error: `Invalid category: ${issues}` }, { status: 400 })
28
+ }
29
+
30
+ const previousCategory = proposal.category || null
31
+ proposal.category = parsed.data.category
32
+ await ctx.em.flush()
33
+
34
+ const cache = resolveCache(ctx.container)
35
+ await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
36
+
37
+ return NextResponse.json({
38
+ ok: true,
39
+ category: parsed.data.category,
40
+ previousCategory,
41
+ })
42
+ } catch (err) {
43
+ return handleRouteError(err, 'categorize proposal')
44
+ }
45
+ }
46
+
47
+ export const openApi: OpenApiRouteDoc = {
48
+ tag: 'InboxOps',
49
+ summary: 'Categorize proposal',
50
+ methods: {
51
+ POST: {
52
+ summary: 'Set or change the category of a proposal',
53
+ description: 'Assigns a category to a proposal. Returns the new and previous category for undo support.',
54
+ responses: [
55
+ { status: 200, description: 'Category updated' },
56
+ { status: 400, description: 'Invalid category value' },
57
+ { status: 404, description: 'Proposal not found' },
58
+ ],
59
+ },
60
+ },
61
+ }
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
4
  import { rejectProposal } from '../../../../lib/executionEngine'
5
+ import { resolveCache, invalidateCountsCache } from '../../../../lib/cache'
4
6
  import {
5
7
  resolveRequestContext,
6
8
  resolveProposal,
@@ -21,6 +23,9 @@ export async function POST(req: Request) {
21
23
 
22
24
  await rejectProposal(proposal.id, toExecutionContext(ctx))
23
25
 
26
+ const cache = resolveCache(ctx.container)
27
+ await runWithCacheTenant(ctx.tenantId, () => invalidateCountsCache(cache, ctx.tenantId))
28
+
24
29
  return NextResponse.json({ ok: true })
25
30
  } catch (err) {
26
31
  return handleRouteError(err, 'reject proposal')