@open-mercato/shared 0.4.2-canary-c02407ff85
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/build.mjs +101 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/api/crud.js +47 -0
- package/dist/lib/api/crud.js.map +7 -0
- package/dist/lib/api/scoped.js +140 -0
- package/dist/lib/api/scoped.js.map +7 -0
- package/dist/lib/auth/jwt.js +34 -0
- package/dist/lib/auth/jwt.js.map +7 -0
- package/dist/lib/auth/server.js +157 -0
- package/dist/lib/auth/server.js.map +7 -0
- package/dist/lib/boolean.js +22 -0
- package/dist/lib/boolean.js.map +7 -0
- package/dist/lib/bootstrap/appResolver.js +43 -0
- package/dist/lib/bootstrap/appResolver.js.map +7 -0
- package/dist/lib/bootstrap/dynamicLoader.js +108 -0
- package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
- package/dist/lib/bootstrap/factory.js +59 -0
- package/dist/lib/bootstrap/factory.js.map +7 -0
- package/dist/lib/bootstrap/index.js +11 -0
- package/dist/lib/bootstrap/index.js.map +7 -0
- package/dist/lib/bootstrap/types.js +1 -0
- package/dist/lib/bootstrap/types.js.map +7 -0
- package/dist/lib/cache/segments.js +36 -0
- package/dist/lib/cache/segments.js.map +7 -0
- package/dist/lib/cli/progress.js +46 -0
- package/dist/lib/cli/progress.js.map +7 -0
- package/dist/lib/commands/command-bus.js +285 -0
- package/dist/lib/commands/command-bus.js.map +7 -0
- package/dist/lib/commands/customFieldSnapshots.js +66 -0
- package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
- package/dist/lib/commands/helpers.js +98 -0
- package/dist/lib/commands/helpers.js.map +7 -0
- package/dist/lib/commands/index.js +8 -0
- package/dist/lib/commands/index.js.map +7 -0
- package/dist/lib/commands/operationMetadata.js +32 -0
- package/dist/lib/commands/operationMetadata.js.map +7 -0
- package/dist/lib/commands/registry.js +43 -0
- package/dist/lib/commands/registry.js.map +7 -0
- package/dist/lib/commands/scope.js +44 -0
- package/dist/lib/commands/scope.js.map +7 -0
- package/dist/lib/commands/types.js +8 -0
- package/dist/lib/commands/types.js.map +7 -0
- package/dist/lib/crud/cache-stats.js +98 -0
- package/dist/lib/crud/cache-stats.js.map +7 -0
- package/dist/lib/crud/cache.js +175 -0
- package/dist/lib/crud/cache.js.map +7 -0
- package/dist/lib/crud/custom-fields-client.js +52 -0
- package/dist/lib/crud/custom-fields-client.js.map +7 -0
- package/dist/lib/crud/custom-fields.js +467 -0
- package/dist/lib/crud/custom-fields.js.map +7 -0
- package/dist/lib/crud/errors.js +24 -0
- package/dist/lib/crud/errors.js.map +7 -0
- package/dist/lib/crud/exporters.js +154 -0
- package/dist/lib/crud/exporters.js.map +7 -0
- package/dist/lib/crud/factory.js +1311 -0
- package/dist/lib/crud/factory.js.map +7 -0
- package/dist/lib/crud/types.js +1 -0
- package/dist/lib/crud/types.js.map +7 -0
- package/dist/lib/custom-fields/normalize.js +36 -0
- package/dist/lib/custom-fields/normalize.js.map +7 -0
- package/dist/lib/data/engine.js +396 -0
- package/dist/lib/data/engine.js.map +7 -0
- package/dist/lib/db/escapeLikePattern.js +5 -0
- package/dist/lib/db/escapeLikePattern.js.map +7 -0
- package/dist/lib/db/mikro.js +82 -0
- package/dist/lib/db/mikro.js.map +7 -0
- package/dist/lib/di/container.js +94 -0
- package/dist/lib/di/container.js.map +7 -0
- package/dist/lib/email/send.js +12 -0
- package/dist/lib/email/send.js.map +7 -0
- package/dist/lib/encryption/aes.js +58 -0
- package/dist/lib/encryption/aes.js.map +7 -0
- package/dist/lib/encryption/customFieldValues.js +49 -0
- package/dist/lib/encryption/customFieldValues.js.map +7 -0
- package/dist/lib/encryption/entityFields.js +26 -0
- package/dist/lib/encryption/entityFields.js.map +7 -0
- package/dist/lib/encryption/entityIds.js +80 -0
- package/dist/lib/encryption/entityIds.js.map +7 -0
- package/dist/lib/encryption/find.js +45 -0
- package/dist/lib/encryption/find.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +69 -0
- package/dist/lib/encryption/indexDoc.js.map +7 -0
- package/dist/lib/encryption/kms.js +282 -0
- package/dist/lib/encryption/kms.js.map +7 -0
- package/dist/lib/encryption/subscriber.js +330 -0
- package/dist/lib/encryption/subscriber.js.map +7 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
- package/dist/lib/encryption/toggles.js +18 -0
- package/dist/lib/encryption/toggles.js.map +7 -0
- package/dist/lib/entities/naming.js +9 -0
- package/dist/lib/entities/naming.js.map +7 -0
- package/dist/lib/entities/system-entities.js +43 -0
- package/dist/lib/entities/system-entities.js.map +7 -0
- package/dist/lib/frontend/organizationEvents.js +41 -0
- package/dist/lib/frontend/organizationEvents.js.map +7 -0
- package/dist/lib/frontend/useOrganizationScope.js +32 -0
- package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
- package/dist/lib/hotkeys/index.js +128 -0
- package/dist/lib/hotkeys/index.js.map +7 -0
- package/dist/lib/i18n/app-dictionaries.js +17 -0
- package/dist/lib/i18n/app-dictionaries.js.map +7 -0
- package/dist/lib/i18n/config.js +7 -0
- package/dist/lib/i18n/config.js.map +7 -0
- package/dist/lib/i18n/context.js +50 -0
- package/dist/lib/i18n/context.js.map +7 -0
- package/dist/lib/i18n/server.js +68 -0
- package/dist/lib/i18n/server.js.map +7 -0
- package/dist/lib/i18n/translate.js +45 -0
- package/dist/lib/i18n/translate.js.map +7 -0
- package/dist/lib/indexers/error-log.js +82 -0
- package/dist/lib/indexers/error-log.js.map +7 -0
- package/dist/lib/indexers/status-log.js +80 -0
- package/dist/lib/indexers/status-log.js.map +7 -0
- package/dist/lib/lib/auth/jwt.js +34 -0
- package/dist/lib/lib/auth/jwt.js.map +7 -0
- package/dist/lib/lib/auth/server.js +77 -0
- package/dist/lib/lib/auth/server.js.map +7 -0
- package/dist/lib/lib/email/send.js +12 -0
- package/dist/lib/lib/email/send.js.map +7 -0
- package/dist/lib/lib/i18n/config.js +7 -0
- package/dist/lib/lib/i18n/config.js.map +7 -0
- package/dist/lib/lib/i18n/context.js +31 -0
- package/dist/lib/lib/i18n/context.js.map +7 -0
- package/dist/lib/lib/utils.js +9 -0
- package/dist/lib/lib/utils.js.map +7 -0
- package/dist/lib/location/countries.js +68 -0
- package/dist/lib/location/countries.js.map +7 -0
- package/dist/lib/modules/index.js +6 -0
- package/dist/lib/modules/index.js.map +7 -0
- package/dist/lib/modules/registry.js +18 -0
- package/dist/lib/modules/registry.js.map +7 -0
- package/dist/lib/openapi/crud.js +137 -0
- package/dist/lib/openapi/crud.js.map +7 -0
- package/dist/lib/openapi/generator.js +1131 -0
- package/dist/lib/openapi/generator.js.map +7 -0
- package/dist/lib/openapi/index.js +10 -0
- package/dist/lib/openapi/index.js.map +7 -0
- package/dist/lib/openapi/sanitize.js +110 -0
- package/dist/lib/openapi/sanitize.js.map +7 -0
- package/dist/lib/openapi/types.js +1 -0
- package/dist/lib/openapi/types.js.map +7 -0
- package/dist/lib/profiler/index.js +258 -0
- package/dist/lib/profiler/index.js.map +7 -0
- package/dist/lib/query/engine.js +729 -0
- package/dist/lib/query/engine.js.map +7 -0
- package/dist/lib/query/join-utils.js +195 -0
- package/dist/lib/query/join-utils.js.map +7 -0
- package/dist/lib/query/types.js +9 -0
- package/dist/lib/query/types.js.map +7 -0
- package/dist/lib/search/config.js +32 -0
- package/dist/lib/search/config.js.map +7 -0
- package/dist/lib/search/tokenize.js +34 -0
- package/dist/lib/search/tokenize.js.map +7 -0
- package/dist/lib/slugify.js +24 -0
- package/dist/lib/slugify.js.map +7 -0
- package/dist/lib/testing/bootstrap.js +51 -0
- package/dist/lib/testing/bootstrap.js.map +7 -0
- package/dist/lib/testing/index.js +17 -0
- package/dist/lib/testing/index.js.map +7 -0
- package/dist/lib/testing/renderWithProviders.js +15 -0
- package/dist/lib/testing/renderWithProviders.js.map +7 -0
- package/dist/lib/url.js +12 -0
- package/dist/lib/url.js.map +7 -0
- package/dist/lib/utils.js +13 -0
- package/dist/lib/utils.js.map +7 -0
- package/dist/lib/version.js +7 -0
- package/dist/lib/version.js.map +7 -0
- package/dist/modules/dashboard/widgets.js +1 -0
- package/dist/modules/dashboard/widgets.js.map +7 -0
- package/dist/modules/dsl.js +30 -0
- package/dist/modules/dsl.js.map +7 -0
- package/dist/modules/entities/kinds.js +22 -0
- package/dist/modules/entities/kinds.js.map +7 -0
- package/dist/modules/entities/options.js +26 -0
- package/dist/modules/entities/options.js.map +7 -0
- package/dist/modules/entities/validation.js +102 -0
- package/dist/modules/entities/validation.js.map +7 -0
- package/dist/modules/entities/validators.js +88 -0
- package/dist/modules/entities/validators.js.map +7 -0
- package/dist/modules/entities.js +1 -0
- package/dist/modules/entities.js.map +7 -0
- package/dist/modules/navigation/sidebarPreferences.js +50 -0
- package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
- package/dist/modules/perspectives/types.js +1 -0
- package/dist/modules/perspectives/types.js.map +7 -0
- package/dist/modules/registry.js +96 -0
- package/dist/modules/registry.js.map +7 -0
- package/dist/modules/search.js +15 -0
- package/dist/modules/search.js.map +7 -0
- package/dist/modules/vector.js +1 -0
- package/dist/modules/vector.js.map +7 -0
- package/dist/modules/widgets/injection-loader.js +180 -0
- package/dist/modules/widgets/injection-loader.js.map +7 -0
- package/dist/modules/widgets/injection.js +1 -0
- package/dist/modules/widgets/injection.js.map +7 -0
- package/dist/security/features.js +23 -0
- package/dist/security/features.js.map +7 -0
- package/dist/types/pg.d.js +1 -0
- package/dist/types/pg.d.js.map +7 -0
- package/dist/types/react-email.d.js +1 -0
- package/dist/types/react-email.d.js.map +7 -0
- package/dist/types/resend.d.js +1 -0
- package/dist/types/resend.d.js.map +7 -0
- package/jest.config.cjs +22 -0
- package/package.json +88 -0
- package/src/index.ts +0 -0
- package/src/lib/api/__tests__/scoped.test.ts +38 -0
- package/src/lib/api/crud.ts +59 -0
- package/src/lib/api/scoped.ts +239 -0
- package/src/lib/auth/jwt.ts +39 -0
- package/src/lib/auth/server.ts +199 -0
- package/src/lib/boolean.ts +17 -0
- package/src/lib/bootstrap/appResolver.ts +85 -0
- package/src/lib/bootstrap/dynamicLoader.ts +177 -0
- package/src/lib/bootstrap/factory.ts +108 -0
- package/src/lib/bootstrap/index.ts +23 -0
- package/src/lib/bootstrap/types.ts +31 -0
- package/src/lib/cache/segments.ts +56 -0
- package/src/lib/cli/progress.ts +55 -0
- package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
- package/src/lib/commands/__tests__/helpers.test.ts +42 -0
- package/src/lib/commands/command-bus.ts +349 -0
- package/src/lib/commands/customFieldSnapshots.ts +86 -0
- package/src/lib/commands/helpers.ts +143 -0
- package/src/lib/commands/index.ts +4 -0
- package/src/lib/commands/operationMetadata.ts +40 -0
- package/src/lib/commands/registry.ts +46 -0
- package/src/lib/commands/scope.ts +59 -0
- package/src/lib/commands/types.ts +63 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
- package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
- package/src/lib/crud/cache-stats.ts +127 -0
- package/src/lib/crud/cache.ts +205 -0
- package/src/lib/crud/custom-fields-client.ts +54 -0
- package/src/lib/crud/custom-fields.ts +607 -0
- package/src/lib/crud/errors.ts +23 -0
- package/src/lib/crud/exporters.ts +188 -0
- package/src/lib/crud/factory.ts +1622 -0
- package/src/lib/crud/types.ts +29 -0
- package/src/lib/custom-fields/normalize.ts +45 -0
- package/src/lib/data/engine.ts +562 -0
- package/src/lib/db/escapeLikePattern.ts +2 -0
- package/src/lib/db/mikro.ts +100 -0
- package/src/lib/di/container.ts +105 -0
- package/src/lib/email/send.ts +18 -0
- package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
- package/src/lib/encryption/aes.ts +64 -0
- package/src/lib/encryption/customFieldValues.ts +67 -0
- package/src/lib/encryption/entityFields.ts +39 -0
- package/src/lib/encryption/entityIds.ts +107 -0
- package/src/lib/encryption/find.ts +81 -0
- package/src/lib/encryption/indexDoc.ts +104 -0
- package/src/lib/encryption/kms.ts +337 -0
- package/src/lib/encryption/subscriber.ts +416 -0
- package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
- package/src/lib/encryption/toggles.ts +15 -0
- package/src/lib/entities/naming.ts +6 -0
- package/src/lib/entities/system-entities.ts +43 -0
- package/src/lib/frontend/organizationEvents.ts +55 -0
- package/src/lib/frontend/useOrganizationScope.ts +30 -0
- package/src/lib/hotkeys/index.ts +168 -0
- package/src/lib/i18n/app-dictionaries.ts +18 -0
- package/src/lib/i18n/config.ts +4 -0
- package/src/lib/i18n/context.tsx +66 -0
- package/src/lib/i18n/server.ts +74 -0
- package/src/lib/i18n/translate.ts +54 -0
- package/src/lib/indexers/error-log.ts +106 -0
- package/src/lib/indexers/status-log.ts +119 -0
- package/src/lib/lib/auth/jwt.ts +39 -0
- package/src/lib/lib/auth/server.ts +94 -0
- package/src/lib/lib/email/send.ts +18 -0
- package/src/lib/lib/i18n/config.ts +4 -0
- package/src/lib/lib/i18n/context.tsx +38 -0
- package/src/lib/lib/utils.ts +6 -0
- package/src/lib/location/countries.ts +97 -0
- package/src/lib/modules/index.ts +1 -0
- package/src/lib/modules/registry.ts +18 -0
- package/src/lib/openapi/crud.ts +218 -0
- package/src/lib/openapi/generator.ts +1311 -0
- package/src/lib/openapi/index.ts +4 -0
- package/src/lib/openapi/sanitize.ts +137 -0
- package/src/lib/openapi/types.ts +79 -0
- package/src/lib/profiler/index.ts +371 -0
- package/src/lib/query/__tests__/engine.test.ts +274 -0
- package/src/lib/query/engine.ts +837 -0
- package/src/lib/query/join-utils.ts +238 -0
- package/src/lib/query/types.ts +121 -0
- package/src/lib/search/config.ts +49 -0
- package/src/lib/search/tokenize.ts +45 -0
- package/src/lib/slugify.ts +28 -0
- package/src/lib/testing/bootstrap.ts +124 -0
- package/src/lib/testing/index.ts +15 -0
- package/src/lib/testing/renderWithProviders.tsx +31 -0
- package/src/lib/url.ts +12 -0
- package/src/lib/utils.ts +17 -0
- package/src/lib/version.ts +5 -0
- package/src/modules/__tests__/dsl.test.ts +35 -0
- package/src/modules/__tests__/registry.test.ts +300 -0
- package/src/modules/dashboard/widgets.ts +57 -0
- package/src/modules/dsl.ts +32 -0
- package/src/modules/entities/__tests__/validation.test.ts +52 -0
- package/src/modules/entities/kinds.ts +20 -0
- package/src/modules/entities/options.ts +36 -0
- package/src/modules/entities/validation.ts +118 -0
- package/src/modules/entities/validators.ts +93 -0
- package/src/modules/entities.ts +102 -0
- package/src/modules/navigation/sidebarPreferences.ts +62 -0
- package/src/modules/perspectives/types.ts +40 -0
- package/src/modules/registry.ts +249 -0
- package/src/modules/search.ts +325 -0
- package/src/modules/vector.ts +122 -0
- package/src/modules/widgets/__tests__/injection.test.ts +48 -0
- package/src/modules/widgets/injection-loader.ts +235 -0
- package/src/modules/widgets/injection.ts +120 -0
- package/src/security/features.ts +22 -0
- package/src/types/pg.d.ts +2 -0
- package/src/types/react-email.d.ts +2 -0
- package/src/types/resend.d.ts +2 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,1622 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { AwilixContainer } from 'awilix'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { buildScopedWhere } from '@open-mercato/shared/lib/api/crud'
|
|
5
|
+
import { getAuthFromCookies, getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
6
|
+
import type { QueryEngine, Where, Sort, Page, QueryCustomFieldSource, QueryJoinEdge } from '@open-mercato/shared/lib/query/types'
|
|
7
|
+
import { SortDir } from '@open-mercato/shared/lib/query/types'
|
|
8
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
9
|
+
import { resolveOrganizationScopeForRequest, type OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
10
|
+
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
11
|
+
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
12
|
+
import type {
|
|
13
|
+
CrudEventAction,
|
|
14
|
+
CrudEventsConfig,
|
|
15
|
+
CrudIndexerConfig,
|
|
16
|
+
CrudIdentifierResolver,
|
|
17
|
+
} from './types'
|
|
18
|
+
import {
|
|
19
|
+
extractCustomFieldValuesFromPayload,
|
|
20
|
+
extractAllCustomFieldEntries,
|
|
21
|
+
decorateRecordWithCustomFields,
|
|
22
|
+
loadCustomFieldDefinitionIndex,
|
|
23
|
+
} from './custom-fields'
|
|
24
|
+
import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
|
|
25
|
+
import { CrudHttpError } from './errors'
|
|
26
|
+
import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
|
|
27
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
28
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
29
|
+
import {
|
|
30
|
+
buildCollectionTags,
|
|
31
|
+
buildRecordTag,
|
|
32
|
+
canonicalizeResourceTag,
|
|
33
|
+
debugCrudCache,
|
|
34
|
+
deriveResourceFromCommandId,
|
|
35
|
+
expandResourceAliases,
|
|
36
|
+
invalidateCrudCache,
|
|
37
|
+
isCrudCacheDebugEnabled,
|
|
38
|
+
isCrudCacheEnabled,
|
|
39
|
+
normalizeIdentifierValue,
|
|
40
|
+
normalizeTagSegment,
|
|
41
|
+
resolveCrudCache,
|
|
42
|
+
} from './cache'
|
|
43
|
+
import { deriveCrudSegmentTag } from './cache-stats'
|
|
44
|
+
import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
|
|
45
|
+
|
|
46
|
+
export type CrudHooks<TCreate, TUpdate, TList> = {
|
|
47
|
+
beforeList?: (q: TList, ctx: CrudCtx) => Promise<void> | void
|
|
48
|
+
afterList?: (res: any, ctx: CrudCtx & { query: TList }) => Promise<void> | void
|
|
49
|
+
beforeCreate?: (input: TCreate, ctx: CrudCtx) => Promise<TCreate | void> | TCreate | void
|
|
50
|
+
afterCreate?: (entity: any, ctx: CrudCtx & { input: TCreate }) => Promise<void> | void
|
|
51
|
+
beforeUpdate?: (input: TUpdate, ctx: CrudCtx) => Promise<TUpdate | void> | TUpdate | void
|
|
52
|
+
afterUpdate?: (entity: any, ctx: CrudCtx & { input: TUpdate }) => Promise<void> | void
|
|
53
|
+
beforeDelete?: (id: string, ctx: CrudCtx) => Promise<void> | void
|
|
54
|
+
afterDelete?: (id: string, ctx: CrudCtx) => Promise<void> | void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type CrudMetadata = {
|
|
58
|
+
GET?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
|
|
59
|
+
POST?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
|
|
60
|
+
PUT?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
|
|
61
|
+
DELETE?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type OrmEntityConfig = {
|
|
65
|
+
entity: any // MikroORM entity class
|
|
66
|
+
idField?: string // default: 'id'
|
|
67
|
+
orgField?: string | null // default: 'organizationId'; pass null to disable automatic org scoping
|
|
68
|
+
tenantField?: string | null // default: 'tenantId'; pass null to disable automatic tenant scoping
|
|
69
|
+
softDeleteField?: string | null // default: 'deletedAt'; pass null to disable implicit soft delete filter
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type CustomFieldsConfig =
|
|
73
|
+
| false
|
|
74
|
+
| {
|
|
75
|
+
enabled: true
|
|
76
|
+
entityId: any // datamodel entity id, e.g. E.example.todo
|
|
77
|
+
// If true, picks body keys starting with `cf_` and maps `cf_<name>` -> `<name>`
|
|
78
|
+
pickPrefixed?: boolean
|
|
79
|
+
// Optional custom mapper; if provided, used instead of pickPrefixed
|
|
80
|
+
map?: (data: Record<string, any>) => Record<string, any>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type CrudListCustomFieldDecorator = {
|
|
84
|
+
entityIds: EntityId | EntityId[]
|
|
85
|
+
resolveContext?: (item: any, ctx: CrudCtx) => { organizationId?: string | null; tenantId?: string | null }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ListConfig<TList> = {
|
|
89
|
+
schema: z.ZodType<TList>
|
|
90
|
+
// Optional: use the QueryEngine when entityId + fields are provided
|
|
91
|
+
entityId?: any
|
|
92
|
+
fields?: any[]
|
|
93
|
+
sortFieldMap?: Record<string, any>
|
|
94
|
+
buildFilters?: (query: TList, ctx: CrudCtx) => Where<any> | Promise<Where<any>>
|
|
95
|
+
transformItem?: (item: any) => any
|
|
96
|
+
allowCsv?: boolean
|
|
97
|
+
csv?: {
|
|
98
|
+
headers: string[]
|
|
99
|
+
row: (item: any) => (string | number | boolean | null | undefined)[]
|
|
100
|
+
filename?: string
|
|
101
|
+
}
|
|
102
|
+
export?: CrudExportOptions
|
|
103
|
+
customFieldSources?: QueryCustomFieldSource[]
|
|
104
|
+
joins?: QueryJoinEdge[]
|
|
105
|
+
decorateCustomFields?: CrudListCustomFieldDecorator
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type CrudExportColumnConfig = {
|
|
109
|
+
field: string
|
|
110
|
+
header?: string
|
|
111
|
+
resolve?: (item: any) => unknown
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type CrudExportOptions = {
|
|
115
|
+
enabled?: boolean
|
|
116
|
+
formats?: CrudExportFormat[]
|
|
117
|
+
filename?: string | ((format: CrudExportFormat) => string)
|
|
118
|
+
columns?: CrudExportColumnConfig[]
|
|
119
|
+
batchSize?: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const DEFAULT_EXPORT_FORMATS: CrudExportFormat[] = ['csv', 'json', 'xml', 'markdown']
|
|
123
|
+
const DEFAULT_EXPORT_BATCH_SIZE = 1000
|
|
124
|
+
const MIN_EXPORT_BATCH_SIZE = 100
|
|
125
|
+
const MAX_EXPORT_BATCH_SIZE = 10000
|
|
126
|
+
|
|
127
|
+
type ColumnResolver = {
|
|
128
|
+
field: string
|
|
129
|
+
header: string
|
|
130
|
+
resolve: (item: any) => unknown
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveAvailableExportFormats(list?: ListConfig<any>): CrudExportFormat[] {
|
|
134
|
+
if (!list) return []
|
|
135
|
+
if (list.export?.enabled === false) return []
|
|
136
|
+
const formats = list.export?.formats && list.export.formats.length > 0
|
|
137
|
+
? [...list.export.formats]
|
|
138
|
+
: [...DEFAULT_EXPORT_FORMATS]
|
|
139
|
+
if (!list.export?.formats && list.allowCsv && !formats.includes('csv')) formats.push('csv')
|
|
140
|
+
return Array.from(new Set(formats))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveExportBatchSize(list: ListConfig<any> | undefined, requestedPageSize: number): number {
|
|
144
|
+
const fallback = Math.max(requestedPageSize, DEFAULT_EXPORT_BATCH_SIZE)
|
|
145
|
+
const raw = list?.export?.batchSize ?? fallback
|
|
146
|
+
return Math.min(Math.max(raw, MIN_EXPORT_BATCH_SIZE), MAX_EXPORT_BATCH_SIZE)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sanitizeFieldName(base: string, used: Set<string>, fallbackIndex: number): string {
|
|
150
|
+
const trimmed = base.trim()
|
|
151
|
+
const sanitized = trimmed.replace(/[^a-zA-Z0-9_\-]/g, '_') || `field_${fallbackIndex}`
|
|
152
|
+
const normalized = /^[A-Za-z_]/.test(sanitized) ? sanitized : `f_${sanitized}`
|
|
153
|
+
let candidate = normalized
|
|
154
|
+
let counter = 1
|
|
155
|
+
while (used.has(candidate)) {
|
|
156
|
+
candidate = `${normalized}_${counter++}`
|
|
157
|
+
}
|
|
158
|
+
used.add(candidate)
|
|
159
|
+
return candidate
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildExportFromColumns(items: any[], columnsConfig: CrudExportColumnConfig[]): PreparedExport {
|
|
163
|
+
const used = new Set<string>()
|
|
164
|
+
const columns: ColumnResolver[] = columnsConfig.map((col, idx) => {
|
|
165
|
+
const fieldName = sanitizeFieldName(col.field || `field_${idx}`, used, idx)
|
|
166
|
+
const header = col.header?.trim().length ? col.header!.trim() : col.field || `Field ${idx + 1}`
|
|
167
|
+
const resolver = col.resolve
|
|
168
|
+
? col.resolve
|
|
169
|
+
: ((item: any) => (item != null ? (item as any)[col.field] : undefined))
|
|
170
|
+
return { field: fieldName, header, resolve: resolver }
|
|
171
|
+
})
|
|
172
|
+
const rows = items.map((item) => {
|
|
173
|
+
const row: Record<string, unknown> = {}
|
|
174
|
+
columns.forEach((column) => {
|
|
175
|
+
try {
|
|
176
|
+
row[column.field] = column.resolve(item)
|
|
177
|
+
} catch {
|
|
178
|
+
row[column.field] = undefined
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
return row
|
|
182
|
+
})
|
|
183
|
+
return {
|
|
184
|
+
columns: columns.map(({ field, header }) => ({ field, header })),
|
|
185
|
+
rows,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildExportFromCsv(items: any[], csv: NonNullable<ListConfig<any>['csv']>): PreparedExport {
|
|
190
|
+
const used = new Set<string>()
|
|
191
|
+
const columns = csv.headers.map((header, idx) => ({
|
|
192
|
+
field: sanitizeFieldName(header || `column_${idx + 1}`, used, idx),
|
|
193
|
+
header: header || `Column ${idx + 1}`,
|
|
194
|
+
}))
|
|
195
|
+
const rows = items.map((item) => {
|
|
196
|
+
const values = csv.row(item) || []
|
|
197
|
+
const row: Record<string, unknown> = {}
|
|
198
|
+
columns.forEach((column, idx) => {
|
|
199
|
+
row[column.field] = values[idx]
|
|
200
|
+
})
|
|
201
|
+
return row
|
|
202
|
+
})
|
|
203
|
+
return { columns, rows }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildDefaultExport(items: any[]): PreparedExport {
|
|
207
|
+
const rows = items.map((item) => {
|
|
208
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
209
|
+
return { ...(item as Record<string, unknown>) }
|
|
210
|
+
}
|
|
211
|
+
return { value: item }
|
|
212
|
+
})
|
|
213
|
+
return {
|
|
214
|
+
columns: ensureColumns(rows),
|
|
215
|
+
rows,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function prepareExportData(items: any[], list: ListConfig<any>): PreparedExport {
|
|
220
|
+
if (list.export?.columns && list.export.columns.length > 0) {
|
|
221
|
+
return buildExportFromColumns(items, list.export.columns)
|
|
222
|
+
}
|
|
223
|
+
if (list.csv) {
|
|
224
|
+
return buildExportFromCsv(items, list.csv)
|
|
225
|
+
}
|
|
226
|
+
const prepared = buildDefaultExport(items)
|
|
227
|
+
return {
|
|
228
|
+
columns: ensureColumns(prepared.rows, prepared.columns),
|
|
229
|
+
rows: prepared.rows,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function finalizeExportFilename(list: ListConfig<any>, format: CrudExportFormat, fallbackBase: string): string {
|
|
234
|
+
const extension = format === 'markdown' ? 'md' : format
|
|
235
|
+
const fromExport = list.export?.filename
|
|
236
|
+
const apply = (value: string | null | undefined): string | null => {
|
|
237
|
+
if (!value) return null
|
|
238
|
+
const trimmed = value.trim()
|
|
239
|
+
if (!trimmed) return null
|
|
240
|
+
const sanitized = trimmed.replace(/[^a-z0-9_\-\.]/gi, '_')
|
|
241
|
+
const lower = sanitized.toLowerCase()
|
|
242
|
+
if (lower.endsWith(`.${extension}`)) return sanitized
|
|
243
|
+
const withoutExtension = sanitized.includes('.') ? sanitized.replace(/\.[^.]+$/, '') : sanitized
|
|
244
|
+
const base = withoutExtension.trim().length > 0 ? withoutExtension : sanitized
|
|
245
|
+
return `${base}.${extension}`
|
|
246
|
+
}
|
|
247
|
+
if (typeof fromExport === 'function') {
|
|
248
|
+
const computed = apply(fromExport(format))
|
|
249
|
+
if (computed) return computed
|
|
250
|
+
} else {
|
|
251
|
+
const computed = apply(fromExport)
|
|
252
|
+
if (computed) return computed
|
|
253
|
+
}
|
|
254
|
+
if (format === 'csv' && list.csv?.filename) {
|
|
255
|
+
const csvName = apply(list.csv.filename)
|
|
256
|
+
if (csvName) return csvName
|
|
257
|
+
}
|
|
258
|
+
return defaultExportFilename(fallbackBase, format)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function normalizeFullRecordForExport(input: any): any {
|
|
262
|
+
if (!input || typeof input !== 'object') return input
|
|
263
|
+
if (Array.isArray(input)) return input.map((item) => normalizeFullRecordForExport(item))
|
|
264
|
+
const record: Record<string, unknown> = {}
|
|
265
|
+
|
|
266
|
+
for (const [key, value] of Object.entries(input)) {
|
|
267
|
+
if (key.startsWith('cf_') || key.startsWith('cf:')) continue
|
|
268
|
+
record[key] = value
|
|
269
|
+
}
|
|
270
|
+
const custom = extractAllCustomFieldEntries(input)
|
|
271
|
+
for (const [rawKey, value] of Object.entries(custom)) {
|
|
272
|
+
const sanitizedKey = rawKey.replace(/^cf_/, '')
|
|
273
|
+
record[sanitizedKey] = value
|
|
274
|
+
}
|
|
275
|
+
return record
|
|
276
|
+
}
|
|
277
|
+
export type CreateConfig<TCreate> = {
|
|
278
|
+
schema: z.ZodType<TCreate>
|
|
279
|
+
mapToEntity: (input: TCreate, ctx: CrudCtx) => Record<string, any>
|
|
280
|
+
customFields?: CustomFieldsConfig
|
|
281
|
+
response?: (entity: any) => any
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export type UpdateConfig<TUpdate> = {
|
|
285
|
+
schema: z.ZodType<TUpdate>
|
|
286
|
+
// Must contain a string uuid `id` field
|
|
287
|
+
getId?: (input: TUpdate) => string
|
|
288
|
+
applyToEntity: (entity: any, input: TUpdate, ctx: CrudCtx) => void | Promise<void>
|
|
289
|
+
customFields?: CustomFieldsConfig
|
|
290
|
+
response?: (entity: any) => any
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export type DeleteConfig = {
|
|
294
|
+
// Where to take id from; default: query param `id`
|
|
295
|
+
idFrom?: 'query' | 'body'
|
|
296
|
+
softDelete?: boolean // default true
|
|
297
|
+
response?: (id: string) => any
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export type CrudCommandActionConfig = {
|
|
301
|
+
commandId: string
|
|
302
|
+
schema?: z.ZodTypeAny
|
|
303
|
+
mapInput?: (args: { parsed: any; raw: any; ctx: CrudCtx }) => Promise<any> | any
|
|
304
|
+
metadata?: (args: { input: any; parsed: any; raw: any; ctx: CrudCtx }) => Promise<CommandLogMetadata | null> | CommandLogMetadata | null
|
|
305
|
+
response?: (args: { result: any; logEntry: any | null; ctx: CrudCtx }) => any
|
|
306
|
+
status?: number
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export type CrudCtx = {
|
|
310
|
+
container: AwilixContainer
|
|
311
|
+
auth: AuthContext | null
|
|
312
|
+
organizationScope: OrganizationScope | null
|
|
313
|
+
selectedOrganizationId: string | null
|
|
314
|
+
organizationIds: string[] | null
|
|
315
|
+
request?: Request
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export type CrudFactoryOptions<TCreate, TUpdate, TList> = {
|
|
319
|
+
metadata?: CrudMetadata
|
|
320
|
+
orm: OrmEntityConfig
|
|
321
|
+
list?: ListConfig<TList>
|
|
322
|
+
create?: CreateConfig<TCreate>
|
|
323
|
+
update?: UpdateConfig<TUpdate>
|
|
324
|
+
del?: DeleteConfig
|
|
325
|
+
events?: CrudEventsConfig
|
|
326
|
+
indexer?: CrudIndexerConfig
|
|
327
|
+
resolveIdentifiers?: CrudIdentifierResolver
|
|
328
|
+
hooks?: CrudHooks<TCreate, TUpdate, TList>
|
|
329
|
+
actions?: {
|
|
330
|
+
create?: CrudCommandActionConfig
|
|
331
|
+
update?: CrudCommandActionConfig
|
|
332
|
+
delete?: CrudCommandActionConfig
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function deriveResourceFromActions(actions: CrudFactoryOptions<any, any, any>['actions']): string | null {
|
|
337
|
+
if (!actions) return null
|
|
338
|
+
const ids: Array<string | null | undefined> = [actions.create?.commandId, actions.update?.commandId, actions.delete?.commandId]
|
|
339
|
+
for (const id of ids) {
|
|
340
|
+
const resolved = deriveResourceFromCommandId(id)
|
|
341
|
+
if (resolved) return resolved
|
|
342
|
+
}
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function resolveResourceAliasesList(
|
|
347
|
+
opts: CrudFactoryOptions<any, any, any>,
|
|
348
|
+
ormEntityName: string | undefined
|
|
349
|
+
): { primary: string; aliases: string[] } {
|
|
350
|
+
const eventsResource =
|
|
351
|
+
opts.events?.module && opts.events?.entity ? `${opts.events.module}.${opts.events.entity}` : null
|
|
352
|
+
const commandResource = deriveResourceFromActions(opts.actions)
|
|
353
|
+
const rawCandidate = eventsResource ?? commandResource ?? ormEntityName ?? 'resource'
|
|
354
|
+
const primary = canonicalizeResourceTag(rawCandidate) ?? 'resource'
|
|
355
|
+
return { primary, aliases: [] }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function mergeCommandMetadata(base: CommandLogMetadata, override: CommandLogMetadata | null | undefined): CommandLogMetadata {
|
|
359
|
+
if (!override) return base
|
|
360
|
+
const mergedContext = {
|
|
361
|
+
...(base.context ?? {}),
|
|
362
|
+
...(override.context ?? {}),
|
|
363
|
+
}
|
|
364
|
+
const merged: CommandLogMetadata = {
|
|
365
|
+
...base,
|
|
366
|
+
...override,
|
|
367
|
+
}
|
|
368
|
+
if (Object.keys(mergedContext).length > 0) merged.context = mergedContext
|
|
369
|
+
else if ('context' in merged) delete merged.context
|
|
370
|
+
return merged
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function json(data: any, init?: ResponseInit) {
|
|
374
|
+
return new Response(JSON.stringify(data), {
|
|
375
|
+
...(init || {}),
|
|
376
|
+
headers: { 'content-type': 'application/json', ...(init?.headers || {}) },
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function attachOperationHeader(res: Response, logEntry: any) {
|
|
381
|
+
if (!res || !(res instanceof Response)) return res
|
|
382
|
+
if (!logEntry || typeof logEntry !== 'object') return res
|
|
383
|
+
const undoToken = typeof logEntry.undoToken === 'string' ? logEntry.undoToken : null
|
|
384
|
+
const id = typeof logEntry.id === 'string' ? logEntry.id : null
|
|
385
|
+
const commandId = typeof logEntry.commandId === 'string' ? logEntry.commandId : null
|
|
386
|
+
if (!undoToken || !id || !commandId) return res
|
|
387
|
+
const actionLabel = typeof logEntry.actionLabel === 'string' ? logEntry.actionLabel : null
|
|
388
|
+
const resourceKind = typeof logEntry.resourceKind === 'string' ? logEntry.resourceKind : null
|
|
389
|
+
const resourceId = typeof logEntry.resourceId === 'string' ? logEntry.resourceId : null
|
|
390
|
+
const createdAt = logEntry.createdAt instanceof Date
|
|
391
|
+
? logEntry.createdAt.toISOString()
|
|
392
|
+
: (typeof logEntry.createdAt === 'string' ? logEntry.createdAt : new Date().toISOString())
|
|
393
|
+
const headerValue = serializeOperationMetadata({
|
|
394
|
+
id,
|
|
395
|
+
undoToken,
|
|
396
|
+
commandId,
|
|
397
|
+
actionLabel,
|
|
398
|
+
resourceKind,
|
|
399
|
+
resourceId,
|
|
400
|
+
executedAt: createdAt,
|
|
401
|
+
})
|
|
402
|
+
try {
|
|
403
|
+
res.headers.set('x-om-operation', headerValue)
|
|
404
|
+
} catch {
|
|
405
|
+
// no-op if headers already sent
|
|
406
|
+
}
|
|
407
|
+
return res
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function handleError(err: unknown): Response {
|
|
411
|
+
if (err instanceof Response) return err
|
|
412
|
+
if (err instanceof CrudHttpError) return json(err.body, { status: err.status })
|
|
413
|
+
if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
|
|
414
|
+
|
|
415
|
+
const message = err instanceof Error ? err.message : undefined
|
|
416
|
+
const stack = err instanceof Error ? err.stack : undefined
|
|
417
|
+
// eslint-disable-next-line no-console
|
|
418
|
+
console.error('[crud] unexpected error', { message, stack, err })
|
|
419
|
+
const body: Record<string, unknown> = {
|
|
420
|
+
error: 'Internal server error',
|
|
421
|
+
message: 'Something went wrong. Please try again later.',
|
|
422
|
+
}
|
|
423
|
+
return json(body, { status: 500 })
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isUuid(v: any): v is string {
|
|
427
|
+
return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
type AccessLogServiceLike = { log: (input: any) => Promise<unknown> | unknown }
|
|
431
|
+
|
|
432
|
+
function resolveAccessLogService(container: AwilixContainer): AccessLogServiceLike | null {
|
|
433
|
+
try {
|
|
434
|
+
const service = container.resolve?.('accessLogService') as AccessLogServiceLike | undefined
|
|
435
|
+
if (service && typeof service.log === 'function') return service
|
|
436
|
+
} catch (err) {
|
|
437
|
+
try {
|
|
438
|
+
console.warn('[crud] accessLogService not available in container', err)
|
|
439
|
+
} catch {}
|
|
440
|
+
}
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function logForbidden(details: Record<string, unknown>) {
|
|
445
|
+
try {
|
|
446
|
+
// eslint-disable-next-line no-console
|
|
447
|
+
console.warn('[crud] Forbidden request', details)
|
|
448
|
+
} catch {}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function collectFieldNames(items: any[]): string[] {
|
|
452
|
+
const set = new Set<string>()
|
|
453
|
+
for (const item of items) {
|
|
454
|
+
if (!item || typeof item !== 'object') continue
|
|
455
|
+
for (const key of Object.keys(item)) {
|
|
456
|
+
if (typeof key === 'string' && key.length > 0) set.add(key)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return Array.from(set)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function determineAccessType(query: unknown, total: number, idField: string): string {
|
|
463
|
+
if (query && typeof query === 'object' && query !== null && idField in (query as Record<string, unknown>)) {
|
|
464
|
+
const value = (query as Record<string, unknown>)[idField]
|
|
465
|
+
if (value !== undefined && value !== null && String(value).length > 0) return 'read:item'
|
|
466
|
+
}
|
|
467
|
+
return total > 1 ? 'read:list' : 'read'
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function createCrudProfiler(resource: string, operation: string): Profiler {
|
|
471
|
+
const enabled = shouldEnableProfiler(resource)
|
|
472
|
+
return createProfiler({
|
|
473
|
+
scope: `crud:${operation}`,
|
|
474
|
+
target: resource,
|
|
475
|
+
label: `${resource}:${operation}`,
|
|
476
|
+
loggerLabel: '[crud:profile]',
|
|
477
|
+
enabled,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export type LogCrudAccessOptions = {
|
|
482
|
+
container: AwilixContainer
|
|
483
|
+
auth: AuthContext | null
|
|
484
|
+
request?: Request
|
|
485
|
+
items: any[]
|
|
486
|
+
idField?: string
|
|
487
|
+
resourceKind: string
|
|
488
|
+
organizationId?: string | null
|
|
489
|
+
tenantId?: string | null
|
|
490
|
+
query?: unknown
|
|
491
|
+
accessType?: string
|
|
492
|
+
fields?: string[]
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function logCrudAccess(options: LogCrudAccessOptions) {
|
|
496
|
+
const { container, auth, request, items, resourceKind } = options
|
|
497
|
+
if (!auth) return
|
|
498
|
+
if (!Array.isArray(items) || items.length === 0) return
|
|
499
|
+
const service = resolveAccessLogService(container)
|
|
500
|
+
if (!service) return
|
|
501
|
+
|
|
502
|
+
const idField = options.idField || 'id'
|
|
503
|
+
const tenantId = options.tenantId ?? auth.tenantId ?? null
|
|
504
|
+
const organizationId = options.organizationId ?? auth.orgId ?? null
|
|
505
|
+
const actorUserId = (auth.keyId ?? auth.sub) ?? null
|
|
506
|
+
const fields = options.fields && options.fields.length ? options.fields : collectFieldNames(items)
|
|
507
|
+
const accessType = options.accessType ?? determineAccessType(options.query, items.length, idField)
|
|
508
|
+
|
|
509
|
+
const context: Record<string, unknown> = {
|
|
510
|
+
resultCount: items.length,
|
|
511
|
+
accessType,
|
|
512
|
+
}
|
|
513
|
+
if (options.query && typeof options.query === 'object' && options.query !== null) {
|
|
514
|
+
context.queryKeys = Object.keys(options.query as Record<string, unknown>)
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
if (request) {
|
|
518
|
+
const url = new URL(request.url)
|
|
519
|
+
context.path = url.pathname
|
|
520
|
+
}
|
|
521
|
+
} catch {
|
|
522
|
+
// ignore url parsing issues
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const uniqueIds = new Set<string>()
|
|
526
|
+
const tasks: Promise<unknown>[] = []
|
|
527
|
+
for (const item of items) {
|
|
528
|
+
if (!item || typeof item !== 'object') continue
|
|
529
|
+
const rawId = (item as any)[idField]
|
|
530
|
+
const resourceId = normalizeIdentifierValue(rawId)
|
|
531
|
+
if (!resourceId || uniqueIds.has(resourceId)) continue
|
|
532
|
+
uniqueIds.add(resourceId)
|
|
533
|
+
const payload: Record<string, unknown> = {
|
|
534
|
+
tenantId,
|
|
535
|
+
organizationId,
|
|
536
|
+
actorUserId,
|
|
537
|
+
resourceKind,
|
|
538
|
+
resourceId,
|
|
539
|
+
accessType,
|
|
540
|
+
}
|
|
541
|
+
if (fields.length > 0) payload.fields = fields
|
|
542
|
+
if (Object.keys(context).length > 0) payload.context = context
|
|
543
|
+
tasks.push(
|
|
544
|
+
Promise.resolve(service.log(payload)).catch((err) => {
|
|
545
|
+
try {
|
|
546
|
+
console.error('[crud] failed to record access log', { err, payload })
|
|
547
|
+
} catch {}
|
|
548
|
+
return undefined
|
|
549
|
+
})
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
if (tasks.length > 0) await Promise.all(tasks)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
type CrudCacheStoredValue = {
|
|
556
|
+
payload: any
|
|
557
|
+
generatedAt: number
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function safeClone<T>(value: T): T {
|
|
561
|
+
try {
|
|
562
|
+
const structuredCloneFn = (globalThis as any).structuredClone
|
|
563
|
+
if (typeof structuredCloneFn === 'function') {
|
|
564
|
+
return structuredCloneFn(value)
|
|
565
|
+
}
|
|
566
|
+
} catch {}
|
|
567
|
+
try {
|
|
568
|
+
return JSON.parse(JSON.stringify(value)) as T
|
|
569
|
+
} catch {
|
|
570
|
+
return value
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function collectScopeOrganizationIds(ctx: CrudCtx): Array<string | null> {
|
|
575
|
+
if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
|
|
576
|
+
return Array.from(new Set(ctx.organizationIds))
|
|
577
|
+
}
|
|
578
|
+
const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
579
|
+
return [fallback]
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function serializeSearchParams(params: URLSearchParams): string {
|
|
583
|
+
if (!params || params.keys().next().done) return ''
|
|
584
|
+
const grouped = new Map<string, string[]>()
|
|
585
|
+
params.forEach((value, key) => {
|
|
586
|
+
const existing = grouped.get(key) ?? []
|
|
587
|
+
existing.push(value)
|
|
588
|
+
grouped.set(key, existing)
|
|
589
|
+
})
|
|
590
|
+
const normalized: Array<[string, string[]]> = Array.from(grouped.entries()).map(([key, values]) => [key, values.sort()])
|
|
591
|
+
normalized.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
592
|
+
return JSON.stringify(normalized)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function buildCrudCacheKey(resource: string, request: Request, ctx: CrudCtx): string {
|
|
596
|
+
const url = new URL(request.url)
|
|
597
|
+
const scopeIds = collectScopeOrganizationIds(ctx)
|
|
598
|
+
const scopeSegment = scopeIds.length
|
|
599
|
+
? scopeIds.map((id) => normalizeTagSegment(id)).sort().join(',')
|
|
600
|
+
: 'none'
|
|
601
|
+
return [
|
|
602
|
+
'crud',
|
|
603
|
+
normalizeTagSegment(resource),
|
|
604
|
+
'GET',
|
|
605
|
+
url.pathname,
|
|
606
|
+
`tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
|
|
607
|
+
`selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
|
|
608
|
+
`scope:${scopeSegment}`,
|
|
609
|
+
`query:${serializeSearchParams(url.searchParams)}`,
|
|
610
|
+
].join('|')
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function extractRecordIds(items: any[], idField: string): string[] {
|
|
614
|
+
if (!Array.isArray(items) || !items.length) return []
|
|
615
|
+
const ids = new Set<string>()
|
|
616
|
+
for (const item of items) {
|
|
617
|
+
if (!item || typeof item !== 'object') continue
|
|
618
|
+
const rawId = (item as any)[idField]
|
|
619
|
+
const id = normalizeIdentifierValue(rawId)
|
|
620
|
+
if (id) ids.add(id)
|
|
621
|
+
}
|
|
622
|
+
return Array.from(ids)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: CrudFactoryOptions<TCreate, TUpdate, TList>) {
|
|
626
|
+
const metadata = opts.metadata || {}
|
|
627
|
+
const ormCfg = {
|
|
628
|
+
entity: opts.orm.entity,
|
|
629
|
+
idField: opts.orm.idField ?? 'id',
|
|
630
|
+
orgField: opts.orm.orgField === null ? null : opts.orm.orgField ?? 'organizationId',
|
|
631
|
+
tenantField: opts.orm.tenantField === null ? null : opts.orm.tenantField ?? 'tenantId',
|
|
632
|
+
softDeleteField: opts.orm.softDeleteField === null ? null : opts.orm.softDeleteField ?? 'deletedAt',
|
|
633
|
+
}
|
|
634
|
+
const entityName = typeof ormCfg.entity?.name === 'string' && ormCfg.entity.name.length > 0 ? ormCfg.entity.name : undefined
|
|
635
|
+
const resourceInfo = resolveResourceAliasesList(opts, entityName)
|
|
636
|
+
const resourceKind = resourceInfo.primary
|
|
637
|
+
const resourceAliases = resourceInfo.aliases
|
|
638
|
+
const resourceTargets = expandResourceAliases(resourceKind, resourceAliases)
|
|
639
|
+
const defaultIdentifierResolver: CrudIdentifierResolver = (entity, _action) => {
|
|
640
|
+
const id = normalizeIdentifierValue((entity as any)[ormCfg.idField!])
|
|
641
|
+
const orgId = ormCfg.orgField ? normalizeIdentifierValue((entity as any)[ormCfg.orgField]) : null
|
|
642
|
+
const tenantId = ormCfg.tenantField ? normalizeIdentifierValue((entity as any)[ormCfg.tenantField]) : null
|
|
643
|
+
return {
|
|
644
|
+
id: id ?? '',
|
|
645
|
+
organizationId: orgId ?? null,
|
|
646
|
+
tenantId: tenantId ?? null,
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const identifierResolver: CrudIdentifierResolver = opts.resolveIdentifiers
|
|
650
|
+
? (entity, action) => {
|
|
651
|
+
const raw = opts.resolveIdentifiers!(entity, action)
|
|
652
|
+
const id = normalizeIdentifierValue(raw?.id)
|
|
653
|
+
const organizationId = normalizeIdentifierValue(raw?.organizationId)
|
|
654
|
+
const tenantId = normalizeIdentifierValue(raw?.tenantId)
|
|
655
|
+
return {
|
|
656
|
+
id: id ?? '',
|
|
657
|
+
organizationId: organizationId ?? null,
|
|
658
|
+
tenantId: tenantId ?? null,
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
: defaultIdentifierResolver
|
|
662
|
+
|
|
663
|
+
const listCustomFieldDecorator = opts.list?.decorateCustomFields
|
|
664
|
+
const indexerConfig = opts.indexer as CrudIndexerConfig | undefined
|
|
665
|
+
const eventsConfig = opts.events as CrudEventsConfig | undefined
|
|
666
|
+
|
|
667
|
+
const pickNormalizedIdentifier = (...values: Array<string | number | null | undefined>): string | null => {
|
|
668
|
+
for (const value of values) {
|
|
669
|
+
const normalized = normalizeIdentifierValue(value)
|
|
670
|
+
if (normalized) return normalized
|
|
671
|
+
}
|
|
672
|
+
return null
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const extractIdentifierFrom = (...payloads: Array<any>): string | null => {
|
|
676
|
+
const candidates: Array<string | number | null | undefined> = []
|
|
677
|
+
for (const payload of payloads) {
|
|
678
|
+
if (!payload || typeof payload !== 'object') {
|
|
679
|
+
candidates.push(payload as any)
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
candidates.push(
|
|
683
|
+
(payload as any)?.id,
|
|
684
|
+
(payload as any)?.shipmentId,
|
|
685
|
+
(payload as any)?.paymentId,
|
|
686
|
+
(payload as any)?.lineId,
|
|
687
|
+
(payload as any)?.adjustmentId,
|
|
688
|
+
(payload as any)?.itemId,
|
|
689
|
+
(payload as any)?.orderAdjustmentId,
|
|
690
|
+
(payload as any)?.orderId,
|
|
691
|
+
(payload as any)?.quoteId,
|
|
692
|
+
)
|
|
693
|
+
}
|
|
694
|
+
return pickNormalizedIdentifier(...candidates)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const markCommandResultForIndexing = async (
|
|
698
|
+
id: string | null,
|
|
699
|
+
action: CrudEventAction,
|
|
700
|
+
ctx: CrudCtx,
|
|
701
|
+
) => {
|
|
702
|
+
if (!id || (!indexerConfig && !eventsConfig)) return
|
|
703
|
+
try {
|
|
704
|
+
const em = ctx.container.resolve('em') as EntityManager
|
|
705
|
+
const entity = await em.findOne(ormCfg.entity, { [ormCfg.idField!]: id } as any)
|
|
706
|
+
const de = ctx.container.resolve('dataEngine') as DataEngine
|
|
707
|
+
const identifiers = identifierResolver(
|
|
708
|
+
entity ?? ({ [ormCfg.idField!]: id } as any),
|
|
709
|
+
action,
|
|
710
|
+
)
|
|
711
|
+
const scopedIdentifiers = {
|
|
712
|
+
...identifiers,
|
|
713
|
+
organizationId:
|
|
714
|
+
identifiers.organizationId ??
|
|
715
|
+
ctx.selectedOrganizationId ??
|
|
716
|
+
ctx.auth?.orgId ??
|
|
717
|
+
null,
|
|
718
|
+
tenantId: identifiers.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
719
|
+
}
|
|
720
|
+
de.markOrmEntityChange({
|
|
721
|
+
action,
|
|
722
|
+
entity: entity ?? ({ [ormCfg.idField!]: id } as any),
|
|
723
|
+
identifiers: scopedIdentifiers,
|
|
724
|
+
events: eventsConfig,
|
|
725
|
+
indexer: indexerConfig,
|
|
726
|
+
})
|
|
727
|
+
await de.flushOrmEntityChanges()
|
|
728
|
+
} catch (err) {
|
|
729
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
730
|
+
console.warn('[crud] failed to mark command result for indexing', { err, id, action, resourceKind })
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const inferFieldValue = (item: Record<string, unknown>, keys: string[]): string | null => {
|
|
736
|
+
for (const key of keys) {
|
|
737
|
+
const value = item[key]
|
|
738
|
+
if (typeof value === 'string') {
|
|
739
|
+
const trimmed = value.trim()
|
|
740
|
+
if (trimmed.length) return trimmed
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return null
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const decorateItemsWithCustomFields = async (items: any[], ctx: CrudCtx): Promise<any[]> => {
|
|
747
|
+
if (!listCustomFieldDecorator || !Array.isArray(items) || items.length === 0) return items
|
|
748
|
+
const entityIds = Array.isArray(listCustomFieldDecorator.entityIds)
|
|
749
|
+
? listCustomFieldDecorator.entityIds
|
|
750
|
+
: [listCustomFieldDecorator.entityIds]
|
|
751
|
+
if (!entityIds.length) return items
|
|
752
|
+
const cfProfiler = createCrudProfiler(resourceKind, 'custom_fields')
|
|
753
|
+
cfProfiler.mark('prepare')
|
|
754
|
+
let profileClosed = false
|
|
755
|
+
const endProfile = (extra?: Record<string, unknown>) => {
|
|
756
|
+
if (!cfProfiler.enabled || profileClosed) return
|
|
757
|
+
profileClosed = true
|
|
758
|
+
cfProfiler.end(extra)
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
const em = (ctx.container.resolve('em') as EntityManager)
|
|
762
|
+
const organizationIds =
|
|
763
|
+
Array.isArray(ctx.organizationIds) && ctx.organizationIds.length
|
|
764
|
+
? ctx.organizationIds
|
|
765
|
+
: [ctx.selectedOrganizationId ?? null]
|
|
766
|
+
const definitionIndex = await loadCustomFieldDefinitionIndex({
|
|
767
|
+
em,
|
|
768
|
+
entityIds,
|
|
769
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
770
|
+
organizationIds,
|
|
771
|
+
})
|
|
772
|
+
cfProfiler.mark('definitions_loaded', { definitionCount: definitionIndex.size })
|
|
773
|
+
const decoratedItems = items.map((raw) => {
|
|
774
|
+
if (!raw || typeof raw !== 'object') return raw
|
|
775
|
+
const item = raw as Record<string, unknown>
|
|
776
|
+
const context = listCustomFieldDecorator.resolveContext
|
|
777
|
+
? listCustomFieldDecorator.resolveContext(raw, ctx) ?? {}
|
|
778
|
+
: {}
|
|
779
|
+
const organizationId =
|
|
780
|
+
context.organizationId ??
|
|
781
|
+
inferFieldValue(item, ['organization_id', 'organizationId'])
|
|
782
|
+
const tenantId =
|
|
783
|
+
context.tenantId ??
|
|
784
|
+
inferFieldValue(item, ['tenant_id', 'tenantId']) ??
|
|
785
|
+
ctx.auth?.tenantId ??
|
|
786
|
+
null
|
|
787
|
+
const decorated = decorateRecordWithCustomFields(item, definitionIndex, {
|
|
788
|
+
organizationId: organizationId ?? null,
|
|
789
|
+
tenantId: tenantId ?? null,
|
|
790
|
+
})
|
|
791
|
+
const output = {
|
|
792
|
+
...item,
|
|
793
|
+
customValues: decorated.customValues,
|
|
794
|
+
customFields: decorated.customFields,
|
|
795
|
+
}
|
|
796
|
+
return output
|
|
797
|
+
})
|
|
798
|
+
cfProfiler.mark('decorate_complete', { itemCount: decoratedItems.length })
|
|
799
|
+
endProfile({
|
|
800
|
+
entityIds: entityIds.length,
|
|
801
|
+
itemCount: decoratedItems.length,
|
|
802
|
+
})
|
|
803
|
+
return decoratedItems
|
|
804
|
+
} catch (err) {
|
|
805
|
+
console.warn('[crud] failed to decorate custom fields', err)
|
|
806
|
+
endProfile({
|
|
807
|
+
result: 'error',
|
|
808
|
+
entityIds: entityIds.length,
|
|
809
|
+
itemCount: items.length,
|
|
810
|
+
})
|
|
811
|
+
return items
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function ensureAuth(request?: Request | null) {
|
|
816
|
+
const auth = request ? await getAuthFromRequest(request) : await getAuthFromCookies()
|
|
817
|
+
if (!auth) return null
|
|
818
|
+
if (auth.tenantId && !isUuid(auth.tenantId)) return null
|
|
819
|
+
return auth
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function withCtx(request: Request): Promise<CrudCtx> {
|
|
823
|
+
const container = await createRequestContainer()
|
|
824
|
+
const rawAuth = await ensureAuth(request)
|
|
825
|
+
let scope: OrganizationScope | null = null
|
|
826
|
+
let selectedOrganizationId: string | null = null
|
|
827
|
+
let organizationIds: string[] | null = null
|
|
828
|
+
if (rawAuth) {
|
|
829
|
+
try {
|
|
830
|
+
scope = await resolveOrganizationScopeForRequest({ container, auth: rawAuth, request })
|
|
831
|
+
} catch {
|
|
832
|
+
scope = null
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const scopedTenantId = scope?.tenantId ?? rawAuth?.tenantId ?? null
|
|
836
|
+
const scopedOrgId = scope ? (scope.selectedId ?? null) : (rawAuth?.orgId ?? null)
|
|
837
|
+
selectedOrganizationId = scopedOrgId
|
|
838
|
+
const scopedAuth = rawAuth
|
|
839
|
+
? {
|
|
840
|
+
...rawAuth,
|
|
841
|
+
tenantId: scopedTenantId ?? null,
|
|
842
|
+
orgId: scopedOrgId ?? null,
|
|
843
|
+
}
|
|
844
|
+
: null
|
|
845
|
+
const fallbackOrgId = scopedOrgId ?? rawAuth?.orgId ?? null
|
|
846
|
+
const rawScopeIds = scope?.filterIds
|
|
847
|
+
const scopedIds = Array.isArray(rawScopeIds) ? rawScopeIds.filter((id): id is string => typeof id === 'string' && id.length > 0) : null
|
|
848
|
+
if (!scope) {
|
|
849
|
+
organizationIds = fallbackOrgId ? [fallbackOrgId] : null
|
|
850
|
+
} else if (scopedIds === null) {
|
|
851
|
+
organizationIds = scope.allowedIds === null ? null : (fallbackOrgId ? [fallbackOrgId] : null)
|
|
852
|
+
} else if (scopedIds.length > 0) {
|
|
853
|
+
organizationIds = Array.from(new Set(scopedIds))
|
|
854
|
+
} else if (fallbackOrgId) {
|
|
855
|
+
const allowedIds = Array.isArray(scope?.allowedIds) ? scope.allowedIds : null
|
|
856
|
+
let canUseFallback = false
|
|
857
|
+
if (allowedIds === null) {
|
|
858
|
+
canUseFallback = true
|
|
859
|
+
} else if (allowedIds.includes(fallbackOrgId) || allowedIds.length === 0) {
|
|
860
|
+
canUseFallback = true
|
|
861
|
+
}
|
|
862
|
+
if (canUseFallback) {
|
|
863
|
+
organizationIds = [fallbackOrgId]
|
|
864
|
+
} else {
|
|
865
|
+
organizationIds = []
|
|
866
|
+
}
|
|
867
|
+
} else {
|
|
868
|
+
organizationIds = []
|
|
869
|
+
}
|
|
870
|
+
return { container, auth: scopedAuth, organizationScope: scope, selectedOrganizationId, organizationIds, request }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function GET(request: Request) {
|
|
874
|
+
const profiler = createCrudProfiler(resourceKind, 'list')
|
|
875
|
+
const requestMeta: Record<string, unknown> = { method: request.method }
|
|
876
|
+
try {
|
|
877
|
+
const urlObj = new URL(request.url)
|
|
878
|
+
requestMeta.path = urlObj.pathname
|
|
879
|
+
requestMeta.url = request.url
|
|
880
|
+
if (urlObj.search) requestMeta.query = urlObj.search
|
|
881
|
+
} catch {
|
|
882
|
+
requestMeta.url = request.url
|
|
883
|
+
}
|
|
884
|
+
profiler.mark('request_received', requestMeta)
|
|
885
|
+
let profileClosed = false
|
|
886
|
+
const finishProfile = (extra?: Record<string, unknown>) => {
|
|
887
|
+
if (!profiler.enabled || profileClosed) return
|
|
888
|
+
profileClosed = true
|
|
889
|
+
const meta = extra ? { ...requestMeta, ...extra } : { ...requestMeta }
|
|
890
|
+
profiler.end(meta)
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
profiler.mark('resolve_context')
|
|
894
|
+
const ctx = await withCtx(request)
|
|
895
|
+
profiler.mark('context_ready')
|
|
896
|
+
if (!ctx.auth) {
|
|
897
|
+
finishProfile({ reason: 'unauthorized' })
|
|
898
|
+
return json({ error: 'Unauthorized' }, { status: 401 })
|
|
899
|
+
}
|
|
900
|
+
if (!opts.list) {
|
|
901
|
+
finishProfile({ reason: 'list_not_configured' })
|
|
902
|
+
return json({ error: 'Not implemented' }, { status: 501 })
|
|
903
|
+
}
|
|
904
|
+
const url = new URL(request.url)
|
|
905
|
+
const queryParams = Object.fromEntries(url.searchParams.entries())
|
|
906
|
+
profiler.mark('query_parsed')
|
|
907
|
+
const validated = opts.list.schema.parse(queryParams)
|
|
908
|
+
profiler.mark('query_validated')
|
|
909
|
+
|
|
910
|
+
await opts.hooks?.beforeList?.(validated as any, ctx)
|
|
911
|
+
profiler.mark('before_list_hook')
|
|
912
|
+
|
|
913
|
+
const availableFormats = resolveAvailableExportFormats(opts.list)
|
|
914
|
+
const requestedExport = normalizeExportFormat((queryParams as any).format)
|
|
915
|
+
const exportRequested = requestedExport != null && availableFormats.includes(requestedExport)
|
|
916
|
+
const requestedPage = Number((queryParams as any).page ?? 1) || 1
|
|
917
|
+
const requestedPageSize = Math.min(Math.max(Number((queryParams as any).pageSize ?? 50) || 50, 1), 100)
|
|
918
|
+
const exportPageSize = exportRequested ? resolveExportBatchSize(opts.list, requestedPageSize) : requestedPageSize
|
|
919
|
+
const exportScopeParam = (queryParams as any).exportScope ?? (queryParams as any).export_scope
|
|
920
|
+
const exportScope = typeof exportScopeParam === 'string' ? exportScopeParam.toLowerCase() : null
|
|
921
|
+
const exportFullRequested = exportRequested && (exportScope === 'full' || parseBooleanToken((queryParams as any).full) === true)
|
|
922
|
+
profiler.mark('export_configured', { exportRequested, exportFullRequested })
|
|
923
|
+
|
|
924
|
+
const cacheEnabled = isCrudCacheEnabled() && !exportRequested
|
|
925
|
+
const cacheTimerStart = cacheEnabled && isCrudCacheDebugEnabled()
|
|
926
|
+
? process.hrtime.bigint()
|
|
927
|
+
: null
|
|
928
|
+
const cache = cacheEnabled ? resolveCrudCache(ctx.container) : null
|
|
929
|
+
const cacheKey = cacheEnabled ? buildCrudCacheKey(resourceKind, request, ctx) : null
|
|
930
|
+
let cacheStatus: 'hit' | 'miss' = 'miss'
|
|
931
|
+
let cachedValue: CrudCacheStoredValue | null = null
|
|
932
|
+
|
|
933
|
+
if (cacheEnabled && cache && cacheKey) {
|
|
934
|
+
const rawCached = await cache.get(cacheKey)
|
|
935
|
+
if (rawCached !== null && rawCached !== undefined) {
|
|
936
|
+
if (typeof rawCached === 'object' && 'payload' in (rawCached as any)) {
|
|
937
|
+
cachedValue = rawCached as CrudCacheStoredValue
|
|
938
|
+
} else {
|
|
939
|
+
cachedValue = { payload: rawCached, generatedAt: Date.now() }
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
profiler.mark('cache_checked', { cached: cachedValue !== null })
|
|
944
|
+
|
|
945
|
+
const tenantForScope = ctx.auth?.tenantId ?? null
|
|
946
|
+
const maybeStoreCrudCache = async (payload: any) => {
|
|
947
|
+
if (!cacheEnabled || !cache || !cacheKey) return
|
|
948
|
+
if (!payload || typeof payload !== 'object') return
|
|
949
|
+
const items = Array.isArray((payload as any).items) ? (payload as any).items : []
|
|
950
|
+
const tags = new Set<string>()
|
|
951
|
+
const scopeOrgIds = collectScopeOrganizationIds(ctx)
|
|
952
|
+
const crudSegment = deriveCrudSegmentTag(resourceKind, request)
|
|
953
|
+
for (const target of resourceTargets) {
|
|
954
|
+
for (const tag of buildCollectionTags(target, tenantForScope, scopeOrgIds)) {
|
|
955
|
+
tags.add(tag)
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
const recordIds = extractRecordIds(items, ormCfg.idField!)
|
|
959
|
+
for (const recordId of recordIds) {
|
|
960
|
+
for (const target of resourceTargets) {
|
|
961
|
+
tags.add(buildRecordTag(target, tenantForScope, recordId))
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (crudSegment) {
|
|
965
|
+
tags.add(`crud:segment:${crudSegment}`)
|
|
966
|
+
}
|
|
967
|
+
if (!tags.size) return
|
|
968
|
+
try {
|
|
969
|
+
await cache.set(cacheKey, { payload: safeClone(payload), generatedAt: Date.now() }, { tags: Array.from(tags) })
|
|
970
|
+
debugCrudCache('store', {
|
|
971
|
+
resource: resourceKind,
|
|
972
|
+
key: cacheKey,
|
|
973
|
+
tags: Array.from(tags),
|
|
974
|
+
itemCount: items.length,
|
|
975
|
+
})
|
|
976
|
+
} catch (err) {
|
|
977
|
+
debugCrudCache('store', {
|
|
978
|
+
resource: resourceKind,
|
|
979
|
+
key: cacheKey,
|
|
980
|
+
error: err instanceof Error ? err.message : String(err),
|
|
981
|
+
})
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const logCacheOutcome = (event: 'hit' | 'miss', itemCount: number) => {
|
|
986
|
+
if (!cacheTimerStart) return
|
|
987
|
+
const elapsedMs = Number(process.hrtime.bigint() - cacheTimerStart) / 1_000_000
|
|
988
|
+
debugCrudCache(event, {
|
|
989
|
+
resource: resourceKind,
|
|
990
|
+
key: cacheKey,
|
|
991
|
+
durationMs: Math.round(elapsedMs * 1000) / 1000,
|
|
992
|
+
itemCount,
|
|
993
|
+
})
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const respondWithPayload = (payload: any, extraHeaders?: Record<string, string>) => {
|
|
997
|
+
const headers: Record<string, string> = extraHeaders ? { ...extraHeaders } : {}
|
|
998
|
+
const warning = payload && typeof payload === 'object' && payload.meta?.partialIndexWarning
|
|
999
|
+
if (warning) {
|
|
1000
|
+
headers['x-om-partial-index'] = JSON.stringify({
|
|
1001
|
+
type: 'partial_index',
|
|
1002
|
+
entity: warning.entity,
|
|
1003
|
+
entityLabel: warning.entityLabel ?? warning.entity,
|
|
1004
|
+
baseCount: warning.baseCount ?? null,
|
|
1005
|
+
indexedCount: warning.indexedCount ?? null,
|
|
1006
|
+
scope: warning.scope ?? 'scoped',
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
if (cacheEnabled) {
|
|
1010
|
+
headers['x-om-cache'] = cacheStatus
|
|
1011
|
+
}
|
|
1012
|
+
return json(payload, Object.keys(headers).length ? { headers } : undefined)
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (cachedValue) {
|
|
1016
|
+
cacheStatus = 'hit'
|
|
1017
|
+
profiler.mark('cache_hit', { generatedAt: cachedValue.generatedAt ?? null })
|
|
1018
|
+
const payload = safeClone(cachedValue.payload)
|
|
1019
|
+
const items = Array.isArray((payload as any)?.items) ? (payload as any).items : []
|
|
1020
|
+
profiler.mark('cache_payload_ready', { itemCount: items.length })
|
|
1021
|
+
await logCrudAccess({
|
|
1022
|
+
container: ctx.container,
|
|
1023
|
+
auth: ctx.auth,
|
|
1024
|
+
request,
|
|
1025
|
+
items,
|
|
1026
|
+
idField: ormCfg.idField!,
|
|
1027
|
+
resourceKind,
|
|
1028
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
1029
|
+
tenantId: ctx.auth.tenantId ?? null,
|
|
1030
|
+
query: validated,
|
|
1031
|
+
})
|
|
1032
|
+
await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
|
|
1033
|
+
logCacheOutcome('hit', items.length)
|
|
1034
|
+
const response = respondWithPayload(payload)
|
|
1035
|
+
finishProfile({ result: 'cache_hit', cacheStatus })
|
|
1036
|
+
return response
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Prefer query engine when configured
|
|
1040
|
+
if (opts.list.entityId && opts.list.fields) {
|
|
1041
|
+
profiler.mark('query_engine_prepare')
|
|
1042
|
+
const qe = (ctx.container.resolve('queryEngine') as QueryEngine)
|
|
1043
|
+
profiler.mark('query_engine_resolved')
|
|
1044
|
+
const sortFieldRaw = (queryParams as any).sortField || 'id'
|
|
1045
|
+
const sortDirRaw = ((queryParams as any).sortDir || 'asc').toLowerCase() === 'desc' ? SortDir.Desc : SortDir.Asc
|
|
1046
|
+
const sortField = (opts.list.sortFieldMap && opts.list.sortFieldMap[sortFieldRaw]) || sortFieldRaw
|
|
1047
|
+
const sort: Sort[] = [{ field: sortField as any, dir: sortDirRaw } as any]
|
|
1048
|
+
const page: Page = exportRequested
|
|
1049
|
+
? { page: 1, pageSize: exportPageSize }
|
|
1050
|
+
: { page: requestedPage, pageSize: requestedPageSize }
|
|
1051
|
+
const filters = exportFullRequested
|
|
1052
|
+
? ({} as Where<any>)
|
|
1053
|
+
: (opts.list.buildFilters ? await opts.list.buildFilters(validated as any, ctx) : ({} as Where<any>))
|
|
1054
|
+
const withDeleted = parseBooleanToken((queryParams as any).withDeleted) === true
|
|
1055
|
+
profiler.mark('filters_ready', { withDeleted })
|
|
1056
|
+
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
1057
|
+
profiler.mark('scope_blocked')
|
|
1058
|
+
logForbidden({
|
|
1059
|
+
resourceKind,
|
|
1060
|
+
action: 'list',
|
|
1061
|
+
reason: 'organization_scope_empty',
|
|
1062
|
+
userId: ctx.auth?.sub ?? null,
|
|
1063
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1064
|
+
organizationIds: ctx.organizationIds,
|
|
1065
|
+
})
|
|
1066
|
+
const emptyPayload = { items: [], total: 0, page: page.page, pageSize: page.pageSize, totalPages: 0 }
|
|
1067
|
+
await opts.hooks?.afterList?.(emptyPayload, { ...ctx, query: validated as any })
|
|
1068
|
+
await maybeStoreCrudCache(emptyPayload)
|
|
1069
|
+
logCacheOutcome(cacheStatus, emptyPayload.items.length)
|
|
1070
|
+
const response = respondWithPayload(emptyPayload)
|
|
1071
|
+
finishProfile({ result: 'empty_scope', cacheStatus, itemCount: 0, total: 0 })
|
|
1072
|
+
return response
|
|
1073
|
+
}
|
|
1074
|
+
const queryOpts: any = {
|
|
1075
|
+
fields: opts.list.fields!,
|
|
1076
|
+
includeCustomFields: true,
|
|
1077
|
+
sort,
|
|
1078
|
+
page,
|
|
1079
|
+
filters,
|
|
1080
|
+
withDeleted,
|
|
1081
|
+
}
|
|
1082
|
+
if (opts.list.customFieldSources) {
|
|
1083
|
+
queryOpts.customFieldSources = opts.list.customFieldSources
|
|
1084
|
+
}
|
|
1085
|
+
if (opts.list.joins) {
|
|
1086
|
+
queryOpts.joins = opts.list.joins
|
|
1087
|
+
}
|
|
1088
|
+
if (ormCfg.tenantField) queryOpts.tenantId = ctx.auth.tenantId!
|
|
1089
|
+
if (ormCfg.orgField) {
|
|
1090
|
+
queryOpts.organizationId = ctx.selectedOrganizationId ?? undefined
|
|
1091
|
+
queryOpts.organizationIds = ctx.organizationIds ?? undefined
|
|
1092
|
+
}
|
|
1093
|
+
const queryEntity = String(opts.list.entityId)
|
|
1094
|
+
profiler.mark('query_options_ready')
|
|
1095
|
+
const queryProfiler = profiler.child('query_engine', { entity: queryEntity })
|
|
1096
|
+
const res = await qe.query(opts.list.entityId as any, { ...queryOpts, profiler: queryProfiler })
|
|
1097
|
+
const rawItems = res.items || []
|
|
1098
|
+
let transformedItems = rawItems.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
|
|
1099
|
+
profiler.mark('transform_complete', { itemCount: transformedItems.length })
|
|
1100
|
+
transformedItems = await decorateItemsWithCustomFields(transformedItems, ctx)
|
|
1101
|
+
profiler.mark('custom_fields_complete', { itemCount: transformedItems.length })
|
|
1102
|
+
|
|
1103
|
+
await logCrudAccess({
|
|
1104
|
+
container: ctx.container,
|
|
1105
|
+
auth: ctx.auth,
|
|
1106
|
+
request,
|
|
1107
|
+
items: transformedItems,
|
|
1108
|
+
idField: ormCfg.idField!,
|
|
1109
|
+
resourceKind,
|
|
1110
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
1111
|
+
tenantId: ctx.auth.tenantId ?? null,
|
|
1112
|
+
query: validated,
|
|
1113
|
+
})
|
|
1114
|
+
profiler.mark('access_logged')
|
|
1115
|
+
|
|
1116
|
+
if (exportRequested && requestedExport) {
|
|
1117
|
+
const total = typeof res.total === 'number' ? res.total : rawItems.length
|
|
1118
|
+
const initialExportItems = exportFullRequested
|
|
1119
|
+
? rawItems.map(normalizeFullRecordForExport)
|
|
1120
|
+
: transformedItems
|
|
1121
|
+
let exportItems = [...initialExportItems]
|
|
1122
|
+
if (total > exportItems.length) {
|
|
1123
|
+
const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
|
|
1124
|
+
const queryBase: any = { ...queryOpts }
|
|
1125
|
+
delete queryBase.page
|
|
1126
|
+
let nextPage = 2
|
|
1127
|
+
while (exportItems.length < total) {
|
|
1128
|
+
profiler.mark('export_next_page_request', { page: nextPage })
|
|
1129
|
+
const nextRes = await qe.query(opts.list.entityId as any, {
|
|
1130
|
+
...queryBase,
|
|
1131
|
+
page: { page: nextPage, pageSize: exportPageSizeNumber },
|
|
1132
|
+
profiler: profiler.child('query_engine', { entity: queryEntity, page: nextPage, mode: 'export' }),
|
|
1133
|
+
})
|
|
1134
|
+
const nextItemsRaw = nextRes.items || []
|
|
1135
|
+
if (!nextItemsRaw.length) break
|
|
1136
|
+
let nextTransformed = nextItemsRaw.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
|
|
1137
|
+
nextTransformed = await decorateItemsWithCustomFields(nextTransformed, ctx)
|
|
1138
|
+
const nextExportItems = exportFullRequested
|
|
1139
|
+
? nextItemsRaw.map(normalizeFullRecordForExport)
|
|
1140
|
+
: nextTransformed
|
|
1141
|
+
exportItems.push(...nextExportItems)
|
|
1142
|
+
if (nextExportItems.length < exportPageSizeNumber) break
|
|
1143
|
+
nextPage += 1
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const prepared = exportFullRequested
|
|
1147
|
+
? { columns: ensureColumns(exportItems), rows: exportItems }
|
|
1148
|
+
: prepareExportData(exportItems, opts.list)
|
|
1149
|
+
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
1150
|
+
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
1151
|
+
const serialized = serializeExport(prepared, requestedExport)
|
|
1152
|
+
const exportPayload = { items: exportItems, total, page: 1, pageSize: exportItems.length, totalPages: 1, ...(res.meta ? { meta: res.meta } : {}) }
|
|
1153
|
+
await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
|
|
1154
|
+
profiler.mark('after_list_hook')
|
|
1155
|
+
const response = new Response(serialized.body, {
|
|
1156
|
+
headers: {
|
|
1157
|
+
'content-type': serialized.contentType,
|
|
1158
|
+
'content-disposition': `attachment; filename="${filename}"`,
|
|
1159
|
+
},
|
|
1160
|
+
})
|
|
1161
|
+
if (res.meta?.partialIndexWarning) {
|
|
1162
|
+
response.headers.set(
|
|
1163
|
+
'x-om-partial-index',
|
|
1164
|
+
JSON.stringify({
|
|
1165
|
+
type: 'partial_index',
|
|
1166
|
+
entity: res.meta.partialIndexWarning.entity,
|
|
1167
|
+
entityLabel: res.meta.partialIndexWarning.entityLabel ?? res.meta.partialIndexWarning.entity,
|
|
1168
|
+
baseCount: res.meta.partialIndexWarning.baseCount ?? null,
|
|
1169
|
+
indexedCount: res.meta.partialIndexWarning.indexedCount ?? null,
|
|
1170
|
+
scope: res.meta.partialIndexWarning.scope ?? 'scoped',
|
|
1171
|
+
}),
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
finishProfile({
|
|
1175
|
+
result: 'export',
|
|
1176
|
+
cacheStatus,
|
|
1177
|
+
itemCount: exportItems.length,
|
|
1178
|
+
total,
|
|
1179
|
+
})
|
|
1180
|
+
return response
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const payload = {
|
|
1184
|
+
items: transformedItems,
|
|
1185
|
+
total: res.total,
|
|
1186
|
+
page: page.page || requestedPage,
|
|
1187
|
+
pageSize: page.pageSize || requestedPageSize,
|
|
1188
|
+
totalPages: Math.ceil(res.total / (Number(page.pageSize) || 1)),
|
|
1189
|
+
...(res.meta ? { meta: res.meta } : {}),
|
|
1190
|
+
}
|
|
1191
|
+
await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
|
|
1192
|
+
profiler.mark('after_list_hook')
|
|
1193
|
+
await maybeStoreCrudCache(payload)
|
|
1194
|
+
profiler.mark('cache_store_attempt', { cacheEnabled })
|
|
1195
|
+
logCacheOutcome(cacheStatus, payload.items.length)
|
|
1196
|
+
const response = respondWithPayload(payload)
|
|
1197
|
+
finishProfile({
|
|
1198
|
+
result: 'ok',
|
|
1199
|
+
cacheStatus,
|
|
1200
|
+
itemCount: payload.items.length,
|
|
1201
|
+
total: payload.total ?? payload.items.length,
|
|
1202
|
+
})
|
|
1203
|
+
return response
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// Fallback: plain ORM list
|
|
1207
|
+
profiler.mark('orm_fallback_prepare')
|
|
1208
|
+
const em = (ctx.container.resolve('em') as any)
|
|
1209
|
+
const repo = em.getRepository(ormCfg.entity)
|
|
1210
|
+
profiler.mark('orm_repo_ready')
|
|
1211
|
+
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
1212
|
+
profiler.mark('fallback_scope_blocked')
|
|
1213
|
+
logForbidden({
|
|
1214
|
+
resourceKind,
|
|
1215
|
+
action: 'list',
|
|
1216
|
+
reason: 'organization_scope_empty',
|
|
1217
|
+
userId: ctx.auth?.sub ?? null,
|
|
1218
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1219
|
+
organizationIds: ctx.organizationIds,
|
|
1220
|
+
})
|
|
1221
|
+
const emptyPayload = { items: [], total: 0 }
|
|
1222
|
+
await opts.hooks?.afterList?.(emptyPayload, { ...ctx, query: validated as any })
|
|
1223
|
+
await maybeStoreCrudCache(emptyPayload)
|
|
1224
|
+
logCacheOutcome(cacheStatus, emptyPayload.items.length)
|
|
1225
|
+
const response = respondWithPayload(emptyPayload)
|
|
1226
|
+
finishProfile({
|
|
1227
|
+
result: 'empty_scope',
|
|
1228
|
+
cacheStatus,
|
|
1229
|
+
itemCount: 0,
|
|
1230
|
+
total: 0,
|
|
1231
|
+
branch: 'fallback',
|
|
1232
|
+
})
|
|
1233
|
+
return response
|
|
1234
|
+
}
|
|
1235
|
+
const where: any = buildScopedWhere(
|
|
1236
|
+
{},
|
|
1237
|
+
{
|
|
1238
|
+
organizationId: ormCfg.orgField ? (ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null) : undefined,
|
|
1239
|
+
organizationIds: ormCfg.orgField ? ctx.organizationIds ?? undefined : undefined,
|
|
1240
|
+
tenantId: ormCfg.tenantField ? ctx.auth.tenantId : undefined,
|
|
1241
|
+
orgField: ormCfg.orgField,
|
|
1242
|
+
tenantField: ormCfg.tenantField,
|
|
1243
|
+
softDeleteField: ormCfg.softDeleteField,
|
|
1244
|
+
}
|
|
1245
|
+
)
|
|
1246
|
+
let list = await repo.find(where)
|
|
1247
|
+
profiler.mark('orm_query_complete', { itemCount: Array.isArray(list) ? list.length : 0 })
|
|
1248
|
+
list = await decorateItemsWithCustomFields(list, ctx)
|
|
1249
|
+
profiler.mark('fallback_custom_fields_complete', { itemCount: Array.isArray(list) ? list.length : 0 })
|
|
1250
|
+
await logCrudAccess({
|
|
1251
|
+
container: ctx.container,
|
|
1252
|
+
auth: ctx.auth,
|
|
1253
|
+
request,
|
|
1254
|
+
items: list,
|
|
1255
|
+
idField: ormCfg.idField!,
|
|
1256
|
+
resourceKind,
|
|
1257
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
1258
|
+
tenantId: ctx.auth.tenantId ?? null,
|
|
1259
|
+
query: validated,
|
|
1260
|
+
})
|
|
1261
|
+
profiler.mark('access_logged')
|
|
1262
|
+
if (exportRequested && requestedExport) {
|
|
1263
|
+
const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
|
|
1264
|
+
const prepared = exportFullRequested
|
|
1265
|
+
? { columns: ensureColumns(exportItems), rows: exportItems }
|
|
1266
|
+
: prepareExportData(exportItems, opts.list)
|
|
1267
|
+
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
1268
|
+
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
1269
|
+
const serialized = serializeExport(prepared, requestedExport)
|
|
1270
|
+
await opts.hooks?.afterList?.({ items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 }, { ...ctx, query: validated as any })
|
|
1271
|
+
profiler.mark('after_list_hook')
|
|
1272
|
+
const response = new Response(serialized.body, {
|
|
1273
|
+
headers: {
|
|
1274
|
+
'content-type': serialized.contentType,
|
|
1275
|
+
'content-disposition': `attachment; filename="${filename}"`,
|
|
1276
|
+
},
|
|
1277
|
+
})
|
|
1278
|
+
finishProfile({
|
|
1279
|
+
result: 'export',
|
|
1280
|
+
cacheStatus,
|
|
1281
|
+
itemCount: exportItems.length,
|
|
1282
|
+
total: exportItems.length,
|
|
1283
|
+
branch: 'fallback',
|
|
1284
|
+
})
|
|
1285
|
+
return response
|
|
1286
|
+
}
|
|
1287
|
+
const payload = { items: list, total: list.length }
|
|
1288
|
+
await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
|
|
1289
|
+
profiler.mark('after_list_hook')
|
|
1290
|
+
await maybeStoreCrudCache(payload)
|
|
1291
|
+
profiler.mark('cache_store_attempt', { cacheEnabled })
|
|
1292
|
+
logCacheOutcome(cacheStatus, payload.items.length)
|
|
1293
|
+
const response = respondWithPayload(payload)
|
|
1294
|
+
finishProfile({
|
|
1295
|
+
result: 'ok',
|
|
1296
|
+
cacheStatus,
|
|
1297
|
+
itemCount: payload.items.length,
|
|
1298
|
+
total: payload.total,
|
|
1299
|
+
branch: 'fallback',
|
|
1300
|
+
})
|
|
1301
|
+
return response
|
|
1302
|
+
} catch (e) {
|
|
1303
|
+
finishProfile({ result: 'error' })
|
|
1304
|
+
return handleError(e)
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function POST(request: Request) {
|
|
1309
|
+
try {
|
|
1310
|
+
const useCommand = !!opts.actions?.create
|
|
1311
|
+
if (!opts.create && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
|
|
1312
|
+
const ctx = await withCtx(request)
|
|
1313
|
+
if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
|
|
1314
|
+
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
1315
|
+
logForbidden({
|
|
1316
|
+
resourceKind,
|
|
1317
|
+
action: 'create',
|
|
1318
|
+
reason: 'organization_scope_empty',
|
|
1319
|
+
userId: ctx.auth?.sub ?? null,
|
|
1320
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1321
|
+
organizationIds: ctx.organizationIds,
|
|
1322
|
+
})
|
|
1323
|
+
return json({ error: 'Forbidden' }, { status: 403 })
|
|
1324
|
+
}
|
|
1325
|
+
const body = await request.json().catch(() => ({}))
|
|
1326
|
+
|
|
1327
|
+
if (useCommand) {
|
|
1328
|
+
const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
|
|
1329
|
+
const action = opts.actions!.create!
|
|
1330
|
+
const parsed = action.schema ? action.schema.parse(body) : body
|
|
1331
|
+
const input = action.mapInput ? await action.mapInput({ parsed, raw: body, ctx }) : parsed
|
|
1332
|
+
const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw: body, ctx }) : null
|
|
1333
|
+
const baseMetadata: CommandLogMetadata = {
|
|
1334
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1335
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
1336
|
+
resourceKind,
|
|
1337
|
+
context: { cacheAliases: resourceTargets },
|
|
1338
|
+
}
|
|
1339
|
+
const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
|
|
1340
|
+
const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
|
|
1341
|
+
const payload = action.response ? action.response({ result, logEntry, ctx }) : result
|
|
1342
|
+
const resolvedPayload = await Promise.resolve(payload)
|
|
1343
|
+
const status = action.status ?? 201
|
|
1344
|
+
const response = json(resolvedPayload, { status })
|
|
1345
|
+
attachOperationHeader(response, logEntry)
|
|
1346
|
+
const indexedId = extractIdentifierFrom(resolvedPayload, result, parsed)
|
|
1347
|
+
await markCommandResultForIndexing(indexedId, 'created', ctx)
|
|
1348
|
+
return response
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const createConfig = opts.create
|
|
1352
|
+
if (!createConfig) throw new Error('Create configuration missing')
|
|
1353
|
+
|
|
1354
|
+
let input = createConfig.schema.parse(body)
|
|
1355
|
+
const modified = await opts.hooks?.beforeCreate?.(input as any, ctx)
|
|
1356
|
+
if (modified) input = modified
|
|
1357
|
+
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
1358
|
+
const entityData = createConfig.mapToEntity(input as any, ctx)
|
|
1359
|
+
// Inject org/tenant
|
|
1360
|
+
const targetOrgId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
|
|
1361
|
+
if (ormCfg.orgField) {
|
|
1362
|
+
if (!targetOrgId) return json({ error: 'Organization context is required' }, { status: 400 })
|
|
1363
|
+
entityData[ormCfg.orgField] = targetOrgId
|
|
1364
|
+
}
|
|
1365
|
+
if (ormCfg.tenantField) {
|
|
1366
|
+
if (!ctx.auth.tenantId) return json({ error: 'Tenant context is required' }, { status: 400 })
|
|
1367
|
+
entityData[ormCfg.tenantField] = ctx.auth.tenantId
|
|
1368
|
+
}
|
|
1369
|
+
const entity = await de.createOrmEntity({ entity: ormCfg.entity, data: entityData })
|
|
1370
|
+
|
|
1371
|
+
// Custom fields
|
|
1372
|
+
if (createConfig.customFields && (createConfig.customFields as any).enabled) {
|
|
1373
|
+
const cfc = createConfig.customFields as Exclude<CustomFieldsConfig, false>
|
|
1374
|
+
const values = cfc.map
|
|
1375
|
+
? cfc.map(body)
|
|
1376
|
+
: (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
|
|
1377
|
+
if (values && Object.keys(values).length > 0) {
|
|
1378
|
+
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
1379
|
+
await de.setCustomFields({
|
|
1380
|
+
entityId: cfc.entityId as any,
|
|
1381
|
+
recordId: String((entity as any)[ormCfg.idField!]),
|
|
1382
|
+
organizationId: targetOrgId,
|
|
1383
|
+
tenantId: ctx.auth.tenantId!,
|
|
1384
|
+
values,
|
|
1385
|
+
})
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
await opts.hooks?.afterCreate?.(entity, { ...ctx, input: input as any })
|
|
1390
|
+
|
|
1391
|
+
const identifiers = identifierResolver(entity, 'created')
|
|
1392
|
+
de.markOrmEntityChange({
|
|
1393
|
+
action: 'created',
|
|
1394
|
+
entity,
|
|
1395
|
+
identifiers,
|
|
1396
|
+
events: opts.events as CrudEventsConfig | undefined,
|
|
1397
|
+
indexer: opts.indexer as CrudIndexerConfig | undefined,
|
|
1398
|
+
})
|
|
1399
|
+
await de.flushOrmEntityChanges()
|
|
1400
|
+
await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'created', resourceTargets)
|
|
1401
|
+
|
|
1402
|
+
const payload = createConfig.response ? createConfig.response(entity) : { id: String((entity as any)[ormCfg.idField!]) }
|
|
1403
|
+
return json(payload, { status: 201 })
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
return handleError(e)
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
async function PUT(request: Request) {
|
|
1410
|
+
try {
|
|
1411
|
+
const useCommand = !!opts.actions?.update
|
|
1412
|
+
if (!opts.update && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
|
|
1413
|
+
const ctx = await withCtx(request)
|
|
1414
|
+
if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
|
|
1415
|
+
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
1416
|
+
logForbidden({
|
|
1417
|
+
resourceKind,
|
|
1418
|
+
action: 'update',
|
|
1419
|
+
reason: 'organization_scope_empty',
|
|
1420
|
+
userId: ctx.auth?.sub ?? null,
|
|
1421
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1422
|
+
organizationIds: ctx.organizationIds,
|
|
1423
|
+
})
|
|
1424
|
+
return json({ error: 'Forbidden' }, { status: 403 })
|
|
1425
|
+
}
|
|
1426
|
+
const body = await request.json().catch(() => ({}))
|
|
1427
|
+
|
|
1428
|
+
if (useCommand) {
|
|
1429
|
+
const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
|
|
1430
|
+
const action = opts.actions!.update!
|
|
1431
|
+
const parsed = action.schema ? action.schema.parse(body) : body
|
|
1432
|
+
const input = action.mapInput ? await action.mapInput({ parsed, raw: body, ctx }) : parsed
|
|
1433
|
+
const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw: body, ctx }) : null
|
|
1434
|
+
const candidateId = normalizeIdentifierValue((input as Record<string, unknown> | null | undefined)?.id)
|
|
1435
|
+
const baseMetadata: CommandLogMetadata = {
|
|
1436
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1437
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
1438
|
+
resourceKind,
|
|
1439
|
+
context: { cacheAliases: resourceTargets },
|
|
1440
|
+
}
|
|
1441
|
+
if (candidateId) baseMetadata.resourceId = candidateId
|
|
1442
|
+
const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
|
|
1443
|
+
const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
|
|
1444
|
+
const payload = action.response ? action.response({ result, logEntry, ctx }) : result
|
|
1445
|
+
const resolvedPayload = await Promise.resolve(payload)
|
|
1446
|
+
const status = action.status ?? 200
|
|
1447
|
+
const response = json(resolvedPayload, { status })
|
|
1448
|
+
attachOperationHeader(response, logEntry)
|
|
1449
|
+
const indexedId = extractIdentifierFrom(resolvedPayload, result, parsed)
|
|
1450
|
+
await markCommandResultForIndexing(indexedId, 'updated', ctx)
|
|
1451
|
+
return response
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const updateConfig = opts.update
|
|
1455
|
+
if (!updateConfig) throw new Error('Update configuration missing')
|
|
1456
|
+
|
|
1457
|
+
let input = updateConfig.schema.parse(body)
|
|
1458
|
+
const modified = await opts.hooks?.beforeUpdate?.(input as any, ctx)
|
|
1459
|
+
if (modified) input = modified
|
|
1460
|
+
|
|
1461
|
+
const id = updateConfig.getId ? updateConfig.getId(input as any) : (input as any).id
|
|
1462
|
+
if (!isUuid(id)) return json({ error: 'Invalid id' }, { status: 400 })
|
|
1463
|
+
|
|
1464
|
+
const targetOrgId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
|
|
1465
|
+
if (ormCfg.orgField && !targetOrgId) return json({ error: 'Organization context is required' }, { status: 400 })
|
|
1466
|
+
|
|
1467
|
+
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
1468
|
+
const where: any = buildScopedWhere(
|
|
1469
|
+
{ [ormCfg.idField!]: id },
|
|
1470
|
+
{
|
|
1471
|
+
organizationId: ormCfg.orgField ? targetOrgId : undefined,
|
|
1472
|
+
organizationIds: ormCfg.orgField ? ctx.organizationIds ?? undefined : undefined,
|
|
1473
|
+
tenantId: ormCfg.tenantField ? ctx.auth.tenantId : undefined,
|
|
1474
|
+
orgField: ormCfg.orgField,
|
|
1475
|
+
tenantField: ormCfg.tenantField,
|
|
1476
|
+
softDeleteField: ormCfg.softDeleteField,
|
|
1477
|
+
}
|
|
1478
|
+
)
|
|
1479
|
+
const entity = await de.updateOrmEntity({
|
|
1480
|
+
entity: ormCfg.entity,
|
|
1481
|
+
where,
|
|
1482
|
+
apply: (e: any) => updateConfig.applyToEntity(e, input as any, ctx),
|
|
1483
|
+
})
|
|
1484
|
+
if (!entity) return json({ error: 'Not found' }, { status: 404 })
|
|
1485
|
+
|
|
1486
|
+
// Custom fields
|
|
1487
|
+
if (updateConfig.customFields && (updateConfig.customFields as any).enabled) {
|
|
1488
|
+
const cfc = updateConfig.customFields as Exclude<CustomFieldsConfig, false>
|
|
1489
|
+
const values = cfc.map
|
|
1490
|
+
? cfc.map(body)
|
|
1491
|
+
: (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
|
|
1492
|
+
if (values && Object.keys(values).length > 0) {
|
|
1493
|
+
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
1494
|
+
await de.setCustomFields({
|
|
1495
|
+
entityId: cfc.entityId as any,
|
|
1496
|
+
recordId: String((entity as any)[ormCfg.idField!]),
|
|
1497
|
+
organizationId: targetOrgId,
|
|
1498
|
+
tenantId: ctx.auth.tenantId!,
|
|
1499
|
+
values,
|
|
1500
|
+
})
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
await opts.hooks?.afterUpdate?.(entity, { ...ctx, input: input as any })
|
|
1505
|
+
const identifiers = identifierResolver(entity, 'updated')
|
|
1506
|
+
de.markOrmEntityChange({
|
|
1507
|
+
action: 'updated',
|
|
1508
|
+
entity,
|
|
1509
|
+
identifiers,
|
|
1510
|
+
events: opts.events as CrudEventsConfig | undefined,
|
|
1511
|
+
indexer: opts.indexer as CrudIndexerConfig | undefined,
|
|
1512
|
+
})
|
|
1513
|
+
await de.flushOrmEntityChanges()
|
|
1514
|
+
await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'updated', resourceTargets)
|
|
1515
|
+
const payload = updateConfig.response ? updateConfig.response(entity) : { success: true }
|
|
1516
|
+
return json(payload)
|
|
1517
|
+
} catch (e) {
|
|
1518
|
+
return handleError(e)
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
async function DELETE(request: Request) {
|
|
1523
|
+
try {
|
|
1524
|
+
const ctx = await withCtx(request)
|
|
1525
|
+
if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
|
|
1526
|
+
if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
|
|
1527
|
+
logForbidden({
|
|
1528
|
+
resourceKind,
|
|
1529
|
+
action: 'delete',
|
|
1530
|
+
reason: 'organization_scope_empty',
|
|
1531
|
+
userId: ctx.auth?.sub ?? null,
|
|
1532
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1533
|
+
organizationIds: ctx.organizationIds,
|
|
1534
|
+
})
|
|
1535
|
+
return json({ error: 'Forbidden' }, { status: 403 })
|
|
1536
|
+
}
|
|
1537
|
+
const useCommand = !!opts.actions?.delete
|
|
1538
|
+
const url = new URL(request.url)
|
|
1539
|
+
|
|
1540
|
+
if (useCommand) {
|
|
1541
|
+
const action = opts.actions!.delete!
|
|
1542
|
+
const body = await request.json().catch(() => ({}))
|
|
1543
|
+
const raw = { body, query: Object.fromEntries(url.searchParams.entries()) }
|
|
1544
|
+
const parsed = action.schema ? action.schema.parse(raw) : raw
|
|
1545
|
+
const input = action.mapInput ? await action.mapInput({ parsed, raw, ctx }) : parsed
|
|
1546
|
+
const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw, ctx }) : null
|
|
1547
|
+
const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
|
|
1548
|
+
const candidateId = normalizeIdentifierValue(
|
|
1549
|
+
(input as Record<string, unknown> | null | undefined)?.id
|
|
1550
|
+
?? (raw.query as Record<string, unknown> | null | undefined)?.id
|
|
1551
|
+
?? (raw.body as Record<string, unknown> | null | undefined)?.id
|
|
1552
|
+
)
|
|
1553
|
+
const baseMetadata: CommandLogMetadata = {
|
|
1554
|
+
tenantId: ctx.auth?.tenantId ?? null,
|
|
1555
|
+
organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
|
|
1556
|
+
resourceKind,
|
|
1557
|
+
context: { cacheAliases: resourceTargets },
|
|
1558
|
+
}
|
|
1559
|
+
if (candidateId) baseMetadata.resourceId = candidateId
|
|
1560
|
+
const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
|
|
1561
|
+
const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
|
|
1562
|
+
const payload = action.response ? action.response({ result, logEntry, ctx }) : result
|
|
1563
|
+
const resolvedPayload = await Promise.resolve(payload)
|
|
1564
|
+
const status = action.status ?? 200
|
|
1565
|
+
const response = json(resolvedPayload, { status })
|
|
1566
|
+
attachOperationHeader(response, logEntry)
|
|
1567
|
+
const indexedId = extractIdentifierFrom(resolvedPayload, result, (parsed as any)?.body, parsed)
|
|
1568
|
+
await markCommandResultForIndexing(indexedId, 'deleted', ctx)
|
|
1569
|
+
return response
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const idFrom = opts.del?.idFrom || 'query'
|
|
1573
|
+
const id = idFrom === 'query'
|
|
1574
|
+
? url.searchParams.get('id')
|
|
1575
|
+
: (await request.json().catch(() => ({}))).id
|
|
1576
|
+
if (!isUuid(id)) return json({ error: 'ID is required' }, { status: 400 })
|
|
1577
|
+
|
|
1578
|
+
const targetOrgId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
|
|
1579
|
+
if (ormCfg.orgField && !targetOrgId) return json({ error: 'Organization context is required' }, { status: 400 })
|
|
1580
|
+
|
|
1581
|
+
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
1582
|
+
const where: any = buildScopedWhere(
|
|
1583
|
+
{ [ormCfg.idField!]: id },
|
|
1584
|
+
{
|
|
1585
|
+
organizationId: ormCfg.orgField ? targetOrgId : undefined,
|
|
1586
|
+
organizationIds: ormCfg.orgField ? ctx.organizationIds ?? undefined : undefined,
|
|
1587
|
+
tenantId: ormCfg.tenantField ? ctx.auth.tenantId : undefined,
|
|
1588
|
+
orgField: ormCfg.orgField,
|
|
1589
|
+
tenantField: ormCfg.tenantField,
|
|
1590
|
+
softDeleteField: ormCfg.softDeleteField,
|
|
1591
|
+
}
|
|
1592
|
+
)
|
|
1593
|
+
await opts.hooks?.beforeDelete?.(id!, ctx)
|
|
1594
|
+
const entity = await de.deleteOrmEntity({
|
|
1595
|
+
entity: ormCfg.entity,
|
|
1596
|
+
where,
|
|
1597
|
+
soft: opts.del?.softDelete !== false,
|
|
1598
|
+
softDeleteField: ormCfg.softDeleteField ?? undefined,
|
|
1599
|
+
})
|
|
1600
|
+
if (!entity) return json({ error: 'Not found' }, { status: 404 })
|
|
1601
|
+
await opts.hooks?.afterDelete?.(id!, ctx)
|
|
1602
|
+
if (entity) {
|
|
1603
|
+
const identifiers = identifierResolver(entity, 'deleted')
|
|
1604
|
+
de.markOrmEntityChange({
|
|
1605
|
+
action: 'deleted',
|
|
1606
|
+
entity,
|
|
1607
|
+
identifiers,
|
|
1608
|
+
events: opts.events as CrudEventsConfig | undefined,
|
|
1609
|
+
indexer: opts.indexer as CrudIndexerConfig | undefined,
|
|
1610
|
+
})
|
|
1611
|
+
await de.flushOrmEntityChanges()
|
|
1612
|
+
await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'deleted', resourceTargets)
|
|
1613
|
+
}
|
|
1614
|
+
const payload = opts.del?.response ? opts.del.response(id) : { success: true }
|
|
1615
|
+
return json(payload)
|
|
1616
|
+
} catch (e) {
|
|
1617
|
+
return handleError(e)
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
return { metadata, GET, POST, PUT, DELETE }
|
|
1622
|
+
}
|