@open-mercato/core 0.6.6-develop.6535.1.5cf43724de → 0.6.6-develop.6540.1.ae856df1ec
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/dist/modules/api_keys/cli.js +2 -1
- package/dist/modules/api_keys/cli.js.map +2 -2
- package/dist/modules/attachments/api/file/[id]/route.js +9 -7
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +9 -7
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +21 -17
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/lib/reconcileOrganization.js +110 -0
- package/dist/modules/attachments/lib/reconcileOrganization.js.map +7 -0
- package/dist/modules/attachments/lib/requestScope.js +10 -0
- package/dist/modules/attachments/lib/requestScope.js.map +7 -0
- package/dist/modules/auth/api/users/route.js +6 -2
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/cli.js +3 -2
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/configs/lib/upgrade-actions.js +22 -0
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/dashboards/cli.js +4 -3
- package/dist/modules/dashboards/cli.js.map +2 -2
- package/dist/modules/directory/utils/organizationScope.js +10 -3
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/entities/api/records.js +5 -4
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/cli.js +2 -1
- package/dist/modules/entities/cli.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/cli.ts +2 -3
- package/src/modules/attachments/api/file/[id]/route.ts +11 -7
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +11 -7
- package/src/modules/attachments/api/route.ts +26 -21
- package/src/modules/attachments/lib/reconcileOrganization.ts +188 -0
- package/src/modules/attachments/lib/requestScope.ts +27 -0
- package/src/modules/auth/api/users/route.ts +11 -2
- package/src/modules/auth/cli.ts +3 -2
- package/src/modules/configs/i18n/de.json +5 -1
- package/src/modules/configs/i18n/en.json +5 -1
- package/src/modules/configs/i18n/es.json +5 -1
- package/src/modules/configs/i18n/pl.json +5 -1
- package/src/modules/configs/lib/upgrade-actions.ts +24 -0
- package/src/modules/dashboards/cli.ts +4 -9
- package/src/modules/directory/utils/organizationScope.ts +36 -2
- package/src/modules/entities/api/records.ts +5 -7
- package/src/modules/entities/cli.ts +2 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
|
+
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
4
|
+
import { Attachment } from '../data/entities'
|
|
5
|
+
|
|
6
|
+
const logger = createLogger('attachments').child({ component: 'reconcileOrganization' })
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Virtual entity id for library attachments — these are not attached to a
|
|
10
|
+
* parent record that owns an organization, so there is nothing to reconcile
|
|
11
|
+
* against. They are counted as skipped rather than unresolved.
|
|
12
|
+
*/
|
|
13
|
+
const LIBRARY_ENTITY_ID = 'attachments:library'
|
|
14
|
+
|
|
15
|
+
const DEFAULT_BATCH_SIZE = 500
|
|
16
|
+
|
|
17
|
+
export type AttachmentOrgReconcileEntityStat = {
|
|
18
|
+
scanned: number
|
|
19
|
+
updated: number
|
|
20
|
+
unresolved: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type AttachmentOrgReconcileReport = {
|
|
24
|
+
scanned: number
|
|
25
|
+
updated: number
|
|
26
|
+
unresolved: number
|
|
27
|
+
skippedVirtual: number
|
|
28
|
+
byEntity: Record<string, AttachmentOrgReconcileEntityStat>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type AttachmentScanRow = {
|
|
32
|
+
id: string
|
|
33
|
+
entity_id: string | null
|
|
34
|
+
record_id: string | null
|
|
35
|
+
organization_id: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readParentOrganizationId(record: Record<string, unknown>): string | null {
|
|
39
|
+
const raw = record['organization_id'] ?? record['organizationId']
|
|
40
|
+
if (typeof raw === 'string' && raw.trim().length) return raw.trim()
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ensureBucket(
|
|
45
|
+
report: AttachmentOrgReconcileReport,
|
|
46
|
+
entityId: string,
|
|
47
|
+
): AttachmentOrgReconcileEntityStat {
|
|
48
|
+
const existing = report.byEntity[entityId]
|
|
49
|
+
if (existing) return existing
|
|
50
|
+
const bucket: AttachmentOrgReconcileEntityStat = { scanned: 0, updated: 0, unresolved: 0 }
|
|
51
|
+
report.byEntity[entityId] = bucket
|
|
52
|
+
return bucket
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reconcile the `organization_id` of existing attachments to the organization
|
|
57
|
+
* of the record they are attached to.
|
|
58
|
+
*
|
|
59
|
+
* Background (#3765): before the upload route became selected-organization
|
|
60
|
+
* aware, a multi-org admin who switched the header organization uploaded files
|
|
61
|
+
* that were silently stored under their *home* organization instead of the
|
|
62
|
+
* selected one (the organization of the parent record). Those rows are now
|
|
63
|
+
* invisible to org-scoped reads and stay orphaned. This heals them forward.
|
|
64
|
+
*
|
|
65
|
+
* The parent record's organization is the ground truth: an attachment is not
|
|
66
|
+
* distinguishable from a legitimately home-org one by looking at the attachment
|
|
67
|
+
* alone. Parent organizations are resolved generically through the Query Engine
|
|
68
|
+
* by `entityId` (works for both base and custom entities, tenant-scoped and
|
|
69
|
+
* organization-agnostic so the parent's real org is returned regardless of
|
|
70
|
+
* which org the caller sits in) — the same mechanism the attachments module
|
|
71
|
+
* already uses to enrich assignment details.
|
|
72
|
+
*
|
|
73
|
+
* The reconciliation is:
|
|
74
|
+
* - idempotent — rows already matching their parent are left untouched;
|
|
75
|
+
* - conservative — when the parent org cannot be resolved (unregistered
|
|
76
|
+
* entity id, hard-deleted parent, parent without an org column) the row is
|
|
77
|
+
* counted as `unresolved` and left as-is rather than guessed at;
|
|
78
|
+
* - tenant-scoped — it reconciles every attachment in the tenant across all
|
|
79
|
+
* organizations (the whole point is moving rows between orgs).
|
|
80
|
+
*
|
|
81
|
+
* Writes go through the passed `EntityManager` (which the caller runs inside a
|
|
82
|
+
* transaction) so the whole reconciliation commits atomically.
|
|
83
|
+
*/
|
|
84
|
+
export async function reconcileAttachmentOrganizations(opts: {
|
|
85
|
+
em: EntityManager
|
|
86
|
+
queryEngine: QueryEngine
|
|
87
|
+
tenantId: string
|
|
88
|
+
batchSize?: number
|
|
89
|
+
}): Promise<AttachmentOrgReconcileReport> {
|
|
90
|
+
const { em, queryEngine, tenantId } = opts
|
|
91
|
+
const batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : DEFAULT_BATCH_SIZE
|
|
92
|
+
const report: AttachmentOrgReconcileReport = {
|
|
93
|
+
scanned: 0,
|
|
94
|
+
updated: 0,
|
|
95
|
+
unresolved: 0,
|
|
96
|
+
skippedVirtual: 0,
|
|
97
|
+
byEntity: {},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rows = (await em.getConnection().execute(
|
|
101
|
+
'select id, entity_id, record_id, organization_id from attachments where tenant_id = ?',
|
|
102
|
+
[tenantId],
|
|
103
|
+
)) as AttachmentScanRow[]
|
|
104
|
+
report.scanned = rows.length
|
|
105
|
+
if (!rows.length) return report
|
|
106
|
+
|
|
107
|
+
const groups = new Map<string, AttachmentScanRow[]>()
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
if (!row.id || !row.entity_id || !row.record_id) continue
|
|
110
|
+
const list = groups.get(row.entity_id) ?? []
|
|
111
|
+
list.push(row)
|
|
112
|
+
groups.set(row.entity_id, list)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pendingUpdates: Array<{ id: string; organizationId: string }> = []
|
|
116
|
+
|
|
117
|
+
for (const [entityId, group] of groups) {
|
|
118
|
+
const bucket = ensureBucket(report, entityId)
|
|
119
|
+
bucket.scanned += group.length
|
|
120
|
+
|
|
121
|
+
if (entityId === LIBRARY_ENTITY_ID) {
|
|
122
|
+
report.skippedVirtual += group.length
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const recordIds = Array.from(
|
|
127
|
+
new Set(group.map((row) => row.record_id).filter((value): value is string => !!value)),
|
|
128
|
+
)
|
|
129
|
+
const parentOrgByRecordId = new Map<string, string | null>()
|
|
130
|
+
let resolvable = true
|
|
131
|
+
|
|
132
|
+
for (let index = 0; index < recordIds.length; index += batchSize) {
|
|
133
|
+
const chunk = recordIds.slice(index, index + batchSize)
|
|
134
|
+
try {
|
|
135
|
+
const result = await queryEngine.query(entityId as any, {
|
|
136
|
+
fields: ['id', 'organization_id'],
|
|
137
|
+
filters: { id: chunk.length === 1 ? { $eq: chunk[0] } : { $in: chunk } },
|
|
138
|
+
tenantId,
|
|
139
|
+
withDeleted: true,
|
|
140
|
+
page: { pageSize: Math.max(chunk.length, 1) },
|
|
141
|
+
})
|
|
142
|
+
for (const item of result.items ?? []) {
|
|
143
|
+
const record = item as Record<string, unknown>
|
|
144
|
+
const recordId = record.id != null ? String(record.id) : null
|
|
145
|
+
if (!recordId) continue
|
|
146
|
+
parentOrgByRecordId.set(recordId, readParentOrganizationId(record))
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
resolvable = false
|
|
150
|
+
logger.warn('org reconcile: cannot resolve parent organization for entity', {
|
|
151
|
+
entityId,
|
|
152
|
+
error,
|
|
153
|
+
})
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!resolvable) {
|
|
159
|
+
bucket.unresolved += group.length
|
|
160
|
+
report.unresolved += group.length
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const row of group) {
|
|
165
|
+
const target = row.record_id ? parentOrgByRecordId.get(row.record_id) : null
|
|
166
|
+
if (!target) {
|
|
167
|
+
bucket.unresolved += 1
|
|
168
|
+
report.unresolved += 1
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
if ((row.organization_id ?? null) === target) continue
|
|
172
|
+
pendingUpdates.push({ id: row.id, organizationId: target })
|
|
173
|
+
bucket.updated += 1
|
|
174
|
+
report.updated += 1
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (let index = 0; index < pendingUpdates.length; index += batchSize) {
|
|
179
|
+
const chunk = pendingUpdates.slice(index, index + batchSize)
|
|
180
|
+
for (const update of chunk) {
|
|
181
|
+
const reference = em.getReference(Attachment, update.id)
|
|
182
|
+
reference.organizationId = update.organizationId
|
|
183
|
+
}
|
|
184
|
+
await em.flush()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return report
|
|
188
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the organization an attachment request should act within.
|
|
7
|
+
*
|
|
8
|
+
* `auth.orgId` alone is NOT selected-organization aware for non-superadmin
|
|
9
|
+
* principals: `applySuperAdminScope` only rewrites `orgId` from the
|
|
10
|
+
* `om_selected_org` cookie for superadmins, so a regular multi-org admin who
|
|
11
|
+
* switches the header organization keeps `auth.orgId` pinned to their own home
|
|
12
|
+
* organization. The CRUD factory and other org-scoped routes derive the active
|
|
13
|
+
* organization via `resolveOrganizationScopeForRequest` (cookie-driven and
|
|
14
|
+
* RBAC-validated for ALL users, falling back to the home org when the selection
|
|
15
|
+
* is absent or inaccessible). Attachments must do the same so uploaded files
|
|
16
|
+
* land under — and are read back from — the currently selected organization
|
|
17
|
+
* rather than the uploader's home organization (#3765).
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveAttachmentOrganizationId(
|
|
20
|
+
container: AwilixContainer,
|
|
21
|
+
auth: AuthContext,
|
|
22
|
+
request: Request,
|
|
23
|
+
): Promise<string | null> {
|
|
24
|
+
if (!auth) return null
|
|
25
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request })
|
|
26
|
+
return scope?.selectedId ?? auth.orgId ?? null
|
|
27
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
23
23
|
import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
|
|
24
24
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
25
|
+
import { parseBooleanFlag } from '@open-mercato/shared/lib/boolean'
|
|
25
26
|
import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
|
|
26
27
|
import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
|
|
27
28
|
import { sql } from 'kysely'
|
|
@@ -41,6 +42,7 @@ const querySchema = z.object({
|
|
|
41
42
|
search: z.string().optional(),
|
|
42
43
|
name: z.string().optional(),
|
|
43
44
|
organizationId: z.string().uuid().optional(),
|
|
45
|
+
scopeToActiveOrganization: z.boolean().optional(),
|
|
44
46
|
roleIds: z.array(z.string().uuid()).optional(),
|
|
45
47
|
}).passthrough()
|
|
46
48
|
|
|
@@ -180,6 +182,7 @@ export async function GET(req: Request) {
|
|
|
180
182
|
search: url.searchParams.get('search') || undefined,
|
|
181
183
|
name: url.searchParams.get('name') || undefined,
|
|
182
184
|
organizationId: url.searchParams.get('organizationId') || undefined,
|
|
185
|
+
scopeToActiveOrganization: parseBooleanFlag(url.searchParams.get('scopeToActiveOrganization') || undefined),
|
|
183
186
|
roleIds: rawRoleIds.length ? rawRoleIds : undefined,
|
|
184
187
|
})
|
|
185
188
|
if (!parsed.success) return NextResponse.json({ items: [], total: 0, totalPages: 1 })
|
|
@@ -195,7 +198,7 @@ export async function GET(req: Request) {
|
|
|
195
198
|
} catch (err) {
|
|
196
199
|
logger.error('Failed to resolve rbac', { err })
|
|
197
200
|
}
|
|
198
|
-
const { id, page, pageSize, search, name, organizationId, roleIds } = parsed.data
|
|
201
|
+
const { id, page, pageSize, search, name, organizationId, scopeToActiveOrganization, roleIds } = parsed.data
|
|
199
202
|
const filters: any[] = [{ deletedAt: null }]
|
|
200
203
|
const actorTenantId = auth.tenantId ? String(auth.tenantId) : null
|
|
201
204
|
let effectiveTenantId: string | null = null
|
|
@@ -244,6 +247,12 @@ export async function GET(req: Request) {
|
|
|
244
247
|
? effectiveSelectedOrganizationId
|
|
245
248
|
: auth.orgId ?? null
|
|
246
249
|
if (organizationId) filters.push({ organizationId })
|
|
250
|
+
// Recipient/assignee pickers scope to the caller's active organization so they never
|
|
251
|
+
// suggest users outside it. A message composed here is stamped with the caller's
|
|
252
|
+
// active org (auth.orgId), and the message detail endpoint enforces
|
|
253
|
+
// hasOrganizationAccess(scope.organizationId, message.organizationId); scoping the
|
|
254
|
+
// suggestions to the same org keeps a picked recipient able to open what they were sent.
|
|
255
|
+
if (scopeToActiveOrganization) filters.push({ organizationId: auth.orgId ?? null })
|
|
247
256
|
const trimmedName = typeof name === 'string' ? name.trim() : ''
|
|
248
257
|
if (trimmedName) {
|
|
249
258
|
const searchPattern = `%${escapeLikePattern(trimmedName)}%`
|
|
@@ -620,7 +629,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
620
629
|
GET: {
|
|
621
630
|
summary: 'List users',
|
|
622
631
|
description:
|
|
623
|
-
'Returns users for the effective selected tenant and organization scope. Search matches email, organization name, and role name. Super administrators may scope the response via the topbar context, organization filters, or role filters.',
|
|
632
|
+
'Returns users for the effective selected tenant and organization scope. Search matches email, organization name, and role name. Super administrators may scope the response via the topbar context, organization filters, or role filters. Pass scopeToActiveOrganization=1 to restrict results to the caller\'s active organization (used by recipient/assignee pickers so suggestions stay within the org that owns the resulting record).',
|
|
624
633
|
query: querySchema,
|
|
625
634
|
responses: [
|
|
626
635
|
{ status: 200, description: 'User collection', schema: userListResponseSchema },
|
package/src/modules/auth/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/
|
|
|
9
9
|
import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
|
|
10
10
|
import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError, DerivedUserPasswordRequiredError } from './lib/setup-app'
|
|
11
11
|
import { normalizeTenantId } from './lib/tenantAccess'
|
|
12
|
+
import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
|
|
12
13
|
import { computeEmailHash, emailHashLookupValues } from './lib/emailHash'
|
|
13
14
|
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
14
15
|
import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
|
|
@@ -71,7 +72,7 @@ const addUser: ModuleCli = {
|
|
|
71
72
|
})
|
|
72
73
|
await em.persist(u).flush()
|
|
73
74
|
if (rolesCsv) {
|
|
74
|
-
const names = rolesCsv
|
|
75
|
+
const names = parseCommaSeparatedList(rolesCsv)
|
|
75
76
|
for (const name of names) {
|
|
76
77
|
const role = await resolveTenantScopedRole(em, name, normalizedTenantId)
|
|
77
78
|
const link = em.create(UserRole, { user: u, role })
|
|
@@ -493,7 +494,7 @@ const setupApp: ModuleCli = {
|
|
|
493
494
|
const container = await createRequestContainer()
|
|
494
495
|
const em = container.resolve<EntityManager>('em')
|
|
495
496
|
const roleNames = rolesCsv
|
|
496
|
-
? rolesCsv
|
|
497
|
+
? parseCommaSeparatedList(rolesCsv)
|
|
497
498
|
: undefined
|
|
498
499
|
|
|
499
500
|
try {
|
|
@@ -189,5 +189,9 @@
|
|
|
189
189
|
"configs.systemStatus.variables.queryIndexWarmupThrottleMs.label": "Warmup-Drosselfenster",
|
|
190
190
|
"configs.systemStatus.variables.scheduleAutoReindex.description": "Automatisch Query-Index-Neuindizierungsaufträge bei Lücken planen.",
|
|
191
191
|
"configs.systemStatus.variables.scheduleAutoReindex.label": "Automatische Neuindizierung",
|
|
192
|
-
"configs.systemStatus.viewDocs": "Dokumentation anzeigen"
|
|
192
|
+
"configs.systemStatus.viewDocs": "Dokumentation anzeigen",
|
|
193
|
+
"configs.upgrades.attachmentsOrgReconcile.cta": "Anhang-Organisationen abgleichen",
|
|
194
|
+
"configs.upgrades.attachmentsOrgReconcile.loading": "Anhang-Organisationen werden abgeglichen…",
|
|
195
|
+
"configs.upgrades.attachmentsOrgReconcile.message": "Version {{version}} behebt Anhänge, die unter der falschen Organisation gespeichert wurden. Führen Sie den Abgleich aus, um vorhandene Anhänge in die Organisation des zugehörigen Datensatzes zu verschieben.",
|
|
196
|
+
"configs.upgrades.attachmentsOrgReconcile.success": "Anhang-Organisationen abgeglichen."
|
|
193
197
|
}
|
|
@@ -189,5 +189,9 @@
|
|
|
189
189
|
"configs.systemStatus.variables.queryIndexWarmupThrottleMs.label": "Warmup throttle window",
|
|
190
190
|
"configs.systemStatus.variables.scheduleAutoReindex.description": "Automatically schedule query index reindex jobs when gaps appear.",
|
|
191
191
|
"configs.systemStatus.variables.scheduleAutoReindex.label": "Auto reindex",
|
|
192
|
-
"configs.systemStatus.viewDocs": "View documentation"
|
|
192
|
+
"configs.systemStatus.viewDocs": "View documentation",
|
|
193
|
+
"configs.upgrades.attachmentsOrgReconcile.cta": "Reconcile attachment organizations",
|
|
194
|
+
"configs.upgrades.attachmentsOrgReconcile.loading": "Reconciling attachment organizations…",
|
|
195
|
+
"configs.upgrades.attachmentsOrgReconcile.message": "Version {{version}} fixes attachments that were saved under the wrong organization. Run the reconciliation to move existing attachments to the organization of the record they belong to.",
|
|
196
|
+
"configs.upgrades.attachmentsOrgReconcile.success": "Attachment organizations reconciled."
|
|
193
197
|
}
|
|
@@ -189,5 +189,9 @@
|
|
|
189
189
|
"configs.systemStatus.variables.queryIndexWarmupThrottleMs.label": "Ventana de limitación del calentamiento",
|
|
190
190
|
"configs.systemStatus.variables.scheduleAutoReindex.description": "Programar automáticamente trabajos de reindexación cuando se detecten brechas.",
|
|
191
191
|
"configs.systemStatus.variables.scheduleAutoReindex.label": "Reindexación automática",
|
|
192
|
-
"configs.systemStatus.viewDocs": "Ver documentación"
|
|
192
|
+
"configs.systemStatus.viewDocs": "Ver documentación",
|
|
193
|
+
"configs.upgrades.attachmentsOrgReconcile.cta": "Reconciliar organizaciones de adjuntos",
|
|
194
|
+
"configs.upgrades.attachmentsOrgReconcile.loading": "Reconciliando organizaciones de adjuntos…",
|
|
195
|
+
"configs.upgrades.attachmentsOrgReconcile.message": "La versión {{version}} corrige los adjuntos guardados en la organización incorrecta. Ejecuta la reconciliación para mover los adjuntos existentes a la organización del registro al que pertenecen.",
|
|
196
|
+
"configs.upgrades.attachmentsOrgReconcile.success": "Organizaciones de adjuntos reconciliadas."
|
|
193
197
|
}
|
|
@@ -189,5 +189,9 @@
|
|
|
189
189
|
"configs.systemStatus.variables.queryIndexWarmupThrottleMs.label": "Okno throttlingu rozgrzewki",
|
|
190
190
|
"configs.systemStatus.variables.scheduleAutoReindex.description": "Automatycznie uruchamia przebudowę indeksu zapytań, gdy wykryte zostaną luki.",
|
|
191
191
|
"configs.systemStatus.variables.scheduleAutoReindex.label": "Automatyczna przebudowa indeksu",
|
|
192
|
-
"configs.systemStatus.viewDocs": "Zobacz dokumentację"
|
|
192
|
+
"configs.systemStatus.viewDocs": "Zobacz dokumentację",
|
|
193
|
+
"configs.upgrades.attachmentsOrgReconcile.cta": "Uzgodnij organizacje załączników",
|
|
194
|
+
"configs.upgrades.attachmentsOrgReconcile.loading": "Uzgadnianie organizacji załączników…",
|
|
195
|
+
"configs.upgrades.attachmentsOrgReconcile.message": "Wersja {{version}} naprawia załączniki zapisane pod niewłaściwą organizacją. Uruchom uzgadnianie, aby przenieść istniejące załączniki do organizacji rekordu, do którego należą.",
|
|
196
|
+
"configs.upgrades.attachmentsOrgReconcile.success": "Organizacje załączników zostały uzgodnione."
|
|
193
197
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import * as semver from 'semver'
|
|
3
3
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
|
+
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
6
|
+
import { reconcileAttachmentOrganizations } from '@open-mercato/core/modules/attachments/lib/reconcileOrganization'
|
|
7
|
+
|
|
8
|
+
const logger = createLogger('configs').child({ component: 'upgrade-actions' })
|
|
4
9
|
|
|
5
10
|
export type UpgradeActionContext = {
|
|
6
11
|
tenantId: string
|
|
@@ -38,6 +43,25 @@ export function compareVersions(a: string, b: string): number {
|
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
export const upgradeActions: UpgradeActionDefinition[] = [
|
|
46
|
+
{
|
|
47
|
+
id: 'attachments.reconcile-organization',
|
|
48
|
+
version: '0.6.6',
|
|
49
|
+
messageKey: 'configs.upgrades.attachmentsOrgReconcile.message',
|
|
50
|
+
ctaKey: 'configs.upgrades.attachmentsOrgReconcile.cta',
|
|
51
|
+
successKey: 'configs.upgrades.attachmentsOrgReconcile.success',
|
|
52
|
+
loadingKey: 'configs.upgrades.attachmentsOrgReconcile.loading',
|
|
53
|
+
async run({ container, em, tenantId }) {
|
|
54
|
+
const queryEngine = container.resolve('queryEngine') as QueryEngine
|
|
55
|
+
const report = await reconcileAttachmentOrganizations({ em, queryEngine, tenantId })
|
|
56
|
+
logger.info('attachments organization reconcile completed', {
|
|
57
|
+
tenantId,
|
|
58
|
+
scanned: report.scanned,
|
|
59
|
+
updated: report.updated,
|
|
60
|
+
unresolved: report.unresolved,
|
|
61
|
+
skippedVirtual: report.skippedVirtual,
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
},
|
|
41
65
|
{
|
|
42
66
|
id: 'customers.seed-interaction-statuses',
|
|
43
67
|
version: '0.6.5',
|
|
@@ -6,6 +6,7 @@ import { Role } from '@open-mercato/core/modules/auth/data/entities'
|
|
|
6
6
|
import { loadAllWidgets } from '@open-mercato/core/modules/dashboards/lib/widgets'
|
|
7
7
|
import { appendWidgetsToRoles, resolveAnalyticsWidgetIds } from '@open-mercato/core/modules/dashboards/lib/role-widgets'
|
|
8
8
|
import { seedAnalyticsData } from './seed/analytics'
|
|
9
|
+
import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
|
|
9
10
|
|
|
10
11
|
type Args = Record<string, string>
|
|
11
12
|
|
|
@@ -108,10 +109,7 @@ const seedDefaults: ModuleCli = {
|
|
|
108
109
|
return
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
const roleNames = roleCsv
|
|
112
|
-
.split(',')
|
|
113
|
-
.map((name) => name.trim())
|
|
114
|
-
.filter(Boolean)
|
|
112
|
+
const roleNames = parseCommaSeparatedList(roleCsv)
|
|
115
113
|
|
|
116
114
|
if (!roleNames.length) {
|
|
117
115
|
console.log('No roles provided, nothing to seed.')
|
|
@@ -125,7 +123,7 @@ const seedDefaults: ModuleCli = {
|
|
|
125
123
|
tenantId,
|
|
126
124
|
organizationId,
|
|
127
125
|
roleNames,
|
|
128
|
-
widgetIds: widgetCsv ? widgetCsv
|
|
126
|
+
widgetIds: widgetCsv ? parseCommaSeparatedList(widgetCsv) : undefined,
|
|
129
127
|
logger: (message) => console.log(message),
|
|
130
128
|
})
|
|
131
129
|
},
|
|
@@ -143,10 +141,7 @@ const enableAnalyticsWidgets: ModuleCli = {
|
|
|
143
141
|
return
|
|
144
142
|
}
|
|
145
143
|
|
|
146
|
-
const roleNames = roleCsv
|
|
147
|
-
.split(',')
|
|
148
|
-
.map((name) => name.trim())
|
|
149
|
-
.filter(Boolean)
|
|
144
|
+
const roleNames = parseCommaSeparatedList(roleCsv)
|
|
150
145
|
|
|
151
146
|
if (!roleNames.length) {
|
|
152
147
|
console.log('No roles provided, nothing to update.')
|
|
@@ -18,6 +18,13 @@ export type OrganizationScope = {
|
|
|
18
18
|
filterIds: string[] | null
|
|
19
19
|
allowedIds: string[] | null
|
|
20
20
|
tenantId: string | null
|
|
21
|
+
// True when the caller explicitly selected a concrete organization (cookie /
|
|
22
|
+
// selectedId param) that could not be honored — it does not exist for the
|
|
23
|
+
// tenant or is not accessible. Reads degrade gracefully (filterIds/selectedId
|
|
24
|
+
// fall back to the caller's accessible orgs), but writes MUST fail loudly on
|
|
25
|
+
// this so a record never lands under an org the caller did not intend. Absent
|
|
26
|
+
// (undefined) means the selection — if any — was honored.
|
|
27
|
+
selectionRejected?: boolean
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
// Phase 4 — short-TTL cache for resolveOrganizationScopeForRequest.
|
|
@@ -79,7 +86,8 @@ function isValidCachedScope(value: unknown): value is OrganizationScope {
|
|
|
79
86
|
const record = value as Partial<OrganizationScope>
|
|
80
87
|
const idOk = (v: unknown) => v === null || typeof v === 'string'
|
|
81
88
|
const arrOk = (v: unknown) => v === null || (Array.isArray(v) && v.every((entry) => typeof entry === 'string'))
|
|
82
|
-
|
|
89
|
+
const flagOk = record.selectionRejected === undefined || typeof record.selectionRejected === 'boolean'
|
|
90
|
+
return idOk(record.selectedId) && idOk(record.tenantId) && arrOk(record.filterIds) && arrOk(record.allowedIds) && flagOk
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
function resolveCacheFromContainer(container: AwilixContainer | null | undefined): CacheStrategy | null {
|
|
@@ -320,9 +328,18 @@ export async function resolveOrganizationScope({
|
|
|
320
328
|
const initialSelected =
|
|
321
329
|
normalizedSelectedId
|
|
322
330
|
?? (widenToAllOrgs ? null : accountOrgId ?? null)
|
|
331
|
+
// A selection is only honored when it resolves to a real, non-deleted org for
|
|
332
|
+
// this tenant. `orgDescendants` holds exactly the existing orgs among the
|
|
333
|
+
// candidate ids, so a stale/dead id (e.g. a selected-org cookie or JWT org
|
|
334
|
+
// that no longer resolves after a DB reset) has no entry and is dropped here.
|
|
335
|
+
// Without the existence guard an unrestricted (all-orgs) principal — whose
|
|
336
|
+
// `allowedSet` is null — would accept the dead id as `effectiveSelected`, and
|
|
337
|
+
// writes derived from `selectedId` would land under an org the read scope
|
|
338
|
+
// (`filterIds`) never filters to, silently orphaning the record.
|
|
339
|
+
const selectionResolvesToOrg = (id: string): boolean => orgDescendants.has(id)
|
|
323
340
|
let effectiveSelected: string | null = null
|
|
324
341
|
if (initialSelected) {
|
|
325
|
-
if (allowedSet === null || allowedSet.has(initialSelected)) {
|
|
342
|
+
if ((allowedSet === null || allowedSet.has(initialSelected)) && selectionResolvesToOrg(initialSelected)) {
|
|
326
343
|
effectiveSelected = initialSelected
|
|
327
344
|
}
|
|
328
345
|
}
|
|
@@ -336,6 +353,13 @@ export async function resolveOrganizationScope({
|
|
|
336
353
|
filterSet = null
|
|
337
354
|
} else if (auth.orgId) {
|
|
338
355
|
filterSet = loadFallbackSet()
|
|
356
|
+
// Keep the write target (`selectedId`) aligned with the read scope
|
|
357
|
+
// (`filterIds`): when an unrestricted principal's requested selection was
|
|
358
|
+
// dropped above, fall the selection back to the account org too so a record
|
|
359
|
+
// created here is always readable back by the same caller.
|
|
360
|
+
if (!effectiveSelected && fallbackOrgId && filterSet && filterSet.size > 0) {
|
|
361
|
+
effectiveSelected = fallbackOrgId
|
|
362
|
+
}
|
|
339
363
|
}
|
|
340
364
|
|
|
341
365
|
if ((!filterSet || filterSet.size === 0) && fallbackOrgId && !widenToAllOrgs) {
|
|
@@ -348,11 +372,21 @@ export async function resolveOrganizationScope({
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
375
|
+
// A concrete organization was explicitly requested (`normalizedSelectedId` is
|
|
376
|
+
// null for both "no selection" and the "all organizations" token) but the
|
|
377
|
+
// resolver could not honor it — it was dropped as non-existent/inaccessible
|
|
378
|
+
// and the effective selection fell back to something else. Surface this so the
|
|
379
|
+
// write layer can reject the request instead of silently creating the record
|
|
380
|
+
// under the fallback org. Only set the field when true to keep the scope shape
|
|
381
|
+
// unchanged for the common (honored) case.
|
|
382
|
+
const selectionRejected = normalizedSelectedId !== null && effectiveSelected !== normalizedSelectedId
|
|
383
|
+
|
|
351
384
|
return {
|
|
352
385
|
selectedId: effectiveSelected,
|
|
353
386
|
filterIds: filterSet ? Array.from(filterSet) : null,
|
|
354
387
|
allowedIds: allowedSet ? Array.from(allowedSet) : null,
|
|
355
388
|
tenantId,
|
|
389
|
+
...(selectionRejected ? { selectionRejected: true } : {}),
|
|
356
390
|
}
|
|
357
391
|
}
|
|
358
392
|
|
|
@@ -8,6 +8,7 @@ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacS
|
|
|
8
8
|
import { resolveOrganizationScope, getSelectedOrganizationFromRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
9
9
|
import { SYSTEM_ENTITY_RECORDS_BLOCKED_CODE, isOrmBackedSystemEntityId } from '@open-mercato/shared/lib/data/engine'
|
|
10
10
|
import { parseBooleanToken, parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
11
|
+
import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
|
|
11
12
|
import { setRecordCustomFields } from '../lib/helpers'
|
|
12
13
|
import { CustomFieldValue } from '../data/entities'
|
|
13
14
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -151,10 +152,7 @@ export async function GET(req: Request) {
|
|
|
151
152
|
const sortDir = (url.searchParams.get('sortDir') || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'
|
|
152
153
|
const withDeleted = parseBooleanWithDefault(url.searchParams.get('withDeleted'), false)
|
|
153
154
|
const searchTerm = (url.searchParams.get('search') || '').trim()
|
|
154
|
-
const searchFields = (url.searchParams.get('searchFields')
|
|
155
|
-
.split(',')
|
|
156
|
-
.map((field) => field.trim())
|
|
157
|
-
.filter(Boolean)
|
|
155
|
+
const searchFields = parseCommaSeparatedList(url.searchParams.get('searchFields'))
|
|
158
156
|
|
|
159
157
|
const qpEntries: Array<[string, string]> = []
|
|
160
158
|
for (const [key, val] of url.searchParams.entries()) {
|
|
@@ -211,11 +209,11 @@ export async function GET(req: Request) {
|
|
|
211
209
|
if (key.startsWith('cf_')) {
|
|
212
210
|
if (key.endsWith('In')) {
|
|
213
211
|
const base = key.slice(0, -2)
|
|
214
|
-
const values = val
|
|
212
|
+
const values = parseCommaSeparatedList(val)
|
|
215
213
|
;(filtersObj as any)[base] = { $in: values }
|
|
216
214
|
} else {
|
|
217
215
|
if (val.includes(',')) {
|
|
218
|
-
const values = val
|
|
216
|
+
const values = parseCommaSeparatedList(val)
|
|
219
217
|
;(filtersObj as any)[key] = { $in: values }
|
|
220
218
|
} else {
|
|
221
219
|
const parsed = parseBooleanToken(val)
|
|
@@ -224,7 +222,7 @@ export async function GET(req: Request) {
|
|
|
224
222
|
}
|
|
225
223
|
} else if (allowAnyKey) {
|
|
226
224
|
if (val.includes(',')) {
|
|
227
|
-
const values = val
|
|
225
|
+
const values = parseCommaSeparatedList(val)
|
|
228
226
|
;(filtersObj as any)[key] = { $in: values }
|
|
229
227
|
} else {
|
|
230
228
|
const parsed = parseBooleanToken(val)
|
|
@@ -10,6 +10,7 @@ import readline from 'node:readline/promises'
|
|
|
10
10
|
import { stdin as input, stdout as output } from 'node:process'
|
|
11
11
|
import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
|
|
12
12
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
13
|
+
import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
|
|
13
14
|
import { createKmsService, type KmsService, type TenantDek } from '@open-mercato/shared/lib/encryption/kms'
|
|
14
15
|
import {
|
|
15
16
|
decryptWithAesGcm,
|
|
@@ -193,7 +194,7 @@ const addField: ModuleCli = {
|
|
|
193
194
|
let options: string[] | undefined
|
|
194
195
|
if (kind === 'select') {
|
|
195
196
|
const raw = (args.options as string) || await ask('Options (comma-separated)', 'low,medium,high')
|
|
196
|
-
options = raw
|
|
197
|
+
options = parseCommaSeparatedList(raw)
|
|
197
198
|
}
|
|
198
199
|
let defaultValue: any = undefined
|
|
199
200
|
const defRaw = (args.default as string) ?? (args.defaultValue as string)
|