@open-mercato/core 0.4.8-develop-641703d2a6 → 0.4.8-develop-bc5be31b5c

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
@@ -6,6 +6,7 @@ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
6
  import { InboxProposalAction, InboxEmail } from '../../../../../../data/entities'
7
7
  import { draftReplyPayloadSchema } from '../../../../../../data/validators'
8
8
  import { emitInboxOpsEvent } from '../../../../../../events'
9
+ import { createMessageRecordForReply } from '../../../../../../lib/messagesIntegration'
9
10
  import {
10
11
  resolveRequestContext,
11
12
  resolveProposal,
@@ -20,18 +21,6 @@ export const metadata = {
20
21
 
21
22
  export async function POST(req: Request) {
22
23
  try {
23
- const apiKey = process.env.RESEND_API_KEY
24
- if (!apiKey) {
25
- return NextResponse.json({ error: 'Email service not configured' }, { status: 503 })
26
- }
27
-
28
- const emailDisabled =
29
- parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||
30
- parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
31
- if (emailDisabled) {
32
- return NextResponse.json({ error: 'Email delivery is disabled' }, { status: 503 })
33
- }
34
-
35
24
  const ctx = await resolveRequestContext(req)
36
25
  const url = new URL(req.url)
37
26
  const proposal = await resolveProposal(url, ctx)
@@ -80,8 +69,21 @@ export async function POST(req: Request) {
80
69
  if (!payloadResult.success) {
81
70
  return NextResponse.json({ error: 'Reply payload missing required fields (to, subject, body)' }, { status: 400 })
82
71
  }
83
- const { to: toAddress, subject, body, inReplyToMessageId, references: payloadReferences } = payloadResult.data
72
+ const { to: toAddress, toName, subject, body } = payloadResult.data
84
73
 
74
+ const apiKey = process.env.RESEND_API_KEY
75
+ if (!apiKey) {
76
+ return NextResponse.json({ error: 'Email service not configured' }, { status: 503 })
77
+ }
78
+
79
+ const emailDisabled =
80
+ parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||
81
+ parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
82
+ if (emailDisabled) {
83
+ return NextResponse.json({ error: 'Email delivery is disabled' }, { status: 503 })
84
+ }
85
+
86
+ const { inReplyToMessageId, references: payloadReferences } = payloadResult.data
85
87
  const fromAddress = process.env.EMAIL_FROM || `inbox@${process.env.INBOX_OPS_DOMAIN || 'inbox.mercato.local'}`
86
88
 
87
89
  const headers: Record<string, string> = {}
@@ -109,11 +111,24 @@ export async function POST(req: Request) {
109
111
  }
110
112
 
111
113
  const sentMessageId = sendData?.id || null
114
+ const messagesResult = await createMessageRecordForReply(
115
+ { to: toAddress, toName, subject, body },
116
+ proposal.inboxEmailId,
117
+ {
118
+ container: ctx.container,
119
+ scope: {
120
+ tenantId: ctx.tenantId,
121
+ organizationId: ctx.organizationId,
122
+ userId: ctx.userId,
123
+ },
124
+ },
125
+ )
112
126
 
113
127
  action.metadata = {
114
128
  ...(action.metadata && typeof action.metadata === 'object' ? action.metadata : {}),
115
129
  replySentAt: new Date().toISOString(),
116
130
  sentMessageId,
131
+ ...(messagesResult ? { messageRecordId: messagesResult.messageId } : {}),
117
132
  }
118
133
  await ctx.em.flush()
119
134
 
@@ -125,12 +140,17 @@ export async function POST(req: Request) {
125
140
  organizationId: ctx.organizationId,
126
141
  toAddress,
127
142
  sentMessageId,
143
+ messageRecordId: messagesResult?.messageId ?? null,
128
144
  })
129
145
  } catch (eventError) {
130
146
  console.error('[inbox_ops:reply:send] Failed to emit event:', eventError)
131
147
  }
132
148
 
133
- return NextResponse.json({ ok: true, sentMessageId })
149
+ return NextResponse.json({
150
+ ok: true,
151
+ sentMessageId,
152
+ ...(messagesResult ? { messageRecordId: messagesResult.messageId } : {}),
153
+ })
134
154
  } catch (err) {
135
155
  return handleRouteError(err, 'send reply')
136
156
  }
@@ -141,8 +161,8 @@ export const openApi: OpenApiRouteDoc = {
141
161
  summary: 'Send draft reply',
142
162
  methods: {
143
163
  POST: {
144
- summary: 'Send a draft reply email via the configured email provider',
145
- description: 'Sends the draft_reply action payload as an email. Sets In-Reply-To and References headers for threading.',
164
+ summary: 'Send a draft reply email and register it in messages when available',
165
+ description: 'Sends the draft_reply action payload via the configured email provider. When the messages module is available, also records the sent reply as an internal message record. Sets In-Reply-To and References headers for threading.',
146
166
  responses: [
147
167
  { status: 200, description: 'Reply sent successfully' },
148
168
  { status: 400, description: 'Missing required payload fields' },
@@ -1,7 +1,15 @@
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 { InboxProposal } from '../../../data/entities'
5
+ import { ALL_CATEGORIES } from '../../../data/validators'
4
6
  import { resolveRequestContext, UnauthorizedError } from '../../routeHelpers'
7
+ import {
8
+ resolveCache,
9
+ createCountsCacheKey,
10
+ createCountsCacheTag,
11
+ COUNTS_CACHE_TTL_MS,
12
+ } from '../../../lib/cache'
5
13
 
6
14
  export const metadata = {
7
15
  GET: { requireAuth: true, requireFeatures: ['inbox_ops.proposals.view'] },
@@ -10,6 +18,15 @@ export const metadata = {
10
18
  export async function GET(req: Request) {
11
19
  try {
12
20
  const ctx = await resolveRequestContext(req)
21
+ const cache = resolveCache(ctx.container)
22
+
23
+ if (cache) {
24
+ const cacheKey = createCountsCacheKey(ctx.tenantId)
25
+ const cached = await runWithCacheTenant(ctx.tenantId, () => cache.get(cacheKey))
26
+ if (cached) {
27
+ return NextResponse.json(cached)
28
+ }
29
+ }
13
30
 
14
31
  const scope = {
15
32
  organizationId: ctx.organizationId,
@@ -19,7 +36,7 @@ export async function GET(req: Request) {
19
36
  }
20
37
 
21
38
  // em.count() is safe here — filter fields (status, organizationId, tenantId,
22
- // deletedAt, isActive) are not encrypted, so decryption helpers are not needed.
39
+ // deletedAt, isActive, category) are not encrypted, so decryption helpers are not needed.
23
40
  const [pending, partial, accepted, rejected] = await Promise.all([
24
41
  ctx.em.count(InboxProposal, { ...scope, status: 'pending' }),
25
42
  ctx.em.count(InboxProposal, { ...scope, status: 'partial' }),
@@ -27,7 +44,45 @@ export async function GET(req: Request) {
27
44
  ctx.em.count(InboxProposal, { ...scope, status: 'rejected' }),
28
45
  ])
29
46
 
30
- return NextResponse.json({ pending, partial, accepted, rejected })
47
+ // Single GROUP BY query for category counts — O(1) queries
48
+ const knex = ctx.em.getKnex()
49
+ const categoryRows = await knex('inbox_proposals')
50
+ .select('category')
51
+ .count('* as count')
52
+ .where({
53
+ organization_id: ctx.organizationId,
54
+ tenant_id: ctx.tenantId,
55
+ is_active: true,
56
+ })
57
+ .whereNull('deleted_at')
58
+ .groupBy('category')
59
+
60
+ const byCategory: Record<string, number> = {}
61
+ for (const cat of ALL_CATEGORIES) {
62
+ byCategory[cat] = 0
63
+ }
64
+ for (const row of categoryRows) {
65
+ const cat = row.category as string | null
66
+ if (cat && cat in byCategory) {
67
+ byCategory[cat] = Number(row.count)
68
+ }
69
+ }
70
+
71
+ const responseBody = { pending, partial, accepted, rejected, byCategory }
72
+
73
+ if (cache) {
74
+ const cacheKey = createCountsCacheKey(ctx.tenantId)
75
+ const tag = createCountsCacheTag(ctx.tenantId)
76
+ try {
77
+ await runWithCacheTenant(ctx.tenantId, () =>
78
+ cache.set(cacheKey, responseBody, { ttl: COUNTS_CACHE_TTL_MS, tags: [tag] }),
79
+ )
80
+ } catch (err) {
81
+ console.warn('[inbox_ops:proposals:counts] Failed to set cache', err)
82
+ }
83
+ }
84
+
85
+ return NextResponse.json(responseBody)
31
86
  } catch (err) {
32
87
  if (err instanceof UnauthorizedError) {
33
88
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -42,10 +97,10 @@ export const openApi: OpenApiRouteDoc = {
42
97
  summary: 'Proposal counts',
43
98
  methods: {
44
99
  GET: {
45
- summary: 'Get proposal status counts',
46
- description: 'Returns counts by status for tab badges',
100
+ summary: 'Get proposal status and category counts',
101
+ description: 'Returns counts by status and by category for tab badges and filter dropdowns',
47
102
  responses: [
48
- { status: 200, description: 'Status counts object' },
103
+ { status: 200, description: 'Status and category counts object' },
49
104
  ],
50
105
  },
51
106
  },
@@ -1,9 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { ZodError } from 'zod'
2
3
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
4
  import type { FilterQuery } from '@mikro-orm/postgresql'
4
5
  import { findAndCountWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
6
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
6
- import { InboxProposal, InboxEmail, InboxProposalAction, InboxDiscrepancy } from '../../data/entities'
7
+ import { InboxProposal, InboxEmail, InboxProposalAction, InboxDiscrepancy, type InboxProposalCategory } from '../../data/entities'
7
8
  import { proposalListQuerySchema } from '../../data/validators'
8
9
  import { resolveRequestContext, UnauthorizedError } from '../routeHelpers'
9
10
 
@@ -16,6 +17,7 @@ export async function GET(req: Request) {
16
17
  const url = new URL(req.url)
17
18
  const query = proposalListQuerySchema.parse({
18
19
  status: url.searchParams.get('status') || undefined,
20
+ category: url.searchParams.get('category') || undefined,
19
21
  search: url.searchParams.get('search') || undefined,
20
22
  page: url.searchParams.get('page') || undefined,
21
23
  pageSize: url.searchParams.get('pageSize') || undefined,
@@ -33,6 +35,14 @@ export async function GET(req: Request) {
33
35
  if (query.status) {
34
36
  where.status = query.status
35
37
  }
38
+ if (query.category) {
39
+ const categories = query.category.split(',').map((c) => c.trim()).filter(Boolean) as InboxProposalCategory[]
40
+ if (categories.length === 1) {
41
+ where.category = categories[0]
42
+ } else if (categories.length > 1) {
43
+ where.category = { $in: categories }
44
+ }
45
+ }
36
46
  if (query.search) {
37
47
  where.summary = { $ilike: `%${escapeLikePattern(query.search)}%` }
38
48
  }
@@ -93,6 +103,9 @@ export async function GET(req: Request) {
93
103
  totalPages: Math.ceil(total / query.pageSize),
94
104
  })
95
105
  } catch (err) {
106
+ if (err instanceof ZodError) {
107
+ return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
108
+ }
96
109
  if (err instanceof UnauthorizedError) {
97
110
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
98
111
  }
@@ -1,9 +1,17 @@
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 { InboxSettings } from '../../data/entities'
5
6
  import { updateSettingsSchema } from '../../data/validators'
6
7
  import { resolveRequestContext, handleRouteError } from '../routeHelpers'
8
+ import {
9
+ resolveCache,
10
+ createSettingsCacheKey,
11
+ createSettingsCacheTag,
12
+ invalidateSettingsCache,
13
+ SETTINGS_CACHE_TTL_MS,
14
+ } from '../../lib/cache'
7
15
 
8
16
  export const metadata = {
9
17
  GET: { requireAuth: true, requireFeatures: ['inbox_ops.settings.manage'] },
@@ -13,6 +21,15 @@ export const metadata = {
13
21
  export async function GET(req: Request) {
14
22
  try {
15
23
  const ctx = await resolveRequestContext(req)
24
+ const cache = resolveCache(ctx.container)
25
+
26
+ if (cache) {
27
+ const cacheKey = createSettingsCacheKey(ctx.tenantId)
28
+ const cached = await runWithCacheTenant(ctx.tenantId, () => cache.get(cacheKey))
29
+ if (cached) {
30
+ return NextResponse.json(cached)
31
+ }
32
+ }
16
33
 
17
34
  const settings = await findOneWithDecryption(
18
35
  ctx.em,
@@ -26,14 +43,28 @@ export async function GET(req: Request) {
26
43
  ctx.scope,
27
44
  )
28
45
 
29
- return NextResponse.json({
46
+ const responseBody = {
30
47
  settings: settings ? {
31
48
  id: settings.id,
32
49
  inboxAddress: settings.inboxAddress,
33
50
  isActive: settings.isActive,
34
51
  workingLanguage: settings.workingLanguage,
35
52
  } : null,
36
- })
53
+ }
54
+
55
+ if (cache) {
56
+ const cacheKey = createSettingsCacheKey(ctx.tenantId)
57
+ const tag = createSettingsCacheTag(ctx.tenantId)
58
+ try {
59
+ await runWithCacheTenant(ctx.tenantId, () =>
60
+ cache.set(cacheKey, responseBody, { ttl: SETTINGS_CACHE_TTL_MS, tags: [tag] }),
61
+ )
62
+ } catch (err) {
63
+ console.warn('[inbox_ops:settings] Failed to set cache', err)
64
+ }
65
+ }
66
+
67
+ return NextResponse.json(responseBody)
37
68
  } catch (err) {
38
69
  return handleRouteError(err, 'load settings')
39
70
  }
@@ -74,6 +105,9 @@ export async function PATCH(req: Request) {
74
105
 
75
106
  await ctx.em.flush()
76
107
 
108
+ const cache = resolveCache(ctx.container)
109
+ await runWithCacheTenant(ctx.tenantId, () => invalidateSettingsCache(cache, ctx.tenantId))
110
+
77
111
  return NextResponse.json({
78
112
  ok: true,
79
113
  settings: {
@@ -17,12 +17,14 @@ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
17
17
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
18
18
  import { ErrorMessage } from '@open-mercato/ui/backend/detail'
19
19
  import { Settings, Inbox, Copy } from 'lucide-react'
20
+ import { CategoryBadge, useCategoryLabels } from '../../components/proposals/CategoryBadge'
20
21
 
21
22
  type ProposalRow = {
22
23
  id: string
23
24
  summary: string
24
25
  confidence: string
25
26
  status: string
27
+ category?: string | null
26
28
  inboxEmailId: string
27
29
  createdAt: string
28
30
  participants?: { name: string; email: string }[]
@@ -46,6 +48,7 @@ type StatusCounts = {
46
48
  partial: number
47
49
  accepted: number
48
50
  rejected: number
51
+ byCategory?: Record<string, number>
49
52
  }
50
53
 
51
54
  const STATUS_COLORS: Record<string, string> = {
@@ -99,6 +102,7 @@ export default function InboxOpsProposalsPage() {
99
102
  const [copied, setCopied] = React.useState(false)
100
103
 
101
104
  const statusFilter = typeof filterValues.status === 'string' ? filterValues.status : undefined
105
+ const categoryFilter = typeof filterValues.category === 'string' ? filterValues.category : undefined
102
106
 
103
107
  const loadProposals = React.useCallback(async () => {
104
108
  setIsLoading(true)
@@ -107,6 +111,7 @@ export default function InboxOpsProposalsPage() {
107
111
  params.set('page', String(page))
108
112
  params.set('pageSize', String(pageSize))
109
113
  if (statusFilter) params.set('status', statusFilter)
114
+ if (categoryFilter) params.set('category', categoryFilter)
110
115
  if (search.trim()) params.set('search', search.trim())
111
116
 
112
117
  try {
@@ -121,7 +126,7 @@ export default function InboxOpsProposalsPage() {
121
126
  setError(t('inbox_ops.flash.load_failed', 'Failed to load proposals'))
122
127
  }
123
128
  setIsLoading(false)
124
- }, [page, pageSize, statusFilter, search, scopeVersion, t])
129
+ }, [page, pageSize, statusFilter, categoryFilter, search, scopeVersion, t])
125
130
 
126
131
  const loadCounts = React.useCallback(async () => {
127
132
  const result = await apiCall<StatusCounts>('/api/inbox_ops/proposals/counts')
@@ -141,7 +146,7 @@ export default function InboxOpsProposalsPage() {
141
146
 
142
147
  React.useEffect(() => {
143
148
  if (initialLoadComplete) loadProposals()
144
- }, [page, statusFilter, search, scopeVersion]) // eslint-disable-line react-hooks/exhaustive-deps
149
+ }, [page, statusFilter, categoryFilter, search, scopeVersion]) // eslint-disable-line react-hooks/exhaustive-deps
145
150
 
146
151
  const handleCopyAddress = React.useCallback(() => {
147
152
  if (settings?.inboxAddress) {
@@ -189,6 +194,9 @@ export default function InboxOpsProposalsPage() {
189
194
  }
190
195
  }, [confirm, t, loadProposals, loadCounts, runMutation])
191
196
 
197
+ const categoryLabels = useCategoryLabels()
198
+ const byCategory = counts.byCategory || {}
199
+
192
200
  const filters = React.useMemo<FilterDef[]>(() => [
193
201
  {
194
202
  id: 'status',
@@ -201,7 +209,22 @@ export default function InboxOpsProposalsPage() {
201
209
  { value: 'rejected', label: `${t('inbox_ops.status.rejected', 'Rejected')} (${counts.rejected})` },
202
210
  ],
203
211
  },
204
- ], [t, counts])
212
+ {
213
+ id: 'category',
214
+ label: t('inbox_ops.category', 'Category'),
215
+ type: 'select',
216
+ options: [
217
+ { value: 'rfq', label: `${categoryLabels.rfq} (${byCategory.rfq || 0})` },
218
+ { value: 'order', label: `${categoryLabels.order} (${byCategory.order || 0})` },
219
+ { value: 'order_update', label: `${categoryLabels.order_update} (${byCategory.order_update || 0})` },
220
+ { value: 'complaint', label: `${categoryLabels.complaint} (${byCategory.complaint || 0})` },
221
+ { value: 'shipping_update', label: `${categoryLabels.shipping_update} (${byCategory.shipping_update || 0})` },
222
+ { value: 'inquiry', label: `${categoryLabels.inquiry} (${byCategory.inquiry || 0})` },
223
+ { value: 'payment', label: `${categoryLabels.payment} (${byCategory.payment || 0})` },
224
+ { value: 'other', label: `${categoryLabels.other} (${byCategory.other || 0})` },
225
+ ],
226
+ },
227
+ ], [t, counts, categoryLabels, byCategory])
205
228
 
206
229
  const columns: ColumnDef<ProposalRow>[] = React.useMemo(() => [
207
230
  {
@@ -226,6 +249,11 @@ export default function InboxOpsProposalsPage() {
226
249
  header: t('inbox_ops.list.status', 'Status'),
227
250
  cell: ({ row }) => <StatusBadge status={row.original.status} />,
228
251
  },
252
+ {
253
+ accessorKey: 'category',
254
+ header: t('inbox_ops.category', 'Category'),
255
+ cell: ({ row }) => <CategoryBadge category={row.original.category} />,
256
+ },
229
257
  {
230
258
  id: 'actions_count',
231
259
  header: t('inbox_ops.list.progress', 'Progress'),
@@ -22,10 +22,12 @@ import {
22
22
  RefreshCw,
23
23
  Users,
24
24
  Languages,
25
+ Pencil,
25
26
  } from 'lucide-react'
26
27
  import type { ProposalTranslationEntry } from '../../../../data/entities'
27
28
  import type { ProposalDetail, ActionDetail, DiscrepancyDetail, EmailDetail } from '../../../../components/proposals/types'
28
29
  import { ActionCard, ConfidenceBadge, useActionTypeLabels, useDiscrepancyDescriptions } from '../../../../components/proposals/ActionCard'
30
+ import { CategoryBadge, CATEGORY_CONFIG, ALL_CATEGORIES, useCategoryLabels } from '../../../../components/proposals/CategoryBadge'
29
31
  import { hasContactNameIssue } from '../../../../lib/contactValidation'
30
32
  import { EditActionDialog } from '../../../../components/proposals/EditActionDialog'
31
33
 
@@ -66,6 +68,82 @@ function EmailThreadViewer({ email }: { email: EmailDetail | null }) {
66
68
  )
67
69
  }
68
70
 
71
+ function CategoryEditDropdown({
72
+ currentCategory,
73
+ onSelect,
74
+ disabled,
75
+ }: {
76
+ currentCategory: string | null | undefined
77
+ onSelect: (category: string) => void
78
+ disabled: boolean
79
+ }) {
80
+ const t = useT()
81
+ const labels = useCategoryLabels()
82
+ const [isOpen, setIsOpen] = React.useState(false)
83
+ const dropdownRef = React.useRef<HTMLDivElement>(null)
84
+
85
+ React.useEffect(() => {
86
+ function handleClickOutside(event: MouseEvent) {
87
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
88
+ setIsOpen(false)
89
+ }
90
+ }
91
+ function handleEscape(event: KeyboardEvent) {
92
+ if (event.key === 'Escape') setIsOpen(false)
93
+ }
94
+ if (isOpen) {
95
+ document.addEventListener('mousedown', handleClickOutside)
96
+ document.addEventListener('keydown', handleEscape)
97
+ }
98
+ return () => {
99
+ document.removeEventListener('mousedown', handleClickOutside)
100
+ document.removeEventListener('keydown', handleEscape)
101
+ }
102
+ }, [isOpen])
103
+
104
+ return (
105
+ <div className="relative inline-block" ref={dropdownRef}>
106
+ <div className="flex items-center gap-1">
107
+ <CategoryBadge category={currentCategory} />
108
+ <Button
109
+ type="button"
110
+ variant="ghost"
111
+ size="sm"
112
+ className="h-6 w-6 p-0"
113
+ onClick={() => setIsOpen(!isOpen)}
114
+ disabled={disabled}
115
+ title={t('inbox_ops.recategorize', 'Change Category')}
116
+ >
117
+ <Pencil className="h-3 w-3" />
118
+ </Button>
119
+ </div>
120
+ {isOpen && (
121
+ <div className="absolute top-full left-0 mt-1 z-50 w-48 rounded-md border bg-popover shadow-md">
122
+ <div className="p-1">
123
+ {ALL_CATEGORIES.map((cat) => {
124
+ const config = CATEGORY_CONFIG[cat]
125
+ const { Icon } = config
126
+ return (
127
+ <Button
128
+ key={cat}
129
+ type="button"
130
+ variant="ghost"
131
+ size="sm"
132
+ className={`flex items-center gap-2 w-full justify-start rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground ${currentCategory === cat ? 'bg-accent' : ''}`}
133
+ onClick={() => { onSelect(cat); setIsOpen(false) }}
134
+ >
135
+ <Icon className="h-3.5 w-3.5" />
136
+ {labels[cat] || cat}
137
+ </Button>
138
+ )
139
+ })}
140
+ </div>
141
+ </div>
142
+ )}
143
+ </div>
144
+ )
145
+ }
146
+
69
147
  export default function ProposalDetailPage({ params }: { params?: { id?: string } }) {
70
148
  const t = useT()
71
149
  const locale = useLocale()
@@ -146,6 +224,22 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
146
224
  setIsTranslating(false)
147
225
  }, [proposalId, locale, t, runMutation])
148
226
 
227
+ const handleCategorize = React.useCallback(async (category: string) => {
228
+ if (!proposalId) return
229
+ const result = await runMutation({
230
+ operation: () => apiCall<{ ok: boolean; category: string; previousCategory: string | null }>(
231
+ `/api/inbox_ops/proposals/${proposalId}/categorize`,
232
+ { method: 'POST', body: JSON.stringify({ category }) },
233
+ ),
234
+ context: {},
235
+ })
236
+ if (result?.ok && result.result?.ok) {
237
+ setProposal((prev) => prev ? { ...prev, category: result.result!.category } : prev)
238
+ } else {
239
+ flash(t('inbox_ops.flash.save_failed', 'Failed to save'), 'error')
240
+ }
241
+ }, [proposalId, t, runMutation])
242
+
149
243
  const loadData = React.useCallback(async () => {
150
244
  if (!proposalId) return
151
245
  setIsLoading(true)
@@ -420,11 +514,19 @@ export default function ProposalDetailPage({ params }: { params?: { id?: string
420
514
  {showTranslation && translation ? translation.summary : proposal.summary}
421
515
  </p>
422
516
 
423
- <div className="flex items-center gap-4 mb-3">
517
+ <div className="flex items-center gap-4 mb-3 flex-wrap">
424
518
  <div>
425
519
  <span className="text-xs text-muted-foreground">{t('inbox_ops.confidence', 'Confidence')}</span>
426
520
  <ConfidenceBadge value={proposal.confidence} />
427
521
  </div>
522
+ <div>
523
+ <span className="text-xs text-muted-foreground block">{t('inbox_ops.category', 'Category')}</span>
524
+ <CategoryEditDropdown
525
+ currentCategory={proposal.category}
526
+ onSelect={handleCategorize}
527
+ disabled={isProcessing}
528
+ />
529
+ </div>
428
530
  </div>
429
531
 
430
532
  {proposal.possiblyIncomplete && (
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
4
+ import type { MessageContentProps } from '@open-mercato/shared/modules/messages/types'
5
+ import { Badge } from '@open-mercato/ui/primitives/badge'
6
+
7
+ export function InboxEmailContent({ message }: MessageContentProps) {
8
+ const t = useT()
9
+
10
+ return (
11
+ <div className="space-y-4">
12
+ <div className="flex items-center gap-2">
13
+ <Badge variant="outline" className="text-xs">
14
+ {t('inbox_ops.title', 'AI Inbox Actions')}
15
+ </Badge>
16
+ {message.senderName ? (
17
+ <span className="text-sm text-muted-foreground">
18
+ {message.senderName}
19
+ </span>
20
+ ) : null}
21
+ </div>
22
+
23
+ <div>
24
+ <h3 className="text-base font-semibold">{message.subject}</h3>
25
+ {message.sentAt ? (
26
+ <p className="text-xs text-muted-foreground">
27
+ {new Date(message.sentAt).toLocaleString()}
28
+ </p>
29
+ ) : null}
30
+ </div>
31
+
32
+ {message.body ? (
33
+ <pre className="whitespace-pre-wrap break-words rounded-md bg-muted/30 p-4 text-sm leading-relaxed">
34
+ {message.body}
35
+ </pre>
36
+ ) : (
37
+ <p className="text-sm text-muted-foreground">
38
+ {t('inbox_ops.no_email_content', 'No email content available')}
39
+ </p>
40
+ )}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ export default InboxEmailContent
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { Mail } from 'lucide-react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import type { ObjectPreviewProps } from '@open-mercato/shared/modules/messages/types'
6
+ import { Badge } from '@open-mercato/ui/primitives/badge'
7
+
8
+ export function InboxEmailPreview({
9
+ previewData,
10
+ actionRequired,
11
+ actionLabel,
12
+ }: ObjectPreviewProps) {
13
+ const t = useT()
14
+
15
+ return (
16
+ <div className="flex items-start gap-3 rounded-md border bg-muted/20 p-3">
17
+ <Mail className="mt-0.5 h-4 w-4 text-muted-foreground" />
18
+ <div className="min-w-0 flex-1 space-y-1">
19
+ <div className="flex items-center gap-2">
20
+ <p className="truncate text-sm font-medium">
21
+ {previewData?.title || t('inbox_ops.title', 'AI Inbox Actions')}
22
+ </p>
23
+ {actionRequired ? (
24
+ <Badge variant="secondary" className="text-xs">
25
+ {actionLabel || t('messages.composer.objectActionRequired', 'Action required')}
26
+ </Badge>
27
+ ) : null}
28
+ </div>
29
+ {previewData?.subtitle ? (
30
+ <p className="truncate text-xs text-muted-foreground">{previewData.subtitle}</p>
31
+ ) : null}
32
+ {previewData?.status ? (
33
+ <Badge variant="outline" className="text-xs">{previewData.status}</Badge>
34
+ ) : null}
35
+ </div>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ export default InboxEmailPreview