@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.
Files changed (60) hide show
  1. package/dist/modules/customers/api/companies/[id]/route.js +3 -2
  2. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  3. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js +59 -91
  4. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js.map +2 -2
  5. package/dist/modules/customers/api/interactions/tasks/route.js +115 -0
  6. package/dist/modules/customers/api/interactions/tasks/route.js.map +7 -0
  7. package/dist/modules/customers/api/people/[id]/route.js +3 -2
  8. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  9. package/dist/modules/customers/api/todos/route.js +14 -134
  10. package/dist/modules/customers/api/todos/route.js.map +2 -2
  11. package/dist/modules/customers/backend/customer-tasks/page.js +10 -0
  12. package/dist/modules/customers/backend/customer-tasks/page.js.map +7 -0
  13. package/dist/modules/customers/backend/customer-tasks/page.meta.js +25 -0
  14. package/dist/modules/customers/backend/customer-tasks/page.meta.js.map +7 -0
  15. package/dist/modules/customers/commands/interactions.js +40 -4
  16. package/dist/modules/customers/commands/interactions.js.map +2 -2
  17. package/dist/modules/customers/components/CustomerTodosTable.js +77 -47
  18. package/dist/modules/customers/components/CustomerTodosTable.js.map +2 -2
  19. package/dist/modules/customers/components/detail/TasksSection.js +4 -4
  20. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  21. package/dist/modules/customers/components/detail/hooks/usePersonTasks.js +2 -1
  22. package/dist/modules/customers/components/detail/hooks/usePersonTasks.js.map +2 -2
  23. package/dist/modules/customers/components/detail/utils.js +3 -0
  24. package/dist/modules/customers/components/detail/utils.js.map +2 -2
  25. package/dist/modules/customers/data/entities.js +2 -2
  26. package/dist/modules/customers/data/entities.js.map +2 -2
  27. package/dist/modules/customers/data/validators.js +2 -2
  28. package/dist/modules/customers/data/validators.js.map +2 -2
  29. package/dist/modules/customers/lib/interactionCompatibility.js +12 -2
  30. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  31. package/dist/modules/customers/lib/todoCompatibility.js +167 -4
  32. package/dist/modules/customers/lib/todoCompatibility.js.map +2 -2
  33. package/dist/modules/customers/migrations/Migration20260401172819.js +45 -0
  34. package/dist/modules/customers/migrations/Migration20260401172819.js.map +7 -0
  35. package/dist/modules/customers/search.js +3 -2
  36. package/dist/modules/customers/search.js.map +2 -2
  37. package/dist/modules/customers/widgets/dashboard/customer-todos/widget.client.js +10 -2
  38. package/dist/modules/customers/widgets/dashboard/customer-todos/widget.client.js.map +2 -2
  39. package/package.json +3 -3
  40. package/src/modules/customers/api/companies/[id]/route.ts +6 -5
  41. package/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts +69 -126
  42. package/src/modules/customers/api/interactions/tasks/route.ts +122 -0
  43. package/src/modules/customers/api/people/[id]/route.ts +3 -2
  44. package/src/modules/customers/api/todos/route.ts +13 -181
  45. package/src/modules/customers/backend/customer-tasks/page.meta.ts +23 -0
  46. package/src/modules/customers/backend/customer-tasks/page.tsx +12 -0
  47. package/src/modules/customers/commands/interactions.ts +50 -2
  48. package/src/modules/customers/components/CustomerTodosTable.tsx +91 -66
  49. package/src/modules/customers/components/detail/TasksSection.tsx +8 -8
  50. package/src/modules/customers/components/detail/hooks/usePersonTasks.ts +2 -1
  51. package/src/modules/customers/components/detail/types.ts +6 -0
  52. package/src/modules/customers/components/detail/utils.ts +3 -0
  53. package/src/modules/customers/data/entities.ts +2 -2
  54. package/src/modules/customers/data/validators.ts +2 -2
  55. package/src/modules/customers/lib/interactionCompatibility.ts +16 -0
  56. package/src/modules/customers/lib/todoCompatibility.ts +229 -10
  57. package/src/modules/customers/migrations/.snapshot-open-mercato.json +1 -1
  58. package/src/modules/customers/migrations/Migration20260401172819.ts +45 -0
  59. package/src/modules/customers/search.ts +3 -2
  60. 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 type { FilterQuery } from '@mikro-orm/core'
10
- import type { EntityId } from '@open-mercato/shared/modules/entities'
11
- import { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber'
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 whereOrganization = Array.isArray(organizationIds)
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
- organizationId: organizationIds?.[0] ?? null,
149
- })
150
-
151
- const queryEngine = (container.resolve('queryEngine') as QueryEngine)
152
- const todoSummaries = await resolveTodoSummaries(queryEngine, links, tenantId, organizationIds)
153
-
154
- const items = links.map((link) => {
155
- const entity = link.entity
156
- const entityRecord = entity && typeof entity !== 'string' ? (entity as CustomerEntity) : null
157
- const todoKey = `${link.todoSource}:${link.todoId}`
158
- const summary = todoSummaries.get(todoKey) ?? null
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: link.id,
161
- todoId: link.todoId,
162
- todoSource: link.todoSource,
163
- todoTitle: summary?.title ?? null,
164
- createdAt: link.createdAt.toISOString(),
165
- organizationId: link.organizationId,
166
- entity: entityRecord
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: entityRecord.id,
169
- displayName: entityRecord.displayName,
170
- kind: entityRecord.kind,
171
- ownerUserId: entityRecord.ownerUserId,
110
+ id: entity.id,
111
+ displayName: entity.displayName ?? null,
112
+ kind: entity.kind ?? null,
113
+ ownerUserId: null,
172
114
  }
173
115
  : {
174
- id: typeof entity === 'string' ? entity : null,
175
- displayName: null,
176
- kind: null,
177
- ownerUserId: null,
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 todo links',
226
- description: 'Returns the most recently created todo links for display on dashboards.',
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 : 'example:todo'
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 : 'example:todo'
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
- hydrateCanonicalInteractions,
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
- type CustomerTodoRow,
34
- mapInteractionRecordToTodoRow,
35
- mapLegacyTodoLinkToRow,
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
- : 'example:todo'
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 = normalizeSearch(query.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
+ }