@open-mercato/core 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +3 -3
- package/agentic/standalone-guide.md +13 -0
- package/dist/generated/entities/organization/index.js +2 -0
- package/dist/generated/entities/organization/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/auth/cli.js +1 -1
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +4 -6
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +1 -0
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +29 -44
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js +1 -1
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
- package/dist/modules/communication_channels/lib/access-control.js +2 -2
- package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
- package/dist/modules/customer_accounts/api/invitations/accept.js +1 -2
- package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
- package/dist/modules/customer_accounts/api/login.js +4 -2
- package/dist/modules/customer_accounts/api/login.js.map +2 -2
- package/dist/modules/customer_accounts/api/magic-link/verify.js +4 -2
- package/dist/modules/customer_accounts/api/magic-link/verify.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/feature-check.js +5 -4
- package/dist/modules/customer_accounts/api/portal/feature-check.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/nav.js +6 -4
- package/dist/modules/customer_accounts/api/portal/nav.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/profile.js +5 -1
- package/dist/modules/customer_accounts/api/portal/profile.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/sessions-refresh.js +3 -3
- package/dist/modules/customer_accounts/api/portal/sessions-refresh.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuth.js +7 -2
- package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuthServer.js +2 -1
- package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerRbacService.js +12 -3
- package/dist/modules/customer_accounts/services/customerRbacService.js.map +2 -2
- package/dist/modules/customer_accounts/setup.js +6 -0
- package/dist/modules/customer_accounts/setup.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +1 -1
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +2 -2
- package/dist/modules/customers/lib/visibilityFilter.js +6 -4
- package/dist/modules/customers/lib/visibilityFilter.js.map +2 -2
- package/dist/modules/dashboards/api/layout/[itemId]/route.js +6 -3
- package/dist/modules/dashboards/api/layout/[itemId]/route.js.map +2 -2
- package/dist/modules/dashboards/api/layout/route.js +22 -9
- package/dist/modules/dashboards/api/layout/route.js.map +2 -2
- package/dist/modules/dashboards/api/roles/widgets/route.js +9 -3
- package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
- package/dist/modules/dashboards/api/users/widgets/route.js +9 -3
- package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/catalog.js +6 -3
- package/dist/modules/dashboards/api/widgets/catalog.js.map +2 -2
- package/dist/modules/dashboards/lib/access.js +5 -3
- package/dist/modules/dashboards/lib/access.js.map +2 -2
- package/dist/modules/directory/api/organization-branding/route.js +10 -3
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/api/organizations/route.js +7 -0
- package/dist/modules/directory/api/organizations/route.js.map +3 -3
- package/dist/modules/directory/backend/directory/branding/page.js +31 -8
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/directory/commands/organizations.js +8 -1
- package/dist/modules/directory/commands/organizations.js.map +2 -2
- package/dist/modules/directory/data/entities.js +4 -0
- package/dist/modules/directory/data/entities.js.map +2 -2
- package/dist/modules/directory/data/validators.js +2 -0
- package/dist/modules/directory/data/validators.js.map +2 -2
- package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js +13 -0
- package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js.map +7 -0
- package/dist/modules/entities/lib/entityAcl.js +47 -15
- package/dist/modules/entities/lib/entityAcl.js.map +3 -3
- package/dist/modules/inbox_ops/ai-tools.js +5 -2
- package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
- package/dist/modules/messages/lib/routeHelpers.js +1 -3
- package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
- package/dist/modules/notifications/lib/notificationRecipients.js +6 -7
- package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
- package/dist/modules/query_index/di.js +3 -0
- package/dist/modules/query_index/di.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +2 -2
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +83 -17
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +13 -7
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/query_index/subscribers/coverage_refresh.js +4 -0
- package/dist/modules/query_index/subscribers/coverage_refresh.js.map +2 -2
- package/dist/modules/query_index/subscribers/delete_one.js +53 -1
- package/dist/modules/query_index/subscribers/delete_one.js.map +3 -3
- package/dist/modules/query_index/subscribers/upsert_one.js +60 -0
- package/dist/modules/query_index/subscribers/upsert_one.js.map +3 -3
- package/dist/modules/sales/commands/documents.js +17 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/staff/api/interceptors.js +5 -3
- package/dist/modules/staff/api/interceptors.js.map +2 -2
- package/dist/modules/sync_excel/api/upload/route.js +15 -2
- package/dist/modules/sync_excel/api/upload/route.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +7 -7
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/generated/entities/organization/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/auth/AGENTS.md +5 -3
- package/src/modules/auth/cli.ts +1 -1
- package/src/modules/auth/lib/backendChrome.tsx +5 -11
- package/src/modules/auth/lib/setup-app.ts +1 -0
- package/src/modules/auth/services/rbacService.ts +41 -46
- package/src/modules/catalog/widgets/injection/product-seo/widget.ts +1 -1
- package/src/modules/communication_channels/lib/access-control.ts +3 -3
- package/src/modules/customer_accounts/AGENTS.md +3 -3
- package/src/modules/customer_accounts/api/invitations/accept.ts +1 -2
- package/src/modules/customer_accounts/api/login.ts +4 -2
- package/src/modules/customer_accounts/api/magic-link/verify.ts +4 -2
- package/src/modules/customer_accounts/api/portal/feature-check.ts +5 -7
- package/src/modules/customer_accounts/api/portal/nav.ts +5 -3
- package/src/modules/customer_accounts/api/portal/profile.ts +5 -1
- package/src/modules/customer_accounts/api/portal/sessions-refresh.ts +3 -3
- package/src/modules/customer_accounts/lib/customerAuth.ts +8 -2
- package/src/modules/customer_accounts/lib/customerAuthServer.ts +1 -0
- package/src/modules/customer_accounts/services/customerRbacService.ts +16 -3
- package/src/modules/customer_accounts/setup.ts +7 -0
- package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +1 -3
- package/src/modules/customers/lib/visibilityFilter.ts +7 -5
- package/src/modules/dashboards/api/layout/[itemId]/route.ts +6 -3
- package/src/modules/dashboards/api/layout/route.ts +22 -9
- package/src/modules/dashboards/api/roles/widgets/route.ts +9 -3
- package/src/modules/dashboards/api/users/widgets/route.ts +9 -3
- package/src/modules/dashboards/api/widgets/catalog.ts +6 -3
- package/src/modules/dashboards/lib/access.ts +5 -3
- package/src/modules/directory/api/organization-branding/route.ts +12 -1
- package/src/modules/directory/api/organizations/route.ts +7 -0
- package/src/modules/directory/backend/directory/branding/page.tsx +34 -9
- package/src/modules/directory/commands/organizations.ts +9 -1
- package/src/modules/directory/data/entities.ts +3 -0
- package/src/modules/directory/data/validators.ts +2 -0
- package/src/modules/directory/i18n/de.json +3 -1
- package/src/modules/directory/i18n/en.json +3 -1
- package/src/modules/directory/i18n/es.json +3 -1
- package/src/modules/directory/i18n/pl.json +3 -1
- package/src/modules/directory/migrations/.snapshot-open-mercato.json +18 -1
- package/src/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.ts +13 -0
- package/src/modules/entities/lib/entityAcl.ts +47 -18
- package/src/modules/inbox_ops/ai-tools.ts +5 -3
- package/src/modules/messages/lib/routeHelpers.ts +4 -6
- package/src/modules/notifications/lib/notificationRecipients.ts +6 -8
- package/src/modules/query_index/di.ts +6 -0
- package/src/modules/query_index/lib/coverage.ts +9 -6
- package/src/modules/query_index/lib/indexer.ts +105 -19
- package/src/modules/query_index/lib/search-tokens.ts +19 -8
- package/src/modules/query_index/subscribers/coverage_refresh.ts +4 -0
- package/src/modules/query_index/subscribers/delete_one.ts +65 -1
- package/src/modules/query_index/subscribers/upsert_one.ts +68 -1
- package/src/modules/sales/commands/documents.ts +27 -0
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
- package/src/modules/staff/api/interceptors.ts +6 -4
- package/src/modules/sync_excel/api/upload/route.ts +15 -2
- package/src/modules/workflows/lib/activity-executor.ts +12 -10
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
|
-
import { hasAllFeatures } from '@open-mercato/shared/security/features'
|
|
3
2
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
4
3
|
import { getModules } from '@open-mercato/shared/lib/i18n/server'
|
|
4
|
+
import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
|
|
5
5
|
import { deriveCustomEntityRecordFeature } from './recordFeatures'
|
|
6
6
|
|
|
7
7
|
export type EntityAclRequirement = {
|
|
@@ -102,7 +102,10 @@ export function canReadAllEntityMetadata(acl: {
|
|
|
102
102
|
isSuperAdmin?: boolean
|
|
103
103
|
features?: readonly string[]
|
|
104
104
|
}): boolean {
|
|
105
|
-
return
|
|
105
|
+
return authorizeFeatures(['entities.definitions.view'], {
|
|
106
|
+
grantedFeatures: acl.features ?? [],
|
|
107
|
+
unrestricted: Boolean(acl.isSuperAdmin),
|
|
108
|
+
})
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
export function canReadEntityMetadata(args: {
|
|
@@ -111,20 +114,29 @@ export function canReadEntityMetadata(args: {
|
|
|
111
114
|
isRestricted?: boolean
|
|
112
115
|
acl: { isSuperAdmin?: boolean; features?: readonly string[] }
|
|
113
116
|
}): boolean {
|
|
114
|
-
if (args.acl.isSuperAdmin) return true
|
|
115
|
-
|
|
116
117
|
const requirement = resolveEntityAclRequirement(args.entityId)
|
|
117
|
-
if (requirement?.platformOnly)
|
|
118
|
+
if (requirement?.platformOnly) {
|
|
119
|
+
return Boolean(args.acl.isSuperAdmin) && authorizeFeatures(requirement.view, {
|
|
120
|
+
grantedFeatures: args.acl.features ?? [],
|
|
121
|
+
unrestricted: true,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
118
124
|
if (canReadAllEntityMetadata(args.acl)) return true
|
|
119
125
|
if (args.isCustomEntity) {
|
|
120
126
|
const requiredFeatures = ['entities.records.view']
|
|
121
127
|
if (args.isRestricted) {
|
|
122
128
|
requiredFeatures.push(deriveCustomEntityRecordFeature(args.entityId, 'view'))
|
|
123
129
|
}
|
|
124
|
-
return
|
|
130
|
+
return authorizeFeatures(requiredFeatures, {
|
|
131
|
+
grantedFeatures: args.acl.features ?? [],
|
|
132
|
+
unrestricted: Boolean(args.acl.isSuperAdmin),
|
|
133
|
+
})
|
|
125
134
|
}
|
|
126
|
-
if (!requirement) return
|
|
127
|
-
return
|
|
135
|
+
if (!requirement) return Boolean(args.acl.isSuperAdmin)
|
|
136
|
+
return authorizeFeatures(requirement.view, {
|
|
137
|
+
grantedFeatures: args.acl.features ?? [],
|
|
138
|
+
unrestricted: Boolean(args.acl.isSuperAdmin),
|
|
139
|
+
})
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
type EntityAclActor = {
|
|
@@ -163,11 +175,16 @@ export async function assertEntityAclForRequest(args: AssertEntityAclArgs): Prom
|
|
|
163
175
|
// entities.records.view/.manage route guard is the whole authorization.
|
|
164
176
|
if (!args.isRestricted) return
|
|
165
177
|
|
|
166
|
-
const acl = await loadActorAcl(args)
|
|
167
|
-
if (acl?.isSuperAdmin) return
|
|
168
|
-
|
|
169
178
|
const required = deriveCustomEntityRecordFeature(args.entityId, args.action)
|
|
170
|
-
|
|
179
|
+
const allowed = await args.rbac.userHasAllFeatures(
|
|
180
|
+
args.auth.sub ?? '',
|
|
181
|
+
[required],
|
|
182
|
+
{
|
|
183
|
+
tenantId: args.auth.tenantId ?? null,
|
|
184
|
+
organizationId: args.auth.orgId ?? null,
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
if (!allowed) {
|
|
171
188
|
throw forbiddenEntityAccess()
|
|
172
189
|
}
|
|
173
190
|
return
|
|
@@ -175,14 +192,26 @@ export async function assertEntityAclForRequest(args: AssertEntityAclArgs): Prom
|
|
|
175
192
|
|
|
176
193
|
const requirement = resolveEntityAclRequirement(args.entityId)
|
|
177
194
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
195
|
+
if (!requirement) {
|
|
196
|
+
const acl = await loadActorAcl(args)
|
|
197
|
+
if (!acl.isSuperAdmin) throw forbiddenEntityAccess()
|
|
198
|
+
return
|
|
199
|
+
}
|
|
181
200
|
|
|
182
|
-
if (
|
|
183
|
-
|
|
201
|
+
if (requirement.platformOnly) {
|
|
202
|
+
const acl = await loadActorAcl(args)
|
|
203
|
+
if (!acl.isSuperAdmin) throw forbiddenEntityAccess()
|
|
204
|
+
}
|
|
184
205
|
|
|
185
|
-
|
|
206
|
+
const allowed = await args.rbac.userHasAllFeatures(
|
|
207
|
+
args.auth.sub ?? '',
|
|
208
|
+
requirement[args.action],
|
|
209
|
+
{
|
|
210
|
+
tenantId: args.auth.tenantId ?? null,
|
|
211
|
+
organizationId: args.auth.orgId ?? null,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
if (!allowed) {
|
|
186
215
|
throw forbiddenEntityAccess()
|
|
187
216
|
}
|
|
188
217
|
}
|
|
@@ -3,7 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
3
3
|
import type { AwilixContainer } from 'awilix'
|
|
4
4
|
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
5
5
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
6
|
-
import {
|
|
6
|
+
import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
|
|
7
7
|
import { InboxProposal, InboxProposalAction, InboxDiscrepancy } from './data/entities'
|
|
8
8
|
import { inboxProposalCategoryEnum } from './data/validators'
|
|
9
9
|
import { executeAction } from './lib/executionEngine'
|
|
@@ -311,8 +311,10 @@ Returns on error: error message with appropriate detail.`,
|
|
|
311
311
|
|
|
312
312
|
// Check target module permission
|
|
313
313
|
if (action.requiredFeature) {
|
|
314
|
-
const featureGranted =
|
|
315
|
-
|
|
314
|
+
const featureGranted = authorizeFeatures([action.requiredFeature], {
|
|
315
|
+
grantedFeatures: ctx.userFeatures,
|
|
316
|
+
unrestricted: ctx.isSuperAdmin,
|
|
317
|
+
})
|
|
316
318
|
if (!featureGranted) {
|
|
317
319
|
return {
|
|
318
320
|
error: 'Insufficient permissions',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
|
|
2
|
-
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
3
2
|
|
|
4
3
|
export function hasOrganizationAccess(
|
|
5
4
|
scopeOrganizationId: string | null,
|
|
@@ -33,10 +32,11 @@ export async function resolveMessageContext(req: Request): Promise<{
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
type RbacService = {
|
|
36
|
-
|
|
35
|
+
userHasAllFeatures: (
|
|
37
36
|
userId: string,
|
|
37
|
+
required: string[],
|
|
38
38
|
scope: { tenantId: string | null; organizationId: string | null }
|
|
39
|
-
) => Promise<
|
|
39
|
+
) => Promise<boolean>
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export async function parseRequestBodySafe(req: Request): Promise<unknown> {
|
|
@@ -56,10 +56,8 @@ export async function canUseMessageEmailFeature(
|
|
|
56
56
|
if (!scope.userId || !scope.tenantId) return false
|
|
57
57
|
|
|
58
58
|
const rbac = ctx.container.resolve('rbacService') as RbacService
|
|
59
|
-
|
|
59
|
+
return rbac.userHasAllFeatures(scope.userId, ['messages.email'], {
|
|
60
60
|
tenantId: scope.tenantId,
|
|
61
61
|
organizationId: scope.organizationId,
|
|
62
62
|
})
|
|
63
|
-
|
|
64
|
-
return Boolean(acl.isSuperAdmin) || hasFeature(acl.features, 'messages.email')
|
|
65
63
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Kysely } from 'kysely'
|
|
2
|
-
import {
|
|
2
|
+
import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
|
|
3
3
|
|
|
4
4
|
interface AclRow {
|
|
5
5
|
user_id: string
|
|
@@ -22,13 +22,11 @@ function collectUsersWithFeature(
|
|
|
22
22
|
requiredFeature: string
|
|
23
23
|
): void {
|
|
24
24
|
for (const row of rows) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const features = normalizeFeatures(row.features_json)
|
|
31
|
-
if (features && hasFeature(features, requiredFeature)) {
|
|
25
|
+
const features = normalizeFeatures(row.features_json) ?? []
|
|
26
|
+
if (authorizeFeatures([requiredFeature], {
|
|
27
|
+
grantedFeatures: features,
|
|
28
|
+
unrestricted: row.is_super_admin,
|
|
29
|
+
})) {
|
|
32
30
|
userIdsSet.add(row.user_id)
|
|
33
31
|
}
|
|
34
32
|
}
|
|
@@ -4,6 +4,7 @@ import { HybridQueryEngine } from './lib/engine'
|
|
|
4
4
|
import { markDeleted } from './lib/indexer'
|
|
5
5
|
import type { EventBus } from '@open-mercato/events'
|
|
6
6
|
import type { VectorIndexService } from '@open-mercato/search/vector'
|
|
7
|
+
import { CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY } from '@open-mercato/shared/lib/crud/types'
|
|
7
8
|
|
|
8
9
|
function toEntityTypeFromEvent(event: string): string | null {
|
|
9
10
|
// Expect '<module>.<entity>.<action>'
|
|
@@ -65,6 +66,10 @@ export function register(container: AppContainer) {
|
|
|
65
66
|
|
|
66
67
|
const makeUpsertHandler = (entityType: string) => async (payload: any, ctx: any) => {
|
|
67
68
|
try {
|
|
69
|
+
// DataEngine emits the canonical query_index.upsert_one itself. The
|
|
70
|
+
// bridge only covers domain events from write paths that do not own an
|
|
71
|
+
// indexer, otherwise failures and error logs are duplicated.
|
|
72
|
+
if (payload?.[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY] === true) return
|
|
68
73
|
const em = ctx.resolve('em')
|
|
69
74
|
let orgId = payload?.organizationId || payload?.orgId || ctx?.organizationId || null
|
|
70
75
|
let tenantId = payload?.tenantId || ctx?.tenantId || null
|
|
@@ -118,6 +123,7 @@ export function register(container: AppContainer) {
|
|
|
118
123
|
}
|
|
119
124
|
const makeDeleteHandler = (entityType: string) => async (payload: any, ctx: any) => {
|
|
120
125
|
try {
|
|
126
|
+
if (payload?.[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY] === true) return
|
|
121
127
|
const em = ctx.resolve('em')
|
|
122
128
|
let orgId = payload?.organizationId || payload?.orgId || ctx?.organizationId || null
|
|
123
129
|
let tenantId = payload?.tenantId || ctx?.tenantId || null
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
-
import { type Kysely, sql } from 'kysely'
|
|
2
|
+
import { type Kysely, type Transaction, sql } from 'kysely'
|
|
3
3
|
import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
4
4
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
5
|
|
|
6
6
|
const logger = createLogger('query_index').child({ component: 'coverage' })
|
|
7
7
|
|
|
8
|
+
type CoverageExecutor = Kysely<any> | Transaction<any>
|
|
9
|
+
|
|
8
10
|
export type CoverageScope = {
|
|
9
11
|
entityType: string
|
|
10
12
|
tenantId?: string | null
|
|
@@ -95,7 +97,7 @@ function applyOrganizationCondition<QB extends { where: (...args: any[]) => QB }
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
async function fetchCoverageRow(
|
|
98
|
-
db:
|
|
100
|
+
db: CoverageExecutor,
|
|
99
101
|
scope: CoverageScope
|
|
100
102
|
): Promise<(CoverageRow & { organization_id: string | null }) | null> {
|
|
101
103
|
const { entityType, tenantId, organizationId, withDeleted } = scope
|
|
@@ -120,7 +122,7 @@ async function fetchCoverageRow(
|
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
async function pruneDuplicateCoverageRows(
|
|
123
|
-
db:
|
|
125
|
+
db: CoverageExecutor,
|
|
124
126
|
scope: CoverageScope,
|
|
125
127
|
keepId: string | null
|
|
126
128
|
): Promise<void> {
|
|
@@ -139,7 +141,7 @@ async function pruneDuplicateCoverageRows(
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
async function upsertCoverageRow(
|
|
142
|
-
db:
|
|
144
|
+
db: CoverageExecutor,
|
|
143
145
|
scope: CoverageScope,
|
|
144
146
|
counts: { baseCount: number; indexedCount: number; vectorIndexedCount: number }
|
|
145
147
|
): Promise<void> {
|
|
@@ -260,10 +262,11 @@ export async function readCoverageSnapshots(
|
|
|
260
262
|
|
|
261
263
|
export async function applyCoverageAdjustments(
|
|
262
264
|
em: EntityManager,
|
|
263
|
-
adjustments: CoverageAdjustment[]
|
|
265
|
+
adjustments: CoverageAdjustment[],
|
|
266
|
+
options?: { trx?: CoverageExecutor },
|
|
264
267
|
): Promise<void> {
|
|
265
268
|
if (!adjustments.length) return
|
|
266
|
-
const db = (em as any).getKysely() as Kysely<any>
|
|
269
|
+
const db = options?.trx ?? ((em as any).getKysely() as Kysely<any>)
|
|
267
270
|
const aggregated = aggregateAdjustments(adjustments)
|
|
268
271
|
for (const entry of aggregated) {
|
|
269
272
|
const scope = entry.scope
|
|
@@ -2,13 +2,21 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
2
2
|
import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
3
3
|
import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
|
|
4
4
|
import { decryptIndexDocForSearch, encryptIndexDocForStorage } from '@open-mercato/shared/lib/encryption/indexDoc'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildCustomFieldDefinitionIndexFromRows,
|
|
7
|
+
normalizeDefinitionKey,
|
|
8
|
+
selectDefinitionForRecord,
|
|
9
|
+
type CustomFieldDefinitionRow,
|
|
10
|
+
} from '@open-mercato/shared/lib/crud/custom-field-definition-index'
|
|
11
|
+
import { type Kysely, type Transaction, sql } from 'kysely'
|
|
6
12
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
7
13
|
import { replaceSearchTokensForRecord, deleteSearchTokensForRecord } from './search-tokens'
|
|
8
14
|
import { attachAggregateSearchField } from './document'
|
|
9
15
|
|
|
10
16
|
const logger = createLogger('query_index').child({ component: 'indexer' })
|
|
11
17
|
|
|
18
|
+
type QueryIndexExecutor = Kysely<any> | Transaction<any>
|
|
19
|
+
|
|
12
20
|
type BuildDocParams = {
|
|
13
21
|
entityType: string // '<module>:<entity>'
|
|
14
22
|
recordId: string
|
|
@@ -96,9 +104,74 @@ export async function buildIndexDoc(em: EntityManager, params: BuildDocParams):
|
|
|
96
104
|
if (!cfMap[cfKey]) cfMap[cfKey] = []
|
|
97
105
|
cfMap[cfKey].push(val)
|
|
98
106
|
}
|
|
107
|
+
|
|
108
|
+
const multiKeys = new Set<string>()
|
|
109
|
+
const fieldKeys = Object.keys(cfMap).map((key) => key.slice(3))
|
|
110
|
+
if (fieldKeys.length > 0) {
|
|
111
|
+
let defQuery = db
|
|
112
|
+
.selectFrom('custom_field_defs' as any)
|
|
113
|
+
.select([
|
|
114
|
+
'key' as any,
|
|
115
|
+
'kind' as any,
|
|
116
|
+
'config_json' as any,
|
|
117
|
+
'organization_id' as any,
|
|
118
|
+
'tenant_id' as any,
|
|
119
|
+
'deleted_at' as any,
|
|
120
|
+
'updated_at' as any,
|
|
121
|
+
])
|
|
122
|
+
.where('entity_id' as any, '=', params.entityType)
|
|
123
|
+
.where('key' as any, 'in', fieldKeys)
|
|
124
|
+
.where('is_active' as any, '=', true)
|
|
125
|
+
.where('deleted_at' as any, 'is', null)
|
|
126
|
+
|
|
127
|
+
if (params.organizationId != null) {
|
|
128
|
+
defQuery = defQuery.where((eb: any) => eb.or([
|
|
129
|
+
eb('organization_id' as any, '=', params.organizationId),
|
|
130
|
+
eb('organization_id' as any, 'is', null),
|
|
131
|
+
]))
|
|
132
|
+
} else {
|
|
133
|
+
defQuery = defQuery.where('organization_id' as any, 'is', null)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (params.tenantId != null) {
|
|
137
|
+
defQuery = defQuery.where((eb: any) => eb.or([
|
|
138
|
+
eb('tenant_id' as any, '=', params.tenantId),
|
|
139
|
+
eb('tenant_id' as any, 'is', null),
|
|
140
|
+
]))
|
|
141
|
+
} else {
|
|
142
|
+
defQuery = defQuery.where('tenant_id' as any, 'is', null)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const defRows = await defQuery.execute() as Array<Record<string, any>>
|
|
146
|
+
const definitionIndex = buildCustomFieldDefinitionIndexFromRows(
|
|
147
|
+
defRows.map((row): CustomFieldDefinitionRow => ({
|
|
148
|
+
key: String(row.key ?? ''),
|
|
149
|
+
entityId: params.entityType,
|
|
150
|
+
kind: typeof row.kind === 'string' ? row.kind : null,
|
|
151
|
+
configJson: row.config_json,
|
|
152
|
+
organizationId: row.organization_id ?? null,
|
|
153
|
+
tenantId: row.tenant_id ?? null,
|
|
154
|
+
deletedAt: row.deleted_at ?? null,
|
|
155
|
+
updatedAt: row.updated_at ?? null,
|
|
156
|
+
})),
|
|
157
|
+
{ organizationIds: params.organizationId ? [params.organizationId] : [] },
|
|
158
|
+
)
|
|
159
|
+
for (const key of fieldKeys) {
|
|
160
|
+
const definitions = definitionIndex.get(normalizeDefinitionKey(key)) ?? []
|
|
161
|
+
const definition = selectDefinitionForRecord(
|
|
162
|
+
definitions,
|
|
163
|
+
params.organizationId ?? null,
|
|
164
|
+
params.tenantId ?? null,
|
|
165
|
+
)
|
|
166
|
+
if (definition?.multi) multiKeys.add(key)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
99
170
|
for (const [key, arr] of Object.entries(cfMap)) {
|
|
100
|
-
//
|
|
101
|
-
|
|
171
|
+
// Cardinality belongs to the field definition, not the current row count:
|
|
172
|
+
// a multi field containing one value must still hydrate as an array.
|
|
173
|
+
const fieldKey = key.slice(3)
|
|
174
|
+
doc[key] = multiKeys.has(fieldKey) || arr.length > 1 ? arr : arr[0]
|
|
102
175
|
}
|
|
103
176
|
|
|
104
177
|
// Attach translations under flat keys 'l10n:{locale}:{field}'
|
|
@@ -181,12 +254,13 @@ function scopeEntityIndexes<QB extends { where: (...args: any[]) => QB }>(
|
|
|
181
254
|
|
|
182
255
|
export async function upsertIndexRow(
|
|
183
256
|
em: EntityManager,
|
|
184
|
-
args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null; deferSearchTokens?: boolean }
|
|
257
|
+
args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null; deferSearchTokens?: boolean; trx?: QueryIndexExecutor }
|
|
185
258
|
): Promise<UpsertIndexResult> {
|
|
186
259
|
const db = (em as any).getKysely()
|
|
260
|
+
const executor = args.trx ?? db
|
|
187
261
|
|
|
188
262
|
const existing = await scopeEntityIndexes(
|
|
189
|
-
|
|
263
|
+
executor.selectFrom('entity_indexes' as any).select(['id' as any, 'deleted_at' as any]),
|
|
190
264
|
args,
|
|
191
265
|
).executeTakeFirst() as { id: string; deleted_at: Date | null } | undefined
|
|
192
266
|
|
|
@@ -205,12 +279,15 @@ export async function upsertIndexRow(
|
|
|
205
279
|
organizationId: args.organizationId ?? null,
|
|
206
280
|
tenantId: args.tenantId ?? null,
|
|
207
281
|
doc: null,
|
|
282
|
+
trx: args.trx,
|
|
208
283
|
})
|
|
209
|
-
} catch {
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (args.trx) throw error
|
|
286
|
+
}
|
|
210
287
|
}
|
|
211
288
|
if (existed) {
|
|
212
289
|
await scopeEntityIndexes(
|
|
213
|
-
|
|
290
|
+
executor.deleteFrom('entity_indexes' as any) as any,
|
|
214
291
|
args,
|
|
215
292
|
).execute()
|
|
216
293
|
}
|
|
@@ -230,7 +307,7 @@ export async function upsertIndexRow(
|
|
|
230
307
|
|
|
231
308
|
// Prefer modern upsert keyed by coalesced org id when available; fallback to update-then-insert
|
|
232
309
|
try {
|
|
233
|
-
await
|
|
310
|
+
await executor
|
|
234
311
|
.insertInto('entity_indexes' as any)
|
|
235
312
|
.values({ ...payload, created_at: sql`now()` } as any)
|
|
236
313
|
.onConflict((oc: any) => oc
|
|
@@ -246,16 +323,18 @@ export async function upsertIndexRow(
|
|
|
246
323
|
} catch {
|
|
247
324
|
// Fallback for schemas without organization_id_coalesced column/index
|
|
248
325
|
const updated = await scopeEntityIndexes(
|
|
249
|
-
|
|
326
|
+
executor.updateTable('entity_indexes' as any).set(payload as any) as any,
|
|
250
327
|
args,
|
|
251
328
|
).executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined
|
|
252
329
|
if (!updated || Number(updated.numUpdatedRows ?? 0) === 0) {
|
|
253
330
|
try {
|
|
254
|
-
await
|
|
331
|
+
await executor
|
|
255
332
|
.insertInto('entity_indexes' as any)
|
|
256
333
|
.values({ ...payload, created_at: sql`now()` } as any)
|
|
257
334
|
.execute()
|
|
258
|
-
} catch {
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (args.trx) throw error
|
|
337
|
+
}
|
|
259
338
|
}
|
|
260
339
|
}
|
|
261
340
|
|
|
@@ -273,8 +352,11 @@ export async function upsertIndexRow(
|
|
|
273
352
|
tenantId: args.tenantId ?? null,
|
|
274
353
|
doc,
|
|
275
354
|
searchTokenDoc: args.searchTokenDoc ?? null,
|
|
355
|
+
trx: args.trx,
|
|
276
356
|
})
|
|
277
|
-
} catch {
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (args.trx) throw error
|
|
359
|
+
}
|
|
278
360
|
}
|
|
279
361
|
return { doc, existed, wasDeleted, created, revived }
|
|
280
362
|
}
|
|
@@ -294,6 +376,7 @@ export async function reindexSearchTokensForRecord(
|
|
|
294
376
|
tenantId?: string | null
|
|
295
377
|
doc: Record<string, any> | null
|
|
296
378
|
searchTokenDoc?: Record<string, unknown> | null
|
|
379
|
+
trx?: QueryIndexExecutor
|
|
297
380
|
},
|
|
298
381
|
): Promise<void> {
|
|
299
382
|
const db = (em as any).getKysely()
|
|
@@ -303,7 +386,7 @@ export async function reindexSearchTokensForRecord(
|
|
|
303
386
|
recordId: args.recordId,
|
|
304
387
|
organizationId: args.organizationId ?? null,
|
|
305
388
|
tenantId: args.tenantId ?? null,
|
|
306
|
-
})
|
|
389
|
+
}, { trx: args.trx })
|
|
307
390
|
return
|
|
308
391
|
}
|
|
309
392
|
const tokenDoc = args.searchTokenDoc ?? (() => {
|
|
@@ -323,16 +406,17 @@ export async function reindexSearchTokensForRecord(
|
|
|
323
406
|
organizationId: args.organizationId ?? null,
|
|
324
407
|
tenantId: args.tenantId ?? null,
|
|
325
408
|
doc: await tokenDoc,
|
|
326
|
-
})
|
|
409
|
+
}, { trx: args.trx })
|
|
327
410
|
}
|
|
328
411
|
|
|
329
412
|
export async function markDeleted(
|
|
330
413
|
em: EntityManager,
|
|
331
|
-
args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null }
|
|
414
|
+
args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; trx?: QueryIndexExecutor }
|
|
332
415
|
): Promise<{ wasActive: boolean }> {
|
|
333
416
|
const db = (em as any).getKysely()
|
|
417
|
+
const executor = args.trx ?? db
|
|
334
418
|
const existing = await scopeEntityIndexes(
|
|
335
|
-
|
|
419
|
+
executor.selectFrom('entity_indexes' as any).select(['deleted_at' as any]),
|
|
336
420
|
args,
|
|
337
421
|
).executeTakeFirst() as { deleted_at: Date | null } | undefined
|
|
338
422
|
|
|
@@ -345,10 +429,12 @@ export async function markDeleted(
|
|
|
345
429
|
recordId: args.recordId,
|
|
346
430
|
organizationId: args.organizationId ?? null,
|
|
347
431
|
tenantId: args.tenantId ?? null,
|
|
348
|
-
})
|
|
349
|
-
} catch {
|
|
432
|
+
}, { trx: args.trx })
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (args.trx) throw error
|
|
435
|
+
}
|
|
350
436
|
await scopeEntityIndexes(
|
|
351
|
-
|
|
437
|
+
executor.deleteFrom('entity_indexes' as any) as any,
|
|
352
438
|
args,
|
|
353
439
|
).execute()
|
|
354
440
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Kysely, sql } from 'kysely'
|
|
1
|
+
import { type Kysely, type Transaction, sql } from 'kysely'
|
|
2
2
|
import {
|
|
3
3
|
isSearchFieldBlocklisted,
|
|
4
4
|
resolveSearchConfig,
|
|
@@ -40,6 +40,7 @@ type BuildTokenOptions = {
|
|
|
40
40
|
|
|
41
41
|
const DEFAULT_SCOPE = { organizationId: null, tenantId: null }
|
|
42
42
|
type EntityFieldPair = [string, string]
|
|
43
|
+
type SearchTokenExecutor = Kysely<any> | Transaction<any>
|
|
43
44
|
|
|
44
45
|
export const isSearchDebugEnabled = (): boolean => {
|
|
45
46
|
return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true
|
|
@@ -151,7 +152,8 @@ function buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null)
|
|
|
151
152
|
|
|
152
153
|
export async function replaceSearchTokensForRecord(
|
|
153
154
|
db: Kysely<any>,
|
|
154
|
-
params: BuildTokenOptions
|
|
155
|
+
params: BuildTokenOptions,
|
|
156
|
+
options?: { trx?: SearchTokenExecutor },
|
|
155
157
|
): Promise<void> {
|
|
156
158
|
const rows = buildSearchTokenRows(params)
|
|
157
159
|
const config = params.config ?? resolveSearchConfig()
|
|
@@ -160,8 +162,8 @@ export async function replaceSearchTokensForRecord(
|
|
|
160
162
|
const tenantId = params.tenantId ?? null
|
|
161
163
|
const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)
|
|
162
164
|
|
|
163
|
-
|
|
164
|
-
let deleteQuery =
|
|
165
|
+
const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {
|
|
166
|
+
let deleteQuery = executor
|
|
165
167
|
.deleteFrom('search_tokens' as any)
|
|
166
168
|
.where('entity_type' as any, '=', params.entityType)
|
|
167
169
|
.where(sql<boolean>`organization_id is not distinct from ${organizationId}`)
|
|
@@ -180,18 +182,27 @@ export async function replaceSearchTokensForRecord(
|
|
|
180
182
|
if (!rows.length) return
|
|
181
183
|
const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))
|
|
182
184
|
for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {
|
|
183
|
-
await
|
|
185
|
+
await executor.insertInto('search_tokens' as any).values(batch as any).execute()
|
|
184
186
|
}
|
|
185
|
-
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (options?.trx) {
|
|
190
|
+
await writeTokens(options.trx)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await db.transaction().execute(writeTokens)
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
export async function deleteSearchTokensForRecord(
|
|
189
198
|
db: Kysely<any>,
|
|
190
|
-
params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null }
|
|
199
|
+
params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },
|
|
200
|
+
options?: { trx?: SearchTokenExecutor },
|
|
191
201
|
): Promise<void> {
|
|
192
202
|
const organizationId = params.organizationId ?? null
|
|
193
203
|
const tenantId = params.tenantId ?? null
|
|
194
|
-
|
|
204
|
+
const executor = options?.trx ?? db
|
|
205
|
+
await executor
|
|
195
206
|
.deleteFrom('search_tokens' as any)
|
|
196
207
|
.where('entity_type' as any, '=', params.entityType)
|
|
197
208
|
.where('entity_id' as any, '=', String(params.recordId))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
|
|
3
|
+
import { isReadProjectionAlwaysConsistent } from '@open-mercato/shared/lib/data/consistency'
|
|
3
4
|
import { refreshCoverageSnapshot } from '../lib/coverage'
|
|
4
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
6
|
|
|
@@ -70,6 +71,9 @@ export default async function handle(payload: Payload, ctx: { resolve: <T = any>
|
|
|
70
71
|
payload,
|
|
71
72
|
},
|
|
72
73
|
)
|
|
74
|
+
if (isReadProjectionAlwaysConsistent()) {
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
}
|
|
75
79
|
|