@open-mercato/shared 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +4 -1
- package/dist/lib/auth/jwt.js +6 -0
- package/dist/lib/auth/jwt.js.map +2 -2
- package/dist/lib/auth/mfaPendingAccess.js +42 -0
- package/dist/lib/auth/mfaPendingAccess.js.map +7 -0
- package/dist/lib/auth/organizationAccess.js +7 -4
- package/dist/lib/auth/organizationAccess.js.map +2 -2
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +38 -7
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/commands/command-bus.js +6 -1
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +24 -8
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/html/htmlToPlainText.js +16 -0
- package/dist/lib/html/htmlToPlainText.js.map +7 -0
- package/dist/lib/location/countries.js +12 -0
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/openapi/crud.js +4 -1
- package/dist/lib/openapi/crud.js.map +2 -2
- package/dist/lib/query/count-cap.js +11 -0
- package/dist/lib/query/count-cap.js.map +7 -0
- package/dist/lib/query/engine.js +270 -34
- package/dist/lib/query/engine.js.map +3 -3
- package/dist/lib/query/types.js.map +1 -1
- package/dist/lib/queue/dispatchOrigin.js +20 -0
- package/dist/lib/queue/dispatchOrigin.js.map +7 -0
- package/dist/lib/search/config.js +1 -0
- package/dist/lib/search/config.js.map +2 -2
- package/dist/lib/search/entityAccess.js +44 -0
- package/dist/lib/search/entityAccess.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/dist/modules/widgets/component-registry.js.map +2 -2
- package/package.json +10 -3
- package/src/lib/auth/__tests__/jwt.test.ts +13 -0
- package/src/lib/auth/__tests__/mfaPendingAccess.test.ts +69 -0
- package/src/lib/auth/__tests__/organizationAccess.test.ts +36 -1
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/__tests__/server.test.ts +104 -0
- package/src/lib/auth/jwt.ts +17 -0
- package/src/lib/auth/mfaPendingAccess.ts +70 -0
- package/src/lib/auth/organizationAccess.ts +11 -3
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +78 -8
- package/src/lib/commands/__tests__/command-bus.test.ts +31 -0
- package/src/lib/commands/command-bus.ts +8 -1
- package/src/lib/crud/__tests__/crud-factory.test.ts +165 -0
- package/src/lib/crud/factory.ts +33 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/lib/html/__tests__/htmlToPlainText.test.ts +59 -0
- package/src/lib/html/htmlToPlainText.ts +17 -0
- package/src/lib/location/__tests__/countries.test.ts +15 -0
- package/src/lib/location/countries.ts +17 -0
- package/src/lib/openapi/crud.ts +3 -0
- package/src/lib/query/__tests__/count-cap-plan.test.ts +240 -0
- package/src/lib/query/__tests__/count-cap.test.ts +41 -0
- package/src/lib/query/__tests__/engine.count-distinct.test.ts +162 -15
- package/src/lib/query/__tests__/engine.scope-and-or.test.ts +11 -1
- package/src/lib/query/__tests__/engine.test.ts +445 -7
- package/src/lib/query/count-cap.ts +19 -0
- package/src/lib/query/engine.ts +434 -54
- package/src/lib/query/types.ts +15 -0
- package/src/lib/queue/dispatchOrigin.ts +35 -0
- package/src/lib/search/config.ts +10 -0
- package/src/lib/search/entityAccess.ts +132 -0
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +40 -0
- package/src/modules/widgets/component-registry.ts +14 -0
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
13
13
|
import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
|
|
14
14
|
import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
|
|
15
|
+
import {
|
|
16
|
+
registerLoggerExtension,
|
|
17
|
+
resetLoggerExtension,
|
|
18
|
+
type LoggerExtensionRecord,
|
|
19
|
+
} from '@open-mercato/shared/lib/logger'
|
|
20
|
+
import {
|
|
21
|
+
registerTelemetryRuntime,
|
|
22
|
+
resetTelemetryRuntime,
|
|
23
|
+
type TelemetryRuntime,
|
|
24
|
+
} from '@open-mercato/shared/lib/telemetry/runtime'
|
|
15
25
|
import { z } from 'zod'
|
|
16
26
|
|
|
17
27
|
// Keep the real custom-field helpers but spy on the definition loader so we can
|
|
@@ -268,6 +278,28 @@ describe('CRUD Factory', () => {
|
|
|
268
278
|
}))
|
|
269
279
|
})
|
|
270
280
|
|
|
281
|
+
it('GET spreads totalIsCapped only when the engine reports a capped count', async () => {
|
|
282
|
+
queryEngine.query.mockResolvedValueOnce({
|
|
283
|
+
items: [{ id: 'id-1', title: 'A', is_done: false }],
|
|
284
|
+
total: 10_000,
|
|
285
|
+
page: 1,
|
|
286
|
+
pageSize: 10,
|
|
287
|
+
meta: { listCountCapWarning: { entity: 'example.todo', cap: 10_000 } },
|
|
288
|
+
})
|
|
289
|
+
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
|
|
290
|
+
expect(res.status).toBe(200)
|
|
291
|
+
const body = await res.json()
|
|
292
|
+
expect(body.total).toBe(10_000)
|
|
293
|
+
expect(body.totalIsCapped).toBe(true)
|
|
294
|
+
expect(body.meta.listCountCapWarning).toEqual({ entity: 'example.todo', cap: 10_000 })
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('GET omits totalIsCapped entirely for exact totals', async () => {
|
|
298
|
+
const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'))
|
|
299
|
+
const body = await res.json()
|
|
300
|
+
expect('totalIsCapped' in body).toBe(false)
|
|
301
|
+
})
|
|
302
|
+
|
|
271
303
|
const makeDecoratedRoute = () => makeCrudRoute({
|
|
272
304
|
metadata: { GET: { requireAuth: true } },
|
|
273
305
|
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
@@ -1054,6 +1086,7 @@ describe('CRUD Factory', () => {
|
|
|
1054
1086
|
await expect(res.json()).resolves.toEqual({
|
|
1055
1087
|
error: 'Internal server error',
|
|
1056
1088
|
message: 'Something went wrong. Please try again later.',
|
|
1089
|
+
requestId: expect.any(String),
|
|
1057
1090
|
})
|
|
1058
1091
|
})
|
|
1059
1092
|
|
|
@@ -1092,6 +1125,138 @@ describe('CRUD Factory', () => {
|
|
|
1092
1125
|
await expect(res.json()).resolves.toEqual({
|
|
1093
1126
|
error: 'Internal server error',
|
|
1094
1127
|
message: 'Something went wrong. Please try again later.',
|
|
1128
|
+
requestId: expect.any(String),
|
|
1129
|
+
})
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
// Issue #5608 — a generic 500 must carry a requestId the client/support can cite, and
|
|
1133
|
+
// that same id must appear on the server log line so the two can be correlated.
|
|
1134
|
+
describe('generic 500 requestId correlation', () => {
|
|
1135
|
+
const logRecords: LoggerExtensionRecord[] = []
|
|
1136
|
+
const reportError = jest.fn()
|
|
1137
|
+
|
|
1138
|
+
const postWithRequestId = (requestId: string) => interceptorErrorRoute().POST(
|
|
1139
|
+
new Request('http://x/api/example/todos/command', {
|
|
1140
|
+
method: 'POST',
|
|
1141
|
+
body: JSON.stringify({ title: 'A' }),
|
|
1142
|
+
headers: { 'content-type': 'application/json', 'x-request-id': requestId },
|
|
1143
|
+
}),
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
beforeEach(() => {
|
|
1147
|
+
logRecords.length = 0
|
|
1148
|
+
reportError.mockClear()
|
|
1149
|
+
registerLoggerExtension({ emit: (record) => logRecords.push(record) })
|
|
1150
|
+
registerTelemetryRuntime({
|
|
1151
|
+
canUseGlobalTracePropagation: () => false,
|
|
1152
|
+
captureTraceContext: () => ({}),
|
|
1153
|
+
continueTrace: (_carrier, _name, fn) => fn(),
|
|
1154
|
+
recordHttpDuration: () => {},
|
|
1155
|
+
reportError,
|
|
1156
|
+
shutdown: async () => {},
|
|
1157
|
+
} satisfies TelemetryRuntime)
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
afterEach(() => {
|
|
1161
|
+
resetLoggerExtension()
|
|
1162
|
+
resetTelemetryRuntime()
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
it('includes a requestId in the body that matches the server log line', async () => {
|
|
1166
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1167
|
+
|
|
1168
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1169
|
+
const body = await res.json()
|
|
1170
|
+
|
|
1171
|
+
expect(res.status).toBe(500)
|
|
1172
|
+
expect(typeof body.requestId).toBe('string')
|
|
1173
|
+
expect(body.requestId.length).toBeGreaterThan(0)
|
|
1174
|
+
|
|
1175
|
+
const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
|
|
1176
|
+
expect(logRecord?.fields.requestId).toBe(body.requestId)
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
it('echoes the requestId on an x-request-id response header', async () => {
|
|
1180
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1181
|
+
|
|
1182
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1183
|
+
const body = await res.json()
|
|
1184
|
+
|
|
1185
|
+
expect(res.headers.get('x-request-id')).toBe(body.requestId)
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
it('reuses an inbound x-request-id header instead of generating a new one', async () => {
|
|
1189
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1190
|
+
|
|
1191
|
+
const res = await postWithRequestId('req-fixed-123')
|
|
1192
|
+
const body = await res.json()
|
|
1193
|
+
|
|
1194
|
+
expect(res.status).toBe(500)
|
|
1195
|
+
expect(body.requestId).toBe('req-fixed-123')
|
|
1196
|
+
const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
|
|
1197
|
+
expect(logRecord?.fields.requestId).toBe('req-fixed-123')
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
// `Headers.get()` returns '' for an empty or whitespace-only header, which a plain
|
|
1201
|
+
// `?? randomUUID()` would hand straight through as a blank correlation id.
|
|
1202
|
+
it.each([
|
|
1203
|
+
['an empty inbound header', ''],
|
|
1204
|
+
['a whitespace-only inbound header', ' '],
|
|
1205
|
+
])('generates a fresh id for %s', async (_label, inbound) => {
|
|
1206
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1207
|
+
|
|
1208
|
+
const res = await postWithRequestId(inbound)
|
|
1209
|
+
const body = await res.json()
|
|
1210
|
+
|
|
1211
|
+
expect(res.status).toBe(500)
|
|
1212
|
+
expect(typeof body.requestId).toBe('string')
|
|
1213
|
+
expect(body.requestId.length).toBeGreaterThan(0)
|
|
1214
|
+
const logRecord = logRecords.find((record) => record.message === 'Unexpected CRUD error')
|
|
1215
|
+
expect(logRecord?.fields.requestId).toBe(body.requestId)
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
// A caller-controlled id lands verbatim in the unquoted `key=value` log line, so an
|
|
1219
|
+
// over-long one or one carrying spaces/`=` is discarded rather than echoed.
|
|
1220
|
+
it.each([
|
|
1221
|
+
['a value carrying log-field separators', 'a=1 tenantId=victim'],
|
|
1222
|
+
['an over-long value', 'x'.repeat(129)],
|
|
1223
|
+
])('discards %s in favor of a generated id', async (_label, inbound) => {
|
|
1224
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1225
|
+
|
|
1226
|
+
const res = await postWithRequestId(inbound)
|
|
1227
|
+
const body = await res.json()
|
|
1228
|
+
|
|
1229
|
+
expect(res.status).toBe(500)
|
|
1230
|
+
expect(body.requestId).not.toBe(inbound)
|
|
1231
|
+
expect(body.requestId).toMatch(/^[A-Za-z0-9-]{36}$/)
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it('reports the error to telemetry with the same requestId', async () => {
|
|
1235
|
+
commandBus.execute.mockRejectedValue(new Error('boom'))
|
|
1236
|
+
|
|
1237
|
+
const res = await postWithRequestId('req-fixed-123')
|
|
1238
|
+
const body = await res.json()
|
|
1239
|
+
|
|
1240
|
+
expect(body.requestId).toBe('req-fixed-123')
|
|
1241
|
+
expect(reportError).toHaveBeenCalledTimes(1)
|
|
1242
|
+
expect(reportError).toHaveBeenCalledWith(
|
|
1243
|
+
expect.any(Error),
|
|
1244
|
+
{ module: 'crud', attributes: { requestId: 'req-fixed-123', errorName: 'Error' } },
|
|
1245
|
+
)
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
// The 503/422 branches deliberately stay outside this change (issue #5608) — lock that
|
|
1249
|
+
// in so a later refactor cannot quietly widen the correlation id across every branch.
|
|
1250
|
+
it('leaves the interceptor-rejection branch without a requestId', async () => {
|
|
1251
|
+
commandBus.execute.mockRejectedValue(
|
|
1252
|
+
new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
const res = await postWithRequestId('req-fixed-123')
|
|
1256
|
+
|
|
1257
|
+
expect(res.status).toBe(422)
|
|
1258
|
+
await expect(res.json()).resolves.toEqual({ error: 'Missing required fields: VAT id' })
|
|
1259
|
+
expect(res.headers.get('x-request-id')).toBeNull()
|
|
1095
1260
|
})
|
|
1096
1261
|
})
|
|
1097
1262
|
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -75,6 +75,8 @@ import { createGenericOptimisticLockReader } from './optimistic-lock'
|
|
|
75
75
|
import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
|
|
76
76
|
import { createLogger } from '../logger'
|
|
77
77
|
import { isTransientDbError } from '../db/pg-errors'
|
|
78
|
+
import { getTelemetryRuntime } from '../telemetry/runtime'
|
|
79
|
+
import { randomUUID } from 'node:crypto'
|
|
78
80
|
|
|
79
81
|
type RbacServiceLike = {
|
|
80
82
|
getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
|
|
@@ -594,7 +596,19 @@ function attachOperationHeader(res: Response, logEntry: any) {
|
|
|
594
596
|
return res
|
|
595
597
|
}
|
|
596
598
|
|
|
597
|
-
|
|
599
|
+
// An inbound `x-request-id` is caller-controlled, so it is only reused when it still
|
|
600
|
+
// looks like an id. `Headers.get()` yields '' for an empty or whitespace-only header —
|
|
601
|
+
// which `??` would not replace — and an unbounded value carrying spaces or `=` would
|
|
602
|
+
// forge fields in the unquoted `key=value` log line this id exists to be read from.
|
|
603
|
+
const INBOUND_REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/
|
|
604
|
+
|
|
605
|
+
function resolveRequestId(request?: Request): string {
|
|
606
|
+
const inbound = request?.headers.get('x-request-id')?.trim()
|
|
607
|
+
if (inbound && INBOUND_REQUEST_ID_PATTERN.test(inbound)) return inbound
|
|
608
|
+
return randomUUID()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function handleError(err: unknown, request?: Request): Response {
|
|
598
612
|
if (err instanceof Response) return err
|
|
599
613
|
if (isCrudHttpError(err)) return json(err.body, { status: err.status })
|
|
600
614
|
// A command interceptor that blocked with an explicit status is a deliberate business
|
|
@@ -618,14 +632,25 @@ function handleError(err: unknown): Response {
|
|
|
618
632
|
)
|
|
619
633
|
}
|
|
620
634
|
|
|
635
|
+
// Unexpected exceptions still collapse into a generic 500 for the client (no internal
|
|
636
|
+
// detail leaked), but a requestId ties that response to this log line and to whatever
|
|
637
|
+
// reaches APM, so a client/support ticket citing it can be correlated with server-side
|
|
638
|
+
// detail (issue #5608).
|
|
621
639
|
const message = err instanceof Error ? err.message : undefined
|
|
622
640
|
const stack = err instanceof Error ? err.stack : undefined
|
|
623
|
-
|
|
641
|
+
const errorName = err instanceof Error ? err.name : undefined
|
|
642
|
+
const requestId = resolveRequestId(request)
|
|
643
|
+
logger.error('Unexpected CRUD error', { message, stack, err, requestId })
|
|
644
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
645
|
+
module: 'crud',
|
|
646
|
+
attributes: { requestId, errorName },
|
|
647
|
+
})
|
|
624
648
|
const body: Record<string, unknown> = {
|
|
625
649
|
error: 'Internal server error',
|
|
626
650
|
message: 'Something went wrong. Please try again later.',
|
|
651
|
+
requestId,
|
|
627
652
|
}
|
|
628
|
-
return json(body, { status: 500 })
|
|
653
|
+
return json(body, { status: 500, headers: { 'x-request-id': requestId } })
|
|
629
654
|
}
|
|
630
655
|
|
|
631
656
|
const LIFECYCLE_ACTION_MAP: Record<string, { before: string; after: string }> = {
|
|
@@ -1941,6 +1966,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1941
1966
|
page: page.page || requestedPage,
|
|
1942
1967
|
pageSize: page.pageSize || requestedPageSize,
|
|
1943
1968
|
totalPages: Math.ceil(res.total / (Number(page.pageSize) || 1)),
|
|
1969
|
+
...(res.meta?.listCountCapWarning ? { totalIsCapped: true } : {}),
|
|
1944
1970
|
...(res.meta ? { meta: res.meta } : {}),
|
|
1945
1971
|
}
|
|
1946
1972
|
await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
|
|
@@ -2162,7 +2188,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2162
2188
|
return response
|
|
2163
2189
|
} catch (e) {
|
|
2164
2190
|
finishProfile({ result: 'error' })
|
|
2165
|
-
return handleError(e)
|
|
2191
|
+
return handleError(e, request)
|
|
2166
2192
|
}
|
|
2167
2193
|
}
|
|
2168
2194
|
|
|
@@ -2476,7 +2502,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2476
2502
|
payload = await enrichSingleRecord(payload, ctx)
|
|
2477
2503
|
return json(payload, { status: 201 })
|
|
2478
2504
|
} catch (e) {
|
|
2479
|
-
return handleError(e)
|
|
2505
|
+
return handleError(e, request)
|
|
2480
2506
|
}
|
|
2481
2507
|
}
|
|
2482
2508
|
|
|
@@ -2814,7 +2840,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2814
2840
|
}
|
|
2815
2841
|
return json(payload)
|
|
2816
2842
|
} catch (e) {
|
|
2817
|
-
return handleError(e)
|
|
2843
|
+
return handleError(e, request)
|
|
2818
2844
|
}
|
|
2819
2845
|
}
|
|
2820
2846
|
|
|
@@ -3103,7 +3129,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
3103
3129
|
}
|
|
3104
3130
|
return json(payload)
|
|
3105
3131
|
} catch (e) {
|
|
3106
|
-
return handleError(e)
|
|
3132
|
+
return handleError(e, request)
|
|
3107
3133
|
}
|
|
3108
3134
|
}
|
|
3109
3135
|
|
|
@@ -68,7 +68,15 @@ describe('DataEngine event contract validation (issue #1421)', () => {
|
|
|
68
68
|
})
|
|
69
69
|
|
|
70
70
|
expect(emitted).toEqual([
|
|
71
|
-
expect.objectContaining({
|
|
71
|
+
expect.objectContaining({
|
|
72
|
+
name: 'issue1421_test.widget.created',
|
|
73
|
+
options: {
|
|
74
|
+
persistent: false,
|
|
75
|
+
tenantId: 'tenant-1',
|
|
76
|
+
organizationId: 'org-1',
|
|
77
|
+
emitterModuleId: 'issue1421_test',
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
72
80
|
])
|
|
73
81
|
expect(loggerWarn).not.toHaveBeenCalled()
|
|
74
82
|
} finally {
|
package/src/lib/data/engine.ts
CHANGED
|
@@ -226,7 +226,12 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
226
226
|
const eventName = `${mod}.${ent}.updated`
|
|
227
227
|
warnIfUndeclaredEvent(eventName, 'setCustomFields')
|
|
228
228
|
try {
|
|
229
|
-
await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {
|
|
229
|
+
await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {
|
|
230
|
+
persistent: true,
|
|
231
|
+
tenantId,
|
|
232
|
+
organizationId,
|
|
233
|
+
emitterModuleId: mod,
|
|
234
|
+
})
|
|
230
235
|
} catch {
|
|
231
236
|
// non-blocking
|
|
232
237
|
}
|
|
@@ -627,6 +632,7 @@ export class DefaultDataEngine implements DataEngine {
|
|
|
627
632
|
persistent: !!events.persistent,
|
|
628
633
|
tenantId: ctx.identifiers.tenantId ?? null,
|
|
629
634
|
organizationId: ctx.identifiers.organizationId ?? null,
|
|
635
|
+
emitterModuleId: events.module,
|
|
630
636
|
})
|
|
631
637
|
} catch {
|
|
632
638
|
// non-blocking
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { htmlToPlainText } from '../htmlToPlainText'
|
|
2
|
+
|
|
3
|
+
describe('htmlToPlainText', () => {
|
|
4
|
+
it('returns an empty string for empty input without invoking the parser', () => {
|
|
5
|
+
expect(htmlToPlainText('')).toBe('')
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('renders block-level markup as readable prose', () => {
|
|
9
|
+
const html = '<html><body><p>Hello,</p><p>second paragraph.</p></body></html>'
|
|
10
|
+
|
|
11
|
+
const text = htmlToPlainText(html)
|
|
12
|
+
|
|
13
|
+
expect(text).toContain('Hello,')
|
|
14
|
+
expect(text).toContain('second paragraph.')
|
|
15
|
+
expect(text).not.toContain('<p>')
|
|
16
|
+
expect(text).not.toContain('</body>')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('drops script and style content instead of inlining it as text', () => {
|
|
20
|
+
// Email HTML routinely carries a <style> block and a tracking <script>.
|
|
21
|
+
// Rendering either as text is what made ingested bodies unreadable.
|
|
22
|
+
const html = [
|
|
23
|
+
'<!DOCTYPE html><html><head>',
|
|
24
|
+
'<style>body { margin: 0; font-family: Helvetica; }</style>',
|
|
25
|
+
'<script>trackOpen("beacon")</script>',
|
|
26
|
+
'</head><body><p>Visible copy</p></body></html>',
|
|
27
|
+
].join('')
|
|
28
|
+
|
|
29
|
+
const text = htmlToPlainText(html)
|
|
30
|
+
|
|
31
|
+
expect(text).toBe('Visible copy')
|
|
32
|
+
expect(text).not.toContain('font-family')
|
|
33
|
+
expect(text).not.toContain('trackOpen')
|
|
34
|
+
expect(text).not.toContain('<!DOCTYPE')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('normalizes CRLF and lone CR line endings to LF', () => {
|
|
38
|
+
const text = htmlToPlainText('<pre>first\r\nsecond\rthird</pre>')
|
|
39
|
+
|
|
40
|
+
expect(text).not.toContain('\r')
|
|
41
|
+
expect(text).toBe('first\nsecond\nthird')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('collapses runs of three or more blank lines down to one', () => {
|
|
45
|
+
const text = htmlToPlainText('<pre>top\n\n\n\n\nbottom</pre>')
|
|
46
|
+
|
|
47
|
+
expect(text).toBe('top\n\nbottom')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('preserves a single blank line between paragraphs', () => {
|
|
51
|
+
const text = htmlToPlainText('<pre>top\n\nbottom</pre>')
|
|
52
|
+
|
|
53
|
+
expect(text).toBe('top\n\nbottom')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('trims surrounding whitespace', () => {
|
|
57
|
+
expect(htmlToPlainText('<pre>\n\n padded \n\n</pre>')).toBe('padded')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { convert } from 'html-to-text'
|
|
2
|
+
|
|
3
|
+
export function htmlToPlainText(html: string): string {
|
|
4
|
+
if (!html) return ''
|
|
5
|
+
return convert(html, {
|
|
6
|
+
wordwrap: false,
|
|
7
|
+
preserveNewlines: true,
|
|
8
|
+
selectors: [
|
|
9
|
+
{ selector: 'script', format: 'skip' },
|
|
10
|
+
{ selector: 'style', format: 'skip' },
|
|
11
|
+
],
|
|
12
|
+
})
|
|
13
|
+
.replace(/\r\n/g, '\n')
|
|
14
|
+
.replace(/\r/g, '\n')
|
|
15
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
16
|
+
.trim()
|
|
17
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
COUNTRY_PRIORITY,
|
|
3
3
|
ISO_COUNTRIES,
|
|
4
4
|
buildCountryOptions,
|
|
5
|
+
matchCountryCodes,
|
|
5
6
|
resolveCountryName,
|
|
6
7
|
} from '../countries'
|
|
7
8
|
|
|
@@ -80,3 +81,17 @@ describe('buildCountryOptions', () => {
|
|
|
80
81
|
expect(options).toEqual(expect.arrayContaining([{ code: 'XK', label: 'Kosovo (XK)' }]))
|
|
81
82
|
})
|
|
82
83
|
})
|
|
84
|
+
|
|
85
|
+
describe('matchCountryCodes', () => {
|
|
86
|
+
it('maps English country names to ISO codes so list search can find stored PL rows', () => {
|
|
87
|
+
expect(matchCountryCodes('Poland')).toContain('PL')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('maps localized names when extra locales are provided', () => {
|
|
91
|
+
expect(matchCountryCodes('Polska', { locales: ['pl'] })).toContain('PL')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns nothing for blank search terms', () => {
|
|
95
|
+
expect(matchCountryCodes(' ')).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -106,3 +106,20 @@ export function buildCountryOptions(options: {
|
|
|
106
106
|
|
|
107
107
|
return [...prioritized, ...remaining]
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
export function matchCountryCodes(
|
|
111
|
+
term: string,
|
|
112
|
+
options: { locales?: string[] } = {},
|
|
113
|
+
): string[] {
|
|
114
|
+
const needle = term.trim().toLowerCase()
|
|
115
|
+
if (!needle) return []
|
|
116
|
+
const searchLocales = options.locales?.length ? options.locales : ['en']
|
|
117
|
+
return ISO_COUNTRIES
|
|
118
|
+
.filter((entry) => {
|
|
119
|
+
if (entry.name.toLowerCase().includes(needle)) return true
|
|
120
|
+
return searchLocales.some((locale) =>
|
|
121
|
+
resolveCountryName(entry.code, { locale }).toLowerCase().includes(needle),
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
.map((entry) => entry.code)
|
|
125
|
+
}
|
package/src/lib/openapi/crud.ts
CHANGED
|
@@ -17,6 +17,9 @@ export function createPagedListResponseSchema(itemSchema: ZodTypeAny, options: P
|
|
|
17
17
|
page: paginationMetaOptional ? z.number().optional() : z.number(),
|
|
18
18
|
pageSize: paginationMetaOptional ? z.number().optional() : z.number(),
|
|
19
19
|
totalPages: z.number(),
|
|
20
|
+
// Present (true) only when the list count was bounded at OM_LIST_COUNT_CAP:
|
|
21
|
+
// `total` is then a floor, not an exact value.
|
|
22
|
+
totalIsCapped: z.boolean().optional(),
|
|
20
23
|
})
|
|
21
24
|
}
|
|
22
25
|
|