@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.
- package/dist/generated/entities/inbox_proposal/index.js +2 -0
- package/dist/generated/entities/inbox_proposal/index.js.map +2 -2
- package/dist/modules/catalog/inbox-actions.js +49 -0
- package/dist/modules/catalog/inbox-actions.js.map +2 -2
- package/dist/modules/customers/inbox-actions.js +69 -27
- package/dist/modules/customers/inbox-actions.js.map +3 -3
- package/dist/modules/inbox_ops/ai-tools.js +346 -0
- package/dist/modules/inbox_ops/ai-tools.js.map +7 -0
- package/dist/modules/inbox_ops/api/extract/route.js +3 -2
- package/dist/modules/inbox_ops/api/extract/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js +4 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/accept/route.js +4 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/accept/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/complete/route.js +4 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/complete/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/reject/route.js +4 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/reject/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +4 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js +59 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/reject/route.js +4 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/reject/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js +34 -14
- package/dist/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/counts/route.js +49 -4
- package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/route.js +13 -0
- package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/settings/route.js +33 -2
- package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js +28 -3
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +103 -5
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +2 -2
- package/dist/modules/inbox_ops/components/messages/InboxEmailContent.js +24 -0
- package/dist/modules/inbox_ops/components/messages/InboxEmailContent.js.map +7 -0
- package/dist/modules/inbox_ops/components/messages/InboxEmailPreview.js +29 -0
- package/dist/modules/inbox_ops/components/messages/InboxEmailPreview.js.map +7 -0
- package/dist/modules/inbox_ops/components/proposals/CategoryBadge.js +59 -0
- package/dist/modules/inbox_ops/components/proposals/CategoryBadge.js.map +7 -0
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +3 -1
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/inbox_ops/data/entities.js +4 -0
- package/dist/modules/inbox_ops/data/entities.js.map +2 -2
- package/dist/modules/inbox_ops/data/validators.js +30 -5
- package/dist/modules/inbox_ops/data/validators.js.map +2 -2
- package/dist/modules/inbox_ops/lib/cache.js +53 -0
- package/dist/modules/inbox_ops/lib/cache.js.map +7 -0
- package/dist/modules/inbox_ops/lib/contactValidation.js +38 -3
- package/dist/modules/inbox_ops/lib/contactValidation.js.map +2 -2
- package/dist/modules/inbox_ops/lib/executionHelpers.js +28 -1
- package/dist/modules/inbox_ops/lib/executionHelpers.js.map +2 -2
- package/dist/modules/inbox_ops/lib/extractionPrompt.js +2 -1
- package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +2 -2
- package/dist/modules/inbox_ops/lib/messageObjectPreviews.js +52 -0
- package/dist/modules/inbox_ops/lib/messageObjectPreviews.js.map +7 -0
- package/dist/modules/inbox_ops/lib/messagesIntegration.js +155 -0
- package/dist/modules/inbox_ops/lib/messagesIntegration.js.map +7 -0
- package/dist/modules/inbox_ops/message-objects.js +36 -0
- package/dist/modules/inbox_ops/message-objects.js.map +7 -0
- package/dist/modules/inbox_ops/message-types.js +38 -0
- package/dist/modules/inbox_ops/message-types.js.map +7 -0
- package/dist/modules/inbox_ops/migrations/Migration20260303173020.js +13 -0
- package/dist/modules/inbox_ops/migrations/Migration20260303173020.js.map +7 -0
- package/dist/modules/inbox_ops/migrations/Migration20260303173215.js +15 -0
- package/dist/modules/inbox_ops/migrations/Migration20260303173215.js.map +7 -0
- package/dist/modules/inbox_ops/search.js +5 -3
- package/dist/modules/inbox_ops/search.js.map +2 -2
- package/dist/modules/inbox_ops/subscribers/extractionWorker.js +65 -3
- package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
- package/generated/entities/inbox_proposal/index.ts +1 -0
- package/package.json +3 -3
- package/src/modules/catalog/inbox-actions.ts +55 -0
- package/src/modules/customers/inbox-actions.ts +86 -27
- package/src/modules/inbox_ops/ai-tools.ts +451 -0
- package/src/modules/inbox_ops/api/extract/route.ts +3 -2
- package/src/modules/inbox_ops/api/proposals/[id]/accept-all/route.ts +5 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/accept/route.ts +5 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/complete/route.ts +5 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/reject/route.ts +5 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +5 -0
- package/src/modules/inbox_ops/api/proposals/[id]/categorize/route.ts +61 -0
- package/src/modules/inbox_ops/api/proposals/[id]/reject/route.ts +5 -0
- package/src/modules/inbox_ops/api/proposals/[id]/replies/[replyId]/send/route.ts +36 -16
- package/src/modules/inbox_ops/api/proposals/counts/route.ts +60 -5
- package/src/modules/inbox_ops/api/proposals/route.ts +14 -1
- package/src/modules/inbox_ops/api/settings/route.ts +36 -2
- package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +31 -3
- package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +103 -1
- package/src/modules/inbox_ops/components/messages/InboxEmailContent.tsx +45 -0
- package/src/modules/inbox_ops/components/messages/InboxEmailPreview.tsx +40 -0
- package/src/modules/inbox_ops/components/proposals/CategoryBadge.tsx +59 -0
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +3 -1
- package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
- package/src/modules/inbox_ops/data/entities.ts +14 -1
- package/src/modules/inbox_ops/data/validators.ts +41 -5
- package/src/modules/inbox_ops/lib/cache.ts +60 -0
- package/src/modules/inbox_ops/lib/contactValidation.ts +31 -2
- package/src/modules/inbox_ops/lib/executionHelpers.ts +40 -0
- package/src/modules/inbox_ops/lib/extractionPrompt.ts +2 -1
- package/src/modules/inbox_ops/lib/messageObjectPreviews.ts +61 -0
- package/src/modules/inbox_ops/lib/messagesIntegration.ts +231 -0
- package/src/modules/inbox_ops/message-objects.ts +34 -0
- package/src/modules/inbox_ops/message-types.ts +36 -0
- package/src/modules/inbox_ops/migrations/Migration20260303173020.ts +13 -0
- package/src/modules/inbox_ops/migrations/Migration20260303173215.ts +15 -0
- package/src/modules/inbox_ops/search.ts +5 -3
- 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
|
|
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({
|
|
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
|
|
145
|
-
description: 'Sends the draft_reply action payload as an
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|