@open-mercato/core 0.4.11-develop.1309.4b37381a7a → 0.4.11-develop.1347.c693e6dfee
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/customers/api/companies/[id]/route.js +3 -2
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js +59 -91
- package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/tasks/route.js +115 -0
- package/dist/modules/customers/api/interactions/tasks/route.js.map +7 -0
- package/dist/modules/customers/api/people/[id]/route.js +3 -2
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/todos/route.js +14 -134
- package/dist/modules/customers/api/todos/route.js.map +2 -2
- package/dist/modules/customers/backend/customer-tasks/page.js +10 -0
- package/dist/modules/customers/backend/customer-tasks/page.js.map +7 -0
- package/dist/modules/customers/backend/customer-tasks/page.meta.js +25 -0
- package/dist/modules/customers/backend/customer-tasks/page.meta.js.map +7 -0
- package/dist/modules/customers/commands/interactions.js +40 -4
- package/dist/modules/customers/commands/interactions.js.map +2 -2
- package/dist/modules/customers/components/CustomerTodosTable.js +77 -47
- package/dist/modules/customers/components/CustomerTodosTable.js.map +2 -2
- package/dist/modules/customers/components/detail/TasksSection.js +4 -4
- package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/usePersonTasks.js +2 -1
- package/dist/modules/customers/components/detail/hooks/usePersonTasks.js.map +2 -2
- package/dist/modules/customers/components/detail/utils.js +3 -0
- package/dist/modules/customers/components/detail/utils.js.map +2 -2
- package/dist/modules/customers/data/entities.js +2 -2
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +2 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/interactionCompatibility.js +12 -2
- package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
- package/dist/modules/customers/lib/todoCompatibility.js +167 -4
- package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
- package/dist/modules/customers/migrations/Migration20260401172819.js +45 -0
- package/dist/modules/customers/migrations/Migration20260401172819.js.map +7 -0
- package/dist/modules/customers/search.js +3 -2
- package/dist/modules/customers/search.js.map +2 -2
- package/dist/modules/customers/widgets/dashboard/customer-todos/widget.client.js +10 -2
- package/dist/modules/customers/widgets/dashboard/customer-todos/widget.client.js.map +2 -2
- package/package.json +3 -3
- package/src/modules/customers/api/companies/[id]/route.ts +6 -5
- package/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts +69 -126
- package/src/modules/customers/api/interactions/tasks/route.ts +122 -0
- package/src/modules/customers/api/people/[id]/route.ts +3 -2
- package/src/modules/customers/api/todos/route.ts +13 -181
- package/src/modules/customers/backend/customer-tasks/page.meta.ts +23 -0
- package/src/modules/customers/backend/customer-tasks/page.tsx +12 -0
- package/src/modules/customers/commands/interactions.ts +50 -2
- package/src/modules/customers/components/CustomerTodosTable.tsx +91 -66
- package/src/modules/customers/components/detail/TasksSection.tsx +8 -8
- package/src/modules/customers/components/detail/hooks/usePersonTasks.ts +2 -1
- package/src/modules/customers/components/detail/types.ts +6 -0
- package/src/modules/customers/components/detail/utils.ts +3 -0
- package/src/modules/customers/data/entities.ts +2 -2
- package/src/modules/customers/data/validators.ts +2 -2
- package/src/modules/customers/lib/interactionCompatibility.ts +16 -0
- package/src/modules/customers/lib/todoCompatibility.ts +229 -10
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +1 -1
- package/src/modules/customers/migrations/Migration20260401172819.ts +45 -0
- package/src/modules/customers/search.ts +3 -2
- package/src/modules/customers/widgets/dashboard/customer-todos/widget.client.tsx +24 -23
|
@@ -2,13 +2,17 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
4
4
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
|
-
import { CustomerEntity, CustomerTodoLink } from '../../../../data/entities'
|
|
6
5
|
import { resolveWidgetScope, type WidgetScopeContext } from '../utils'
|
|
6
|
+
import { resolveCustomerInteractionFeatureFlags } from '../../../../lib/interactionFeatureFlags'
|
|
7
|
+
import { CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE } from '../../../../lib/interactionCompatibility'
|
|
7
8
|
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
8
9
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
listLegacyTodoRows,
|
|
12
|
+
listCanonicalTodoRows,
|
|
13
|
+
sortTodoRows,
|
|
14
|
+
type CustomerTodoRow,
|
|
15
|
+
} from '../../../../lib/todoCompatibility'
|
|
12
16
|
|
|
13
17
|
const querySchema = z.object({
|
|
14
18
|
limit: z.coerce.number().min(1).max(20).default(5),
|
|
@@ -47,135 +51,73 @@ async function resolveContext(req: Request, translate: (key: string, fallback?:
|
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
type TodoSummary = {
|
|
51
|
-
id: string
|
|
52
|
-
title: string | null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const TODO_TITLE_FIELDS = ['title', 'subject', 'name', 'summary', 'text', 'description'] as const
|
|
56
|
-
|
|
57
|
-
function extractTodoTitle(record: Record<string, unknown>): string | null {
|
|
58
|
-
for (const key of TODO_TITLE_FIELDS) {
|
|
59
|
-
const value = record[key]
|
|
60
|
-
if (typeof value === 'string' && value.trim().length > 0) {
|
|
61
|
-
return value.trim()
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function resolveTodoSummaries(
|
|
68
|
-
queryEngine: QueryEngine,
|
|
69
|
-
links: CustomerTodoLink[],
|
|
70
|
-
tenantId: string,
|
|
71
|
-
organizationIds: string[] | null
|
|
72
|
-
): Promise<Map<string, TodoSummary>> {
|
|
73
|
-
const results = new Map<string, TodoSummary>()
|
|
74
|
-
if (!links.length) return results
|
|
75
|
-
|
|
76
|
-
const idsBySource = new Map<string, Set<string>>()
|
|
77
|
-
for (const link of links) {
|
|
78
|
-
const source = typeof link.todoSource === 'string' && link.todoSource.length > 0 ? link.todoSource : 'unknown'
|
|
79
|
-
const id = String(link.todoId ?? '')
|
|
80
|
-
if (!id) continue
|
|
81
|
-
if (!idsBySource.has(source)) idsBySource.set(source, new Set<string>())
|
|
82
|
-
idsBySource.get(source)!.add(id)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const scopedOrgIds = Array.isArray(organizationIds)
|
|
86
|
-
? Array.from(new Set(organizationIds.filter((id) => typeof id === 'string' && id.length > 0)))
|
|
87
|
-
: null
|
|
88
|
-
|
|
89
|
-
for (const [source, idSet] of idsBySource.entries()) {
|
|
90
|
-
const ids = Array.from(idSet)
|
|
91
|
-
if (ids.length === 0 || source === 'unknown') continue
|
|
92
|
-
try {
|
|
93
|
-
const requestedFields = Array.from(new Set(['id', ...TODO_TITLE_FIELDS]))
|
|
94
|
-
const queryResult = await queryEngine.query<Record<string, unknown>>(source as EntityId, {
|
|
95
|
-
tenantId,
|
|
96
|
-
organizationIds: scopedOrgIds && scopedOrgIds.length > 0 ? scopedOrgIds : undefined,
|
|
97
|
-
filters: { id: { $in: ids } },
|
|
98
|
-
fields: requestedFields,
|
|
99
|
-
includeCustomFields: false,
|
|
100
|
-
page: { page: 1, pageSize: Math.max(ids.length, 1) },
|
|
101
|
-
})
|
|
102
|
-
for (const item of queryResult.items ?? []) {
|
|
103
|
-
if (!item || typeof item !== 'object') continue
|
|
104
|
-
const raw = item as Record<string, unknown>
|
|
105
|
-
const todoId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : String(raw.id ?? '')
|
|
106
|
-
if (!todoId) continue
|
|
107
|
-
const title = extractTodoTitle(raw)
|
|
108
|
-
results.set(`${source}:${todoId}`, { id: todoId, title })
|
|
109
|
-
}
|
|
110
|
-
} catch (err) {
|
|
111
|
-
console.warn(`customers.widgets.todos: failed to resolve todos for source ${source}`, err)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return results
|
|
116
|
-
}
|
|
117
|
-
|
|
118
54
|
export async function GET(req: Request) {
|
|
119
55
|
const { translate } = await resolveTranslations()
|
|
120
56
|
try {
|
|
121
57
|
const { container, em, tenantId, organizationIds, limit } = await resolveContext(req, translate)
|
|
122
|
-
const
|
|
123
|
-
? organizationIds.length === 1
|
|
124
|
-
? organizationIds[0]
|
|
125
|
-
: { $in: Array.from(new Set(organizationIds)) }
|
|
126
|
-
: null
|
|
127
|
-
|
|
128
|
-
const linkFilters = {
|
|
129
|
-
tenantId,
|
|
130
|
-
...(whereOrganization ? { organizationId: whereOrganization } : {}),
|
|
131
|
-
entity: {
|
|
132
|
-
deletedAt: null,
|
|
133
|
-
} as FilterQuery<CustomerEntity>,
|
|
134
|
-
} as FilterQuery<CustomerTodoLink>
|
|
135
|
-
|
|
136
|
-
const links = await em.find(
|
|
137
|
-
CustomerTodoLink,
|
|
138
|
-
linkFilters,
|
|
139
|
-
{
|
|
140
|
-
limit,
|
|
141
|
-
orderBy: { createdAt: 'desc' },
|
|
142
|
-
populate: ['entity'],
|
|
143
|
-
}
|
|
144
|
-
)
|
|
145
|
-
await decryptEntitiesWithFallbackScope(links, {
|
|
146
|
-
em,
|
|
58
|
+
const auth = {
|
|
147
59
|
tenantId,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
60
|
+
orgId: organizationIds?.[0] ?? null,
|
|
61
|
+
sub: 'customers.dashboard.todos',
|
|
62
|
+
}
|
|
63
|
+
const flags = await resolveCustomerInteractionFeatureFlags(container, tenantId)
|
|
64
|
+
const rows = flags.unified
|
|
65
|
+
? (await listCanonicalTodoRows(
|
|
66
|
+
em,
|
|
67
|
+
container,
|
|
68
|
+
auth,
|
|
69
|
+
organizationIds?.[0] ?? null,
|
|
70
|
+
organizationIds ?? null,
|
|
71
|
+
)).items
|
|
72
|
+
: await Promise.all([
|
|
73
|
+
listLegacyTodoRows(
|
|
74
|
+
em,
|
|
75
|
+
container.resolve('queryEngine') as QueryEngine,
|
|
76
|
+
tenantId,
|
|
77
|
+
organizationIds ?? null,
|
|
78
|
+
undefined,
|
|
79
|
+
),
|
|
80
|
+
listCanonicalTodoRows(
|
|
81
|
+
em,
|
|
82
|
+
container,
|
|
83
|
+
auth,
|
|
84
|
+
organizationIds?.[0] ?? null,
|
|
85
|
+
organizationIds ?? null,
|
|
86
|
+
{
|
|
87
|
+
includeDeleted: true,
|
|
88
|
+
source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
|
|
89
|
+
},
|
|
90
|
+
),
|
|
91
|
+
]).then(([legacyRows, canonicalRows]) =>
|
|
92
|
+
sortTodoRows([
|
|
93
|
+
...legacyRows.filter((row) => !canonicalRows.bridgeIds.has(row.todoId)),
|
|
94
|
+
...canonicalRows.items,
|
|
95
|
+
]),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const items = rows.slice(0, limit).map((row: CustomerTodoRow) => {
|
|
99
|
+
const entity = row.customer ?? null
|
|
159
100
|
return {
|
|
160
|
-
id:
|
|
161
|
-
todoId:
|
|
162
|
-
todoSource:
|
|
163
|
-
todoTitle:
|
|
164
|
-
createdAt:
|
|
165
|
-
organizationId:
|
|
166
|
-
|
|
101
|
+
id: row.id,
|
|
102
|
+
todoId: row.todoId,
|
|
103
|
+
todoSource: row.todoSource,
|
|
104
|
+
todoTitle: row.todoTitle ?? null,
|
|
105
|
+
createdAt: row.createdAt,
|
|
106
|
+
organizationId: row.organizationId ?? null,
|
|
107
|
+
_integrations: row._integrations ?? undefined,
|
|
108
|
+
entity: entity?.id
|
|
167
109
|
? {
|
|
168
|
-
id:
|
|
169
|
-
displayName:
|
|
170
|
-
kind:
|
|
171
|
-
ownerUserId:
|
|
110
|
+
id: entity.id,
|
|
111
|
+
displayName: entity.displayName ?? null,
|
|
112
|
+
kind: entity.kind ?? null,
|
|
113
|
+
ownerUserId: null,
|
|
172
114
|
}
|
|
173
115
|
: {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
116
|
+
id: null,
|
|
117
|
+
displayName: null,
|
|
118
|
+
kind: null,
|
|
119
|
+
ownerUserId: null,
|
|
120
|
+
},
|
|
179
121
|
}
|
|
180
122
|
})
|
|
181
123
|
|
|
@@ -198,6 +140,7 @@ const customerTodoWidgetItemSchema = z.object({
|
|
|
198
140
|
todoSource: z.string(),
|
|
199
141
|
todoTitle: z.string().nullable().optional(),
|
|
200
142
|
createdAt: z.string(),
|
|
143
|
+
_integrations: z.record(z.string(), z.unknown()).optional(),
|
|
201
144
|
organizationId: z.string().uuid().nullable().optional(),
|
|
202
145
|
entity: z
|
|
203
146
|
.object({
|
|
@@ -222,8 +165,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
222
165
|
summary: 'Customer todos widget',
|
|
223
166
|
methods: {
|
|
224
167
|
GET: {
|
|
225
|
-
summary: 'Fetch recent customer
|
|
226
|
-
description: 'Returns the most
|
|
168
|
+
summary: 'Fetch recent customer tasks',
|
|
169
|
+
description: 'Returns the most recent customer tasks for display on dashboards, including legacy compatibility rows when needed.',
|
|
227
170
|
query: querySchema,
|
|
228
171
|
responses: [
|
|
229
172
|
{ status: 200, description: 'Widget payload', schema: customerTodoWidgetResponseSchema },
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
|
+
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
|
+
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
8
|
+
import { createCustomersCrudOpenApi, createPagedListResponseSchema } from '../../openapi'
|
|
9
|
+
import { resolveCustomerInteractionFeatureFlags } from '../../../lib/interactionFeatureFlags'
|
|
10
|
+
import { resolveCustomersRequestContext } from '../../../lib/interactionRequestContext'
|
|
11
|
+
import { CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE } from '../../../lib/interactionCompatibility'
|
|
12
|
+
import {
|
|
13
|
+
filterTodoRows,
|
|
14
|
+
listCanonicalTodoRows,
|
|
15
|
+
listLegacyTodoRows,
|
|
16
|
+
normalizeTodoSearch,
|
|
17
|
+
paginateTodoRows,
|
|
18
|
+
sortTodoRows,
|
|
19
|
+
} from '../../../lib/todoCompatibility'
|
|
20
|
+
|
|
21
|
+
const querySchema = z.object({
|
|
22
|
+
page: z.coerce.number().min(1).default(1),
|
|
23
|
+
pageSize: z.coerce.number().min(1).max(100).default(50),
|
|
24
|
+
search: z.string().optional(),
|
|
25
|
+
all: z.string().optional(),
|
|
26
|
+
entityId: z.string().uuid().optional(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const metadata = {
|
|
30
|
+
GET: { requireAuth: true, requireFeatures: ['customers.interactions.view'] },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function GET(request: Request): Promise<Response> {
|
|
34
|
+
const { translate } = await resolveTranslations()
|
|
35
|
+
try {
|
|
36
|
+
const { auth, em, organizationIds, container, selectedOrganizationId } =
|
|
37
|
+
await resolveCustomersRequestContext(request)
|
|
38
|
+
const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams))
|
|
39
|
+
const flags = await resolveCustomerInteractionFeatureFlags(container, auth.tenantId)
|
|
40
|
+
const exportAll = parseBooleanToken(query.all) === true
|
|
41
|
+
const search = normalizeTodoSearch(query.search)
|
|
42
|
+
const queryEngine = container.resolve('queryEngine') as QueryEngine
|
|
43
|
+
|
|
44
|
+
const mergedRows = flags.unified
|
|
45
|
+
? (await listCanonicalTodoRows(
|
|
46
|
+
em,
|
|
47
|
+
container,
|
|
48
|
+
auth,
|
|
49
|
+
selectedOrganizationId,
|
|
50
|
+
organizationIds,
|
|
51
|
+
{ entityId: query.entityId },
|
|
52
|
+
)).items
|
|
53
|
+
: await Promise.all([
|
|
54
|
+
listLegacyTodoRows(em, queryEngine, auth.tenantId, organizationIds, query.entityId),
|
|
55
|
+
listCanonicalTodoRows(
|
|
56
|
+
em,
|
|
57
|
+
container,
|
|
58
|
+
auth,
|
|
59
|
+
selectedOrganizationId,
|
|
60
|
+
organizationIds,
|
|
61
|
+
{
|
|
62
|
+
entityId: query.entityId,
|
|
63
|
+
includeDeleted: true,
|
|
64
|
+
source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
|
|
65
|
+
},
|
|
66
|
+
),
|
|
67
|
+
]).then(([legacyRows, canonicalRows]) => [
|
|
68
|
+
...legacyRows.filter((row) => !canonicalRows.bridgeIds.has(row.todoId)),
|
|
69
|
+
...canonicalRows.items,
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
const filteredRows = filterTodoRows(sortTodoRows(mergedRows), search)
|
|
73
|
+
const paged = paginateTodoRows(filteredRows, query.page, query.pageSize, exportAll)
|
|
74
|
+
|
|
75
|
+
return NextResponse.json({
|
|
76
|
+
items: paged.items,
|
|
77
|
+
total: paged.total,
|
|
78
|
+
page: paged.page,
|
|
79
|
+
pageSize: paged.pageSize,
|
|
80
|
+
totalPages: paged.totalPages,
|
|
81
|
+
})
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof CrudHttpError) {
|
|
84
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
85
|
+
}
|
|
86
|
+
if (err instanceof z.ZodError) {
|
|
87
|
+
return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 })
|
|
88
|
+
}
|
|
89
|
+
console.error('customers.interactions.tasks.get failed', err)
|
|
90
|
+
return NextResponse.json({ error: translate('customers.errors.internalError', 'Internal server error') }, { status: 500 })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const todoItemSchema = z.object({
|
|
95
|
+
id: z.string(),
|
|
96
|
+
todoId: z.string(),
|
|
97
|
+
todoSource: z.string(),
|
|
98
|
+
todoTitle: z.string().nullable(),
|
|
99
|
+
todoIsDone: z.boolean().nullable(),
|
|
100
|
+
todoPriority: z.number().nullable().optional(),
|
|
101
|
+
todoSeverity: z.string().nullable().optional(),
|
|
102
|
+
todoDescription: z.string().nullable().optional(),
|
|
103
|
+
todoDueAt: z.string().nullable().optional(),
|
|
104
|
+
todoCustomValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
105
|
+
todoOrganizationId: z.string().nullable(),
|
|
106
|
+
organizationId: z.string(),
|
|
107
|
+
tenantId: z.string(),
|
|
108
|
+
createdAt: z.string(),
|
|
109
|
+
externalHref: z.string().nullable().optional(),
|
|
110
|
+
_integrations: z.record(z.string(), z.unknown()).optional(),
|
|
111
|
+
customer: z.object({
|
|
112
|
+
id: z.string().nullable(),
|
|
113
|
+
displayName: z.string().nullable(),
|
|
114
|
+
kind: z.string().nullable(),
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
export const openApi: OpenApiRouteDoc = createCustomersCrudOpenApi({
|
|
119
|
+
resourceName: 'CustomerTask',
|
|
120
|
+
querySchema,
|
|
121
|
+
listResponseSchema: createPagedListResponseSchema(todoItemSchema),
|
|
122
|
+
})
|
|
@@ -23,6 +23,7 @@ import { E } from '#generated/entities.ids.generated'
|
|
|
23
23
|
import { mergePersonCustomFieldValues, resolvePersonCustomFieldRouting } from '../../../lib/customFieldRouting'
|
|
24
24
|
import {
|
|
25
25
|
CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE,
|
|
26
|
+
EXAMPLE_TODO_SOURCE,
|
|
26
27
|
CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
|
|
27
28
|
mapInteractionRecordToActivitySummary,
|
|
28
29
|
mapInteractionRecordToTodoSummary,
|
|
@@ -235,7 +236,7 @@ async function resolveTodoDetails(
|
|
|
235
236
|
|
|
236
237
|
const idsBySource = new Map<string, Set<string>>()
|
|
237
238
|
for (const link of links) {
|
|
238
|
-
const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource :
|
|
239
|
+
const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : EXAMPLE_TODO_SOURCE
|
|
239
240
|
const id = typeof link.todoId === 'string' && link.todoId.trim().length > 0 ? link.todoId : String(link.todoId ?? '')
|
|
240
241
|
if (!id) continue
|
|
241
242
|
if (!idsBySource.has(source)) idsBySource.set(source, new Set<string>())
|
|
@@ -775,7 +776,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
|
|
|
775
776
|
...todoLinks
|
|
776
777
|
.filter((link) => !canonicalTodoBridgeIds.has(link.todoId))
|
|
777
778
|
.map((link) => {
|
|
778
|
-
const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource :
|
|
779
|
+
const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : EXAMPLE_TODO_SOURCE
|
|
779
780
|
const key = `${source}:${link.todoId}`
|
|
780
781
|
const detail = todoDetails.get(key)
|
|
781
782
|
return {
|
|
@@ -22,17 +22,16 @@ import { todoLinkWithTodoCreateSchema } from '../../data/validators'
|
|
|
22
22
|
import { resolveCustomerInteractionFeatureFlags } from '../../lib/interactionFeatureFlags'
|
|
23
23
|
import { resolveCustomersRequestContext } from '../../lib/interactionRequestContext'
|
|
24
24
|
import {
|
|
25
|
-
|
|
26
|
-
loadCustomerSummaries,
|
|
27
|
-
} from '../../lib/interactionReadModel'
|
|
28
|
-
import {
|
|
25
|
+
EXAMPLE_TODO_SOURCE,
|
|
29
26
|
CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
|
|
30
|
-
CUSTOMER_INTERACTION_TASK_SOURCE,
|
|
31
27
|
} from '../../lib/interactionCompatibility'
|
|
32
28
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
filterTodoRows,
|
|
30
|
+
listCanonicalTodoRows,
|
|
31
|
+
listLegacyTodoRows,
|
|
32
|
+
normalizeTodoSearch,
|
|
33
|
+
paginateTodoRows,
|
|
34
|
+
sortTodoRows,
|
|
36
35
|
resolveLegacyTodoDetails,
|
|
37
36
|
} from '../../lib/todoCompatibility'
|
|
38
37
|
|
|
@@ -79,11 +78,6 @@ const DEPRECATION_HEADERS = {
|
|
|
79
78
|
Link: '</api/customers/interactions>; rel="successor-version"',
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
type CanonicalTodoListResult = {
|
|
83
|
-
items: CustomerTodoRow[]
|
|
84
|
-
bridgeIds: Set<string>
|
|
85
|
-
}
|
|
86
|
-
|
|
87
81
|
function resolveGuardUserId(auth: {
|
|
88
82
|
sub?: string | null
|
|
89
83
|
userId?: string | null
|
|
@@ -118,64 +112,6 @@ async function legacyAdaptersDisabledResponse(): Promise<Response> {
|
|
|
118
112
|
))
|
|
119
113
|
}
|
|
120
114
|
|
|
121
|
-
function normalizeSearch(value: string | undefined): string | null {
|
|
122
|
-
if (typeof value !== 'string') return null
|
|
123
|
-
const trimmed = value.trim().toLowerCase()
|
|
124
|
-
return trimmed.length > 0 ? trimmed : null
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function sortTodoRows(rows: CustomerTodoRow[]): CustomerTodoRow[] {
|
|
128
|
-
return [...rows].sort((left, right) => {
|
|
129
|
-
const leftTime = new Date(left.createdAt).getTime()
|
|
130
|
-
const rightTime = new Date(right.createdAt).getTime()
|
|
131
|
-
if (leftTime === rightTime) {
|
|
132
|
-
return right.id.localeCompare(left.id)
|
|
133
|
-
}
|
|
134
|
-
return rightTime - leftTime
|
|
135
|
-
})
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function filterTodoRows(rows: CustomerTodoRow[], search: string | null): CustomerTodoRow[] {
|
|
139
|
-
if (!search) return rows
|
|
140
|
-
return rows.filter((row) => {
|
|
141
|
-
const haystack = [
|
|
142
|
-
row.customer.displayName,
|
|
143
|
-
row.todoTitle,
|
|
144
|
-
row.todoDescription,
|
|
145
|
-
]
|
|
146
|
-
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
|
147
|
-
.join(' ')
|
|
148
|
-
.toLowerCase()
|
|
149
|
-
return haystack.includes(search)
|
|
150
|
-
})
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function paginateTodoRows(
|
|
154
|
-
rows: CustomerTodoRow[],
|
|
155
|
-
page: number,
|
|
156
|
-
pageSize: number,
|
|
157
|
-
exportAll: boolean,
|
|
158
|
-
): { items: CustomerTodoRow[]; total: number; page: number; pageSize: number; totalPages: number } {
|
|
159
|
-
const total = rows.length
|
|
160
|
-
if (exportAll) {
|
|
161
|
-
return {
|
|
162
|
-
items: rows,
|
|
163
|
-
total,
|
|
164
|
-
page: 1,
|
|
165
|
-
pageSize: total,
|
|
166
|
-
totalPages: 1,
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
const start = (page - 1) * pageSize
|
|
170
|
-
return {
|
|
171
|
-
items: rows.slice(start, start + pageSize),
|
|
172
|
-
total,
|
|
173
|
-
page,
|
|
174
|
-
pageSize,
|
|
175
|
-
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
115
|
function normalizeTodoStatusInput(body: z.infer<typeof todoUpdateBodySchema>): boolean | undefined {
|
|
180
116
|
if (typeof body.isDone === 'boolean') return body.isDone
|
|
181
117
|
if (typeof body.is_done === 'boolean') return body.is_done
|
|
@@ -198,110 +134,6 @@ function collectTodoCustomValues(
|
|
|
198
134
|
return Object.keys(direct).length > 0 ? direct : undefined
|
|
199
135
|
}
|
|
200
136
|
|
|
201
|
-
async function listLegacyTodoRows(
|
|
202
|
-
em: EntityManager,
|
|
203
|
-
queryEngine: QueryEngine,
|
|
204
|
-
tenantId: string,
|
|
205
|
-
organizationIds: string[] | null,
|
|
206
|
-
entityId: string | undefined,
|
|
207
|
-
): Promise<CustomerTodoRow[]> {
|
|
208
|
-
const where: Record<string, unknown> = { tenantId }
|
|
209
|
-
if (organizationIds && organizationIds.length > 0) {
|
|
210
|
-
where.organizationId = { $in: organizationIds }
|
|
211
|
-
}
|
|
212
|
-
if (entityId) {
|
|
213
|
-
where.entity = entityId
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const links = await em.find(CustomerTodoLink, where, {
|
|
217
|
-
populate: ['entity'],
|
|
218
|
-
orderBy: { createdAt: 'desc' },
|
|
219
|
-
})
|
|
220
|
-
const details = await resolveLegacyTodoDetails(
|
|
221
|
-
queryEngine,
|
|
222
|
-
links,
|
|
223
|
-
tenantId,
|
|
224
|
-
organizationIds ?? [],
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
return links.map((link) => {
|
|
228
|
-
const source =
|
|
229
|
-
typeof link.todoSource === 'string' && link.todoSource.trim().length > 0
|
|
230
|
-
? link.todoSource
|
|
231
|
-
: 'example:todo'
|
|
232
|
-
return mapLegacyTodoLinkToRow(
|
|
233
|
-
link,
|
|
234
|
-
details.get(`${source}:${link.todoId}`) ?? null,
|
|
235
|
-
)
|
|
236
|
-
})
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function listCanonicalTodoRows(
|
|
240
|
-
em: EntityManager,
|
|
241
|
-
queryEngine: QueryEngine,
|
|
242
|
-
container: { resolve: (name: string) => unknown },
|
|
243
|
-
auth: { tenantId: string | null; orgId: string | null; sub?: string | null; userId?: string | null; keyId?: string | null },
|
|
244
|
-
selectedOrganizationId: string | null,
|
|
245
|
-
organizationIds: string[] | null,
|
|
246
|
-
query: z.infer<typeof querySchema>,
|
|
247
|
-
options?: { includeDeleted?: boolean; source?: string | string[] | null },
|
|
248
|
-
): Promise<CanonicalTodoListResult> {
|
|
249
|
-
const where: Record<string, unknown> = {
|
|
250
|
-
tenantId: auth.tenantId,
|
|
251
|
-
interactionType: 'task',
|
|
252
|
-
}
|
|
253
|
-
if (!options?.includeDeleted) {
|
|
254
|
-
where.deletedAt = null
|
|
255
|
-
}
|
|
256
|
-
if (organizationIds && organizationIds.length > 0) {
|
|
257
|
-
where.organizationId = { $in: organizationIds }
|
|
258
|
-
}
|
|
259
|
-
if (query.entityId) {
|
|
260
|
-
where.entity = query.entityId
|
|
261
|
-
}
|
|
262
|
-
if (options?.source) {
|
|
263
|
-
where.source = Array.isArray(options.source) ? { $in: options.source } : options.source
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const interactions = await em.find(CustomerInteraction, where, {
|
|
267
|
-
orderBy: { createdAt: 'desc' },
|
|
268
|
-
})
|
|
269
|
-
const activeInteractions = interactions.filter((interaction) => !interaction.deletedAt)
|
|
270
|
-
const hydrated = await hydrateCanonicalInteractions({
|
|
271
|
-
em,
|
|
272
|
-
container,
|
|
273
|
-
auth,
|
|
274
|
-
selectedOrganizationId,
|
|
275
|
-
interactions: activeInteractions,
|
|
276
|
-
})
|
|
277
|
-
const customerIds = Array.from(
|
|
278
|
-
new Set(
|
|
279
|
-
hydrated
|
|
280
|
-
.map((interaction) => interaction.entityId ?? null)
|
|
281
|
-
.filter((value): value is string => !!value),
|
|
282
|
-
),
|
|
283
|
-
)
|
|
284
|
-
const customerSummaries = await loadCustomerSummaries(em, customerIds, auth.tenantId, selectedOrganizationId)
|
|
285
|
-
|
|
286
|
-
const items = hydrated.map((interaction) =>
|
|
287
|
-
mapInteractionRecordToTodoRow(
|
|
288
|
-
interaction,
|
|
289
|
-
interaction.entityId ? customerSummaries.get(interaction.entityId) ?? null : null,
|
|
290
|
-
{
|
|
291
|
-
todoSource:
|
|
292
|
-
interaction.source === CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE
|
|
293
|
-
? CUSTOMER_INTERACTION_TASK_SOURCE
|
|
294
|
-
: CUSTOMER_INTERACTION_TASK_SOURCE,
|
|
295
|
-
},
|
|
296
|
-
),
|
|
297
|
-
)
|
|
298
|
-
|
|
299
|
-
return {
|
|
300
|
-
items,
|
|
301
|
-
bridgeIds: new Set(interactions.map((interaction) => interaction.id)),
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
137
|
async function findLegacyTodoLink(
|
|
306
138
|
em: EntityManager,
|
|
307
139
|
target: { linkId?: string; todoId?: string },
|
|
@@ -335,7 +167,7 @@ async function ensureCanonicalTodoBridge(
|
|
|
335
167
|
const source =
|
|
336
168
|
typeof link.todoSource === 'string' && link.todoSource.trim().length > 0
|
|
337
169
|
? link.todoSource
|
|
338
|
-
:
|
|
170
|
+
: EXAMPLE_TODO_SOURCE
|
|
339
171
|
const detail = detailMap.get(`${source}:${link.todoId}`) ?? null
|
|
340
172
|
const entityId = typeof link.entity === 'string' ? link.entity : link.entity.id
|
|
341
173
|
|
|
@@ -396,29 +228,27 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
396
228
|
}
|
|
397
229
|
const queryEngine = container.resolve('queryEngine') as QueryEngine
|
|
398
230
|
const exportAll = parseBooleanToken(query.all) === true
|
|
399
|
-
const search =
|
|
231
|
+
const search = normalizeTodoSearch(query.search)
|
|
400
232
|
|
|
401
233
|
const mergedRows = flags.unified
|
|
402
234
|
? (await listCanonicalTodoRows(
|
|
403
235
|
em,
|
|
404
|
-
queryEngine,
|
|
405
236
|
container,
|
|
406
237
|
auth,
|
|
407
238
|
selectedOrganizationId,
|
|
408
239
|
organizationIds,
|
|
409
|
-
query,
|
|
240
|
+
{ entityId: query.entityId },
|
|
410
241
|
)).items
|
|
411
242
|
: await Promise.all([
|
|
412
243
|
listLegacyTodoRows(em, queryEngine, auth.tenantId, organizationIds, query.entityId),
|
|
413
244
|
listCanonicalTodoRows(
|
|
414
245
|
em,
|
|
415
|
-
queryEngine,
|
|
416
246
|
container,
|
|
417
247
|
auth,
|
|
418
248
|
selectedOrganizationId,
|
|
419
249
|
organizationIds,
|
|
420
|
-
query,
|
|
421
250
|
{
|
|
251
|
+
entityId: query.entityId,
|
|
422
252
|
includeDeleted: true,
|
|
423
253
|
source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE,
|
|
424
254
|
},
|
|
@@ -732,6 +562,8 @@ const todoItemSchema = z.object({
|
|
|
732
562
|
organizationId: z.string(),
|
|
733
563
|
tenantId: z.string(),
|
|
734
564
|
createdAt: z.string(),
|
|
565
|
+
externalHref: z.string().nullable().optional(),
|
|
566
|
+
_integrations: z.record(z.string(), z.unknown()).optional(),
|
|
735
567
|
customer: z.object({
|
|
736
568
|
id: z.string().nullable(),
|
|
737
569
|
displayName: z.string().nullable(),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const tasksIcon = React.createElement(
|
|
4
|
+
'svg',
|
|
5
|
+
{ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
|
|
6
|
+
React.createElement('rect', { x: 9, y: 3, width: 6, height: 4, rx: 1 }),
|
|
7
|
+
React.createElement('path', { d: 'M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2' }),
|
|
8
|
+
React.createElement('path', { d: 'M9 12h6' }),
|
|
9
|
+
React.createElement('path', { d: 'M9 16h4' }),
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
export const metadata = {
|
|
13
|
+
requireAuth: true,
|
|
14
|
+
requireFeatures: ['customers.interactions.view'],
|
|
15
|
+
pageTitle: 'Customer related tasks',
|
|
16
|
+
pageTitleKey: 'customers.workPlan.customerTodos.page.title',
|
|
17
|
+
pageGroup: 'Customers',
|
|
18
|
+
pageGroupKey: 'customers.nav.group',
|
|
19
|
+
pagePriority: 10,
|
|
20
|
+
pageOrder: 120,
|
|
21
|
+
icon: tasksIcon,
|
|
22
|
+
breadcrumb: [{ label: 'Customer related tasks', labelKey: 'customers.workPlan.customerTodos.page.title' }],
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
2
|
+
import { CustomerTodosTable } from '../../components/CustomerTodosTable'
|
|
3
|
+
|
|
4
|
+
export default function CustomerTasksPage() {
|
|
5
|
+
return (
|
|
6
|
+
<Page>
|
|
7
|
+
<PageBody>
|
|
8
|
+
<CustomerTodosTable />
|
|
9
|
+
</PageBody>
|
|
10
|
+
</Page>
|
|
11
|
+
)
|
|
12
|
+
}
|