@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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/cli.js +2 -1
  3. package/dist/modules/api_keys/cli.js.map +2 -2
  4. package/dist/modules/attachments/api/file/[id]/route.js +9 -7
  5. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  6. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +9 -7
  7. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  8. package/dist/modules/attachments/api/route.js +21 -17
  9. package/dist/modules/attachments/api/route.js.map +2 -2
  10. package/dist/modules/attachments/lib/reconcileOrganization.js +110 -0
  11. package/dist/modules/attachments/lib/reconcileOrganization.js.map +7 -0
  12. package/dist/modules/attachments/lib/requestScope.js +10 -0
  13. package/dist/modules/attachments/lib/requestScope.js.map +7 -0
  14. package/dist/modules/auth/api/users/route.js +6 -2
  15. package/dist/modules/auth/api/users/route.js.map +2 -2
  16. package/dist/modules/auth/cli.js +3 -2
  17. package/dist/modules/auth/cli.js.map +2 -2
  18. package/dist/modules/configs/lib/upgrade-actions.js +22 -0
  19. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  20. package/dist/modules/dashboards/cli.js +4 -3
  21. package/dist/modules/dashboards/cli.js.map +2 -2
  22. package/dist/modules/directory/utils/organizationScope.js +10 -3
  23. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  24. package/dist/modules/entities/api/records.js +5 -4
  25. package/dist/modules/entities/api/records.js.map +2 -2
  26. package/dist/modules/entities/cli.js +2 -1
  27. package/dist/modules/entities/cli.js.map +2 -2
  28. package/package.json +7 -7
  29. package/src/modules/api_keys/cli.ts +2 -3
  30. package/src/modules/attachments/api/file/[id]/route.ts +11 -7
  31. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +11 -7
  32. package/src/modules/attachments/api/route.ts +26 -21
  33. package/src/modules/attachments/lib/reconcileOrganization.ts +188 -0
  34. package/src/modules/attachments/lib/requestScope.ts +27 -0
  35. package/src/modules/auth/api/users/route.ts +11 -2
  36. package/src/modules/auth/cli.ts +3 -2
  37. package/src/modules/configs/i18n/de.json +5 -1
  38. package/src/modules/configs/i18n/en.json +5 -1
  39. package/src/modules/configs/i18n/es.json +5 -1
  40. package/src/modules/configs/i18n/pl.json +5 -1
  41. package/src/modules/configs/lib/upgrade-actions.ts +24 -0
  42. package/src/modules/dashboards/cli.ts +4 -9
  43. package/src/modules/directory/utils/organizationScope.ts +36 -2
  44. package/src/modules/entities/api/records.ts +5 -7
  45. 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 },
@@ -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.split(',').map(s => s.trim()).filter(Boolean)
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.split(',').map((s) => s.trim()).filter(Boolean)
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.split(',').map((id) => id.trim()).filter(Boolean) : undefined,
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
- return idOk(record.selectedId) && idOk(record.tenantId) && arrOk(record.filterIds) && arrOk(record.allowedIds)
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.split(',').map((s) => s.trim()).filter(Boolean)
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.split(',').map((s) => s.trim()).filter(Boolean)
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.split(',').map((s) => s.trim()).filter(Boolean)
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.split(',').map((s) => s.trim()).filter(Boolean)
197
+ options = parseCommaSeparatedList(raw)
197
198
  }
198
199
  let defaultValue: any = undefined
199
200
  const defRaw = (args.default as string) ?? (args.defaultValue as string)