@open-mercato/core 0.10.1-develop.7841.1.af87ac614d → 0.10.1-develop.7847.1.7d83dc5794

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 (36) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/api/keys/route.js +15 -1
  3. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  4. package/dist/modules/auth/api/users/route.js +16 -1
  5. package/dist/modules/auth/api/users/route.js.map +2 -2
  6. package/dist/modules/auth/backend/users/page.js +13 -2
  7. package/dist/modules/auth/backend/users/page.js.map +2 -2
  8. package/dist/modules/catalog/commands/products.js +4 -2
  9. package/dist/modules/catalog/commands/products.js.map +2 -2
  10. package/dist/modules/directory/api/organization-branding/route.js +6 -9
  11. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  12. package/dist/modules/directory/backend/directory/branding/page.js +15 -3
  13. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  14. package/dist/modules/wms/api/listEnrichers.js +3 -1
  15. package/dist/modules/wms/api/listEnrichers.js.map +2 -2
  16. package/dist/modules/wms/index.js +1 -1
  17. package/dist/modules/wms/index.js.map +2 -2
  18. package/package.json +7 -7
  19. package/src/modules/api_keys/api/keys/route.ts +15 -1
  20. package/src/modules/api_keys/i18n/de.json +1 -0
  21. package/src/modules/api_keys/i18n/en.json +1 -0
  22. package/src/modules/api_keys/i18n/es.json +1 -0
  23. package/src/modules/api_keys/i18n/ko.json +1 -0
  24. package/src/modules/api_keys/i18n/pl.json +1 -0
  25. package/src/modules/auth/api/users/route.ts +18 -1
  26. package/src/modules/auth/backend/users/page.tsx +14 -2
  27. package/src/modules/auth/i18n/de.json +1 -0
  28. package/src/modules/auth/i18n/en.json +1 -0
  29. package/src/modules/auth/i18n/es.json +1 -0
  30. package/src/modules/auth/i18n/ko.json +1 -0
  31. package/src/modules/auth/i18n/pl.json +1 -0
  32. package/src/modules/catalog/commands/products.ts +4 -1
  33. package/src/modules/directory/api/organization-branding/route.ts +5 -8
  34. package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
  35. package/src/modules/wms/api/listEnrichers.ts +5 -2
  36. package/src/modules/wms/index.ts +1 -1
@@ -14,7 +14,7 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
14
14
  import { Organization } from "@open-mercato/core/modules/directory/data/entities";
15
15
  import { organizationUpdateSchema } from "@open-mercato/core/modules/directory/data/validators";
16
16
  import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
17
- import { resolveSingleOrganizationIdOrDeny } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
17
+ import { isExplicitlyEmptyOrganizationScope } from "@open-mercato/shared/lib/auth/organizationScope";
18
18
  import "@open-mercato/core/modules/directory/commands/organizations";
19
19
  import { createLogger } from "@open-mercato/shared/lib/logger";
20
20
  import { getCommandInterceptorHttpRejection } from "@open-mercato/shared/lib/commands/errors";
@@ -63,15 +63,12 @@ async function resolveCurrentOrganization(req) {
63
63
  }
64
64
  const container = await createRequestContainer();
65
65
  const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
66
- let organizationId;
67
- try {
68
- organizationId = resolveSingleOrganizationIdOrDeny(scope, auth);
69
- } catch (error) {
70
- if (isCrudHttpError(error)) {
71
- return { response: NextResponse.json(error.body, { status: error.status }) };
72
- }
73
- throw error;
66
+ if (isExplicitlyEmptyOrganizationScope(scope)) {
67
+ return {
68
+ response: NextResponse.json({ error: translate("api.errors.forbidden", "Forbidden") }, { status: 403 })
69
+ };
74
70
  }
71
+ const organizationId = scope.selectedId;
75
72
  const tenantId = scope.tenantId ?? auth.tenantId ?? null;
76
73
  if (!organizationId || !tenantId) {
77
74
  return {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/directory/api/organization-branding/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport {\n runCrudMutationGuardAfterSuccess,\n validateCrudMutationGuard,\n} from '@open-mercato/shared/lib/crud/mutation-guard'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { Organization } from '@open-mercato/core/modules/directory/data/entities'\nimport { organizationUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport '@open-mercato/core/modules/directory/commands/organizations'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\n\nconst logger = createLogger('directory').child({ component: 'organization-branding' })\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['directory.organizations.view'] },\n PUT: { requireAuth: true, requireFeatures: ['directory.organizations.manage'] },\n}\n\nconst brandingResponseSchema = z.object({\n organizationId: z.string().uuid(),\n organizationName: z.string(),\n tenantId: z.string().uuid(),\n logoUrl: z.string().nullable(),\n logoPreserveAspectRatio: z.boolean(),\n updatedAt: z.string().nullable(),\n})\n\nconst brandingUpdateSchema = z.object({\n logoUrl: organizationUpdateSchema.shape.logoUrl,\n logoPreserveAspectRatio: organizationUpdateSchema.shape.logoPreserveAspectRatio,\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\ntype RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>\n\nfunction buildCommandContext(\n container: RequestContainer,\n auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>,\n req: Request,\n organizationId: string,\n tenantId: string,\n): CommandRuntimeContext {\n return {\n container,\n auth,\n organizationScope: {\n selectedId: organizationId,\n filterIds: [organizationId],\n allowedIds: null,\n tenantId,\n },\n selectedOrganizationId: organizationId,\n organizationIds: [organizationId],\n request: req,\n }\n}\n\nasync function resolveCurrentOrganization(req: Request) {\n const { translate } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return {\n response: NextResponse.json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 }),\n }\n }\n\n const container = await createRequestContainer()\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n let organizationId: string | null\n try {\n organizationId = resolveSingleOrganizationIdOrDeny(scope, auth)\n } catch (error) {\n if (isCrudHttpError(error)) {\n return { response: NextResponse.json(error.body, { status: error.status }) }\n }\n throw error\n }\n const tenantId = scope.tenantId ?? auth.tenantId ?? null\n if (!organizationId || !tenantId) {\n return {\n response: NextResponse.json(\n {\n error: translate(\n 'directory.branding.errors.organizationRequired',\n 'Select a single organization before changing sidebar branding.',\n ),\n },\n { status: 400 },\n ),\n }\n }\n\n const em = container.resolve('em') as EntityManager\n const organization = await findOneWithDecryption(\n em,\n Organization,\n { id: organizationId, tenant: tenantId, deletedAt: null },\n { populate: ['tenant'] },\n { tenantId, organizationId },\n )\n if (!organization) {\n return {\n response: NextResponse.json(\n { error: translate('directory.branding.errors.notFound', 'Organization not found') },\n { status: 404 },\n ),\n }\n }\n\n return { auth, container, organization, organizationId, tenantId, translate }\n}\n\nfunction toIsoOrNull(value: Date | string | null | undefined): string | null {\n if (value == null) return null\n if (value instanceof Date) {\n const ms = value.getTime()\n return Number.isFinite(ms) ? new Date(ms).toISOString() : null\n }\n const trimmed = String(value).trim()\n if (!trimmed) return null\n const parsed = new Date(trimmed)\n return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null\n}\n\nfunction toResponsePayload(organization: Organization, tenantId: string) {\n return {\n organizationId: String(organization.id),\n organizationName: organization.name,\n tenantId,\n logoUrl: organization.logoUrl ?? null,\n logoPreserveAspectRatio: !!organization.logoPreserveAspectRatio,\n updatedAt: toIsoOrNull(organization.updatedAt),\n }\n}\n\nasync function invalidateSidebarBrandingCache(container: RequestContainer, organizationId: string, tenantId: string) {\n try {\n const cache = container.resolve('cache') as {\n deleteByTags?: (tags: string[]) => Promise<unknown>\n } | null\n await runWithCacheTenant(tenantId, () =>\n cache?.deleteByTags?.([\n `nav:sidebar:organization:${organizationId}`,\n `nav:sidebar:tenant:${tenantId}`,\n ]),\n )\n } catch {\n // Cache invalidation is best-effort; the persisted branding is the source of truth.\n }\n}\n\nexport async function GET(req: Request) {\n const resolved = await resolveCurrentOrganization(req)\n if ('response' in resolved) return resolved.response\n\n return NextResponse.json(toResponsePayload(resolved.organization, resolved.tenantId))\n}\n\nexport async function PUT(req: Request) {\n const resolved = await resolveCurrentOrganization(req)\n if ('response' in resolved) return resolved.response\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json(\n { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },\n { status: 422 },\n )\n }\n if (!body || typeof body !== 'object' || !Object.prototype.hasOwnProperty.call(body, 'logoUrl')) {\n return NextResponse.json(\n { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },\n { status: 422 },\n )\n }\n\n const parsed = brandingUpdateSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n {\n error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.'),\n issues: parsed.error.issues,\n },\n { status: 422 },\n )\n }\n\n try {\n // Refuse a stale overwrite when two tabs edit the same organization branding.\n // Strictly additive \u2014 a no-op when the client omits the expected-version header.\n enforceCommandOptimisticLock({\n resourceKind: 'directory.organization',\n resourceId: resolved.organizationId,\n current: resolved.organization.updatedAt ?? null,\n request: req,\n })\n\n // Mutation-guard contract for custom write routes: validate before the write,\n // then run the after-success hook once the command persists.\n const guardResult = await validateCrudMutationGuard(resolved.container, {\n tenantId: resolved.tenantId,\n organizationId: resolved.organizationId,\n userId: resolved.auth.sub,\n resourceKind: 'directory.organization',\n resourceId: resolved.organizationId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: {\n logoUrl: parsed.data.logoUrl ?? null,\n ...(parsed.data.logoPreserveAspectRatio !== undefined\n ? { logoPreserveAspectRatio: parsed.data.logoPreserveAspectRatio }\n : {}),\n },\n })\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n\n const commandBus = resolved.container.resolve('commandBus') as CommandBus\n const ctx = buildCommandContext(\n resolved.container,\n resolved.auth,\n req,\n resolved.organizationId,\n resolved.tenantId,\n )\n const { result } = await commandBus.execute<Record<string, unknown>, Organization>(\n 'directory.organizations.update',\n {\n input: {\n id: resolved.organizationId,\n tenantId: resolved.tenantId,\n logoUrl: parsed.data.logoUrl ?? null,\n ...(parsed.data.logoPreserveAspectRatio !== undefined\n ? { logoPreserveAspectRatio: parsed.data.logoPreserveAspectRatio }\n : {}),\n },\n ctx,\n },\n )\n\n if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {\n await runCrudMutationGuardAfterSuccess(resolved.container, {\n tenantId: resolved.tenantId,\n organizationId: resolved.organizationId,\n userId: resolved.auth.sub,\n resourceKind: 'directory.organization',\n resourceId: resolved.organizationId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n metadata: guardResult.metadata ?? null,\n })\n }\n\n await invalidateSidebarBrandingCache(resolved.container, resolved.organizationId, resolved.tenantId)\n return NextResponse.json(toResponsePayload(result, resolved.tenantId))\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n logger.error('Organization branding update failed', { err })\n return NextResponse.json(\n { error: resolved.translate('directory.branding.errors.save', 'Failed to update organization branding.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Directory',\n summary: 'Current organization branding',\n methods: {\n GET: {\n summary: 'Read sidebar branding for the selected organization',\n description: 'Returns the logo URL used by the backend sidebar for the currently selected organization.',\n responses: [\n { status: 200, description: 'Organization branding', schema: brandingResponseSchema },\n ],\n errors: [\n { status: 400, description: 'A concrete organization scope is required', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Organization not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update sidebar branding for the selected organization',\n description: 'Stores an external image URL or an internal attachment image URL as the selected organization logo.',\n requestBody: {\n contentType: 'application/json',\n schema: brandingUpdateSchema,\n },\n responses: [\n { status: 200, description: 'Updated organization branding', schema: brandingResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Save failed', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 409, description: 'Organization branding changed since it was loaded', schema: errorSchema },\n { status: 422, description: 'Invalid logo URL', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport {\n runCrudMutationGuardAfterSuccess,\n validateCrudMutationGuard,\n} from '@open-mercato/shared/lib/crud/mutation-guard'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { Organization } from '@open-mercato/core/modules/directory/data/entities'\nimport { organizationUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { isExplicitlyEmptyOrganizationScope } from '@open-mercato/shared/lib/auth/organizationScope'\nimport '@open-mercato/core/modules/directory/commands/organizations'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\n\nconst logger = createLogger('directory').child({ component: 'organization-branding' })\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['directory.organizations.view'] },\n PUT: { requireAuth: true, requireFeatures: ['directory.organizations.manage'] },\n}\n\nconst brandingResponseSchema = z.object({\n organizationId: z.string().uuid(),\n organizationName: z.string(),\n tenantId: z.string().uuid(),\n logoUrl: z.string().nullable(),\n logoPreserveAspectRatio: z.boolean(),\n updatedAt: z.string().nullable(),\n})\n\nconst brandingUpdateSchema = z.object({\n logoUrl: organizationUpdateSchema.shape.logoUrl,\n logoPreserveAspectRatio: organizationUpdateSchema.shape.logoPreserveAspectRatio,\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\ntype RequestContainer = Awaited<ReturnType<typeof createRequestContainer>>\n\nfunction buildCommandContext(\n container: RequestContainer,\n auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>,\n req: Request,\n organizationId: string,\n tenantId: string,\n): CommandRuntimeContext {\n return {\n container,\n auth,\n organizationScope: {\n selectedId: organizationId,\n filterIds: [organizationId],\n allowedIds: null,\n tenantId,\n },\n selectedOrganizationId: organizationId,\n organizationIds: [organizationId],\n request: req,\n }\n}\n\nasync function resolveCurrentOrganization(req: Request) {\n const { translate } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub) {\n return {\n response: NextResponse.json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 }),\n }\n }\n\n const container = await createRequestContainer()\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (isExplicitlyEmptyOrganizationScope(scope)) {\n return {\n response: NextResponse.json({ error: translate('api.errors.forbidden', 'Forbidden') }, { status: 403 }),\n }\n }\n const organizationId = scope.selectedId\n const tenantId = scope.tenantId ?? auth.tenantId ?? null\n if (!organizationId || !tenantId) {\n return {\n response: NextResponse.json(\n {\n error: translate(\n 'directory.branding.errors.organizationRequired',\n 'Select a single organization before changing sidebar branding.',\n ),\n },\n { status: 400 },\n ),\n }\n }\n\n const em = container.resolve('em') as EntityManager\n const organization = await findOneWithDecryption(\n em,\n Organization,\n { id: organizationId, tenant: tenantId, deletedAt: null },\n { populate: ['tenant'] },\n { tenantId, organizationId },\n )\n if (!organization) {\n return {\n response: NextResponse.json(\n { error: translate('directory.branding.errors.notFound', 'Organization not found') },\n { status: 404 },\n ),\n }\n }\n\n return { auth, container, organization, organizationId, tenantId, translate }\n}\n\nfunction toIsoOrNull(value: Date | string | null | undefined): string | null {\n if (value == null) return null\n if (value instanceof Date) {\n const ms = value.getTime()\n return Number.isFinite(ms) ? new Date(ms).toISOString() : null\n }\n const trimmed = String(value).trim()\n if (!trimmed) return null\n const parsed = new Date(trimmed)\n return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null\n}\n\nfunction toResponsePayload(organization: Organization, tenantId: string) {\n return {\n organizationId: String(organization.id),\n organizationName: organization.name,\n tenantId,\n logoUrl: organization.logoUrl ?? null,\n logoPreserveAspectRatio: !!organization.logoPreserveAspectRatio,\n updatedAt: toIsoOrNull(organization.updatedAt),\n }\n}\n\nasync function invalidateSidebarBrandingCache(container: RequestContainer, organizationId: string, tenantId: string) {\n try {\n const cache = container.resolve('cache') as {\n deleteByTags?: (tags: string[]) => Promise<unknown>\n } | null\n await runWithCacheTenant(tenantId, () =>\n cache?.deleteByTags?.([\n `nav:sidebar:organization:${organizationId}`,\n `nav:sidebar:tenant:${tenantId}`,\n ]),\n )\n } catch {\n // Cache invalidation is best-effort; the persisted branding is the source of truth.\n }\n}\n\nexport async function GET(req: Request) {\n const resolved = await resolveCurrentOrganization(req)\n if ('response' in resolved) return resolved.response\n\n return NextResponse.json(toResponsePayload(resolved.organization, resolved.tenantId))\n}\n\nexport async function PUT(req: Request) {\n const resolved = await resolveCurrentOrganization(req)\n if ('response' in resolved) return resolved.response\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json(\n { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },\n { status: 422 },\n )\n }\n if (!body || typeof body !== 'object' || !Object.prototype.hasOwnProperty.call(body, 'logoUrl')) {\n return NextResponse.json(\n { error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.') },\n { status: 422 },\n )\n }\n\n const parsed = brandingUpdateSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n {\n error: resolved.translate('directory.branding.errors.invalidLogoUrl', 'Enter a valid image URL.'),\n issues: parsed.error.issues,\n },\n { status: 422 },\n )\n }\n\n try {\n // Refuse a stale overwrite when two tabs edit the same organization branding.\n // Strictly additive \u2014 a no-op when the client omits the expected-version header.\n enforceCommandOptimisticLock({\n resourceKind: 'directory.organization',\n resourceId: resolved.organizationId,\n current: resolved.organization.updatedAt ?? null,\n request: req,\n })\n\n // Mutation-guard contract for custom write routes: validate before the write,\n // then run the after-success hook once the command persists.\n const guardResult = await validateCrudMutationGuard(resolved.container, {\n tenantId: resolved.tenantId,\n organizationId: resolved.organizationId,\n userId: resolved.auth.sub,\n resourceKind: 'directory.organization',\n resourceId: resolved.organizationId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: {\n logoUrl: parsed.data.logoUrl ?? null,\n ...(parsed.data.logoPreserveAspectRatio !== undefined\n ? { logoPreserveAspectRatio: parsed.data.logoPreserveAspectRatio }\n : {}),\n },\n })\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n\n const commandBus = resolved.container.resolve('commandBus') as CommandBus\n const ctx = buildCommandContext(\n resolved.container,\n resolved.auth,\n req,\n resolved.organizationId,\n resolved.tenantId,\n )\n const { result } = await commandBus.execute<Record<string, unknown>, Organization>(\n 'directory.organizations.update',\n {\n input: {\n id: resolved.organizationId,\n tenantId: resolved.tenantId,\n logoUrl: parsed.data.logoUrl ?? null,\n ...(parsed.data.logoPreserveAspectRatio !== undefined\n ? { logoPreserveAspectRatio: parsed.data.logoPreserveAspectRatio }\n : {}),\n },\n ctx,\n },\n )\n\n if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {\n await runCrudMutationGuardAfterSuccess(resolved.container, {\n tenantId: resolved.tenantId,\n organizationId: resolved.organizationId,\n userId: resolved.auth.sub,\n resourceKind: 'directory.organization',\n resourceId: resolved.organizationId,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n metadata: guardResult.metadata ?? null,\n })\n }\n\n await invalidateSidebarBrandingCache(resolved.container, resolved.organizationId, resolved.tenantId)\n return NextResponse.json(toResponsePayload(result, resolved.tenantId))\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n logger.error('Organization branding update failed', { err })\n return NextResponse.json(\n { error: resolved.translate('directory.branding.errors.save', 'Failed to update organization branding.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Directory',\n summary: 'Current organization branding',\n methods: {\n GET: {\n summary: 'Read sidebar branding for the selected organization',\n description: 'Returns the logo URL used by the backend sidebar for the currently selected organization.',\n responses: [\n { status: 200, description: 'Organization branding', schema: brandingResponseSchema },\n ],\n errors: [\n { status: 400, description: 'A concrete organization scope is required', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Organization not found', schema: errorSchema },\n ],\n },\n PUT: {\n summary: 'Update sidebar branding for the selected organization',\n description: 'Stores an external image URL or an internal attachment image URL as the selected organization logo.',\n requestBody: {\n contentType: 'application/json',\n schema: brandingUpdateSchema,\n },\n responses: [\n { status: 200, description: 'Updated organization branding', schema: brandingResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Save failed', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 409, description: 'Organization branding changed since it was loaded', schema: errorSchema },\n { status: 422, description: 'Invalid logo URL', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -15,13 +15,15 @@ import { Button } from "@open-mercato/ui/primitives/button";
15
15
  import { Input } from "@open-mercato/ui/primitives/input";
16
16
  import { SwitchField } from "@open-mercato/ui/primitives/switch-field";
17
17
  import { useT } from "@open-mercato/shared/lib/i18n/context";
18
- import { useOrganizationScopeDetail } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
18
+ import { useOrganizationScopeDetail, useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
19
+ import { getCurrentOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/organizationEvents";
19
20
  const BRANDING_API = "/api/directory/organization-branding";
20
21
  const BRANDING_ENTITY_ID = "directory.organization";
21
22
  function OrganizationBrandingPage() {
22
23
  const t = useT();
23
24
  const queryClient = useQueryClient();
24
25
  const { organizationId, tenantId } = useOrganizationScopeDetail();
26
+ const scopeVersion = useOrganizationScopeVersion();
25
27
  const [logoUrl, setLogoUrl] = React.useState("");
26
28
  const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false);
27
29
  const [selectedFile, setSelectedFile] = React.useState(null);
@@ -45,7 +47,8 @@ function OrganizationBrandingPage() {
45
47
  setLogoUrl(data?.logoUrl ?? "");
46
48
  setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false);
47
49
  setSelectedFile(null);
48
- }, [data?.logoPreserveAspectRatio, data?.logoUrl, organizationId, tenantId]);
50
+ if (fileInputRef.current) fileInputRef.current.value = "";
51
+ }, [scopeVersion, organizationId, tenantId, data?.organizationId, data?.logoPreserveAspectRatio, data?.logoUrl]);
49
52
  React.useEffect(() => {
50
53
  if (!selectedFile || typeof URL === "undefined") {
51
54
  setFilePreviewUrl(null);
@@ -81,7 +84,13 @@ function OrganizationBrandingPage() {
81
84
  try {
82
85
  await runMutation({
83
86
  operation: async () => {
87
+ if (getCurrentOrganizationScopeVersion() !== scopeVersion) {
88
+ throw new Error("[internal] Organization scope changed during branding save");
89
+ }
84
90
  const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null;
91
+ if (getCurrentOrganizationScopeVersion() !== scopeVersion) {
92
+ throw new Error("[internal] Organization scope changed during branding save");
93
+ }
85
94
  const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim();
86
95
  const response = await withScopedApiRequestHeaders(
87
96
  buildOptimisticLockHeader(data.updatedAt),
@@ -112,12 +121,15 @@ function OrganizationBrandingPage() {
112
121
  hasUpload: shouldUpload
113
122
  }
114
123
  });
124
+ if (getCurrentOrganizationScopeVersion() !== scopeVersion) return;
115
125
  await queryClient.invalidateQueries({ queryKey: ["directory-organization-branding"] });
126
+ if (getCurrentOrganizationScopeVersion() !== scopeVersion) return;
116
127
  window.dispatchEvent(new Event("om:refresh-sidebar"));
117
128
  setSelectedFile(null);
118
129
  if (fileInputRef.current) fileInputRef.current.value = "";
119
130
  flash(t("directory.branding.flash.saved", "Organization branding updated"), "success");
120
131
  } catch (err) {
132
+ if (getCurrentOrganizationScopeVersion() !== scopeVersion) return;
121
133
  if (surfaceRecordConflict(err, t)) return;
122
134
  const fallback = t("directory.branding.errors.save", "Failed to update organization branding");
123
135
  const message = err instanceof Error ? err.message : fallback;
@@ -125,7 +137,7 @@ function OrganizationBrandingPage() {
125
137
  } finally {
126
138
  setSaving(false);
127
139
  }
128
- }, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo]);
140
+ }, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, scopeVersion, selectedFile, t, uploadLogo]);
129
141
  const handleSubmit = React.useCallback((event) => {
130
142
  event.preventDefault();
131
143
  void saveBranding();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/directory/backend/directory/branding/page.tsx"],
4
- "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { ImagePlus, Loader2, RotateCcw, Save } from 'lucide-react'\nimport { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'\nimport { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { SwitchField } from '@open-mercato/ui/primitives/switch-field'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\n\ntype BrandingPayload = {\n organizationId: string\n organizationName: string\n tenantId: string\n logoUrl: string | null\n logoPreserveAspectRatio: boolean\n updatedAt: string | null\n}\n\ntype UploadPayload = {\n ok: true\n item: {\n id: string\n url: string\n thumbnailUrl?: string\n }\n}\n\nconst BRANDING_API = '/api/directory/organization-branding'\nconst BRANDING_ENTITY_ID = 'directory.organization'\n\nexport default function OrganizationBrandingPage() {\n const t = useT()\n const queryClient = useQueryClient()\n const { organizationId, tenantId } = useOrganizationScopeDetail()\n const [logoUrl, setLogoUrl] = React.useState('')\n const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false)\n const [selectedFile, setSelectedFile] = React.useState<File | null>(null)\n const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n const { runMutation } = useGuardedMutation({\n contextId: 'directory.organization-branding',\n blockedMessage: t('directory.branding.errors.blocked', 'Branding save was blocked.'),\n })\n\n const { data, isLoading, error } = useQuery<BrandingPayload>({\n queryKey: ['directory-organization-branding', organizationId, tenantId],\n enabled: Boolean(organizationId),\n queryFn: () => readApiResultOrThrow<BrandingPayload>(\n BRANDING_API,\n undefined,\n { errorMessage: t('directory.branding.errors.load', 'Failed to load organization branding') },\n ),\n })\n\n React.useEffect(() => {\n setLogoUrl(data?.logoUrl ?? '')\n setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false)\n setSelectedFile(null)\n }, [data?.logoPreserveAspectRatio, data?.logoUrl, organizationId, tenantId])\n\n React.useEffect(() => {\n if (!selectedFile || typeof URL === 'undefined') {\n setFilePreviewUrl(null)\n return\n }\n const nextPreviewUrl = URL.createObjectURL(selectedFile)\n setFilePreviewUrl(nextPreviewUrl)\n return () => URL.revokeObjectURL(nextPreviewUrl)\n }, [selectedFile])\n\n const currentPreviewUrl = filePreviewUrl ?? logoUrl\n\n const uploadLogo = React.useCallback(async (organizationId: string): Promise<string | null> => {\n if (!selectedFile) return null\n const form = new FormData()\n form.set('entityId', BRANDING_ENTITY_ID)\n form.set('recordId', organizationId)\n form.set('file', selectedFile)\n form.set('tags', JSON.stringify(['organization-logo']))\n\n const upload = await readApiResultOrThrow<UploadPayload>(\n '/api/attachments',\n {\n method: 'POST',\n body: form,\n },\n { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },\n )\n return upload?.item.url ?? upload?.item.thumbnailUrl ?? null\n }, [selectedFile, t])\n\n const saveBranding = React.useCallback(async (\n nextLogoUrl?: string,\n options?: { skipUpload?: boolean; logoPreserveAspectRatio?: boolean },\n ) => {\n if (!data) return\n const shouldUpload = Boolean(selectedFile && !options?.skipUpload)\n const resolvedLogoPreserveAspectRatio = options?.logoPreserveAspectRatio ?? logoPreserveAspectRatio\n setSaving(true)\n try {\n await runMutation({\n operation: async () => {\n const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null\n const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()\n const response = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data.updatedAt),\n () => apiCallOrThrow<BrandingPayload>(\n BRANDING_API,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n logoUrl: resolvedLogoUrl || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n }),\n },\n { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },\n ),\n )\n return response.result\n },\n context: {\n entityId: BRANDING_ENTITY_ID,\n recordId: data.organizationId,\n operation: 'update-branding',\n },\n mutationPayload: {\n organizationId: data.organizationId,\n logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n hasUpload: shouldUpload,\n },\n })\n await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')\n } catch (err: unknown) {\n if (surfaceRecordConflict(err, t)) return\n const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n } finally {\n setSaving(false)\n }\n }, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])\n\n const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n void saveBranding()\n }, [saveBranding])\n\n if (!organizationId) {\n return (\n <Page>\n <PageHeader title={t('directory.branding.title', 'Organization branding')} />\n <PageBody>\n <Alert status=\"information\">\n <AlertDescription>\n {t(\n 'directory.branding.organizationRequired',\n 'Branding is managed for one organization at a time. Select a single organization to continue.',\n )}\n </AlertDescription>\n </Alert>\n </PageBody>\n </Page>\n )\n }\n\n if (isLoading) {\n return <LoadingMessage label={t('directory.branding.loading', 'Loading organization branding...')} />\n }\n\n if (error || !data) {\n return (\n <ErrorMessage\n label={t('directory.branding.errors.load', 'Failed to load organization branding')}\n description={error instanceof Error ? error.message : undefined}\n />\n )\n }\n\n return (\n <Page>\n <PageHeader\n title={t('directory.branding.title', 'Organization branding')}\n description={t(\n 'directory.branding.description',\n 'Set the logo used in the backend sidebar for the currently selected organization.',\n )}\n />\n <PageBody>\n <form className=\"space-y-5\" onSubmit={handleSubmit}>\n <div className=\"grid gap-5 lg:grid-cols-[260px_1fr]\">\n <div className=\"space-y-3\">\n <div className=\"flex aspect-square w-full max-w-[220px] items-center justify-center overflow-hidden rounded-lg border bg-muted/30\">\n {currentPreviewUrl ? (\n <img\n src={currentPreviewUrl}\n alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}\n className={`h-full w-full ${logoPreserveAspectRatio ? 'object-contain' : 'rounded-full object-cover'}`}\n />\n ) : (\n <ImagePlus className=\"size-10 text-muted-foreground\" aria-hidden />\n )}\n </div>\n <p className=\"text-sm font-medium text-foreground\">{data.organizationName}</p>\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.currentScope', 'Current organization')}\n </p>\n </div>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-file\" className=\"text-sm font-medium\">\n {t('directory.branding.file.label', 'Upload logo')}\n </label>\n <Input\n ref={fileInputRef}\n id=\"organization-logo-file\"\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp\"\n onChange={(event) => {\n const file = event.currentTarget.files?.[0]\n if (!file) return\n setSelectedFile(file)\n }}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.file.hint', 'PNG, JPG, or WebP works best. Uploaded files are stored as organization attachments.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-url\" className=\"text-sm font-medium\">\n {t('directory.branding.url.label', 'Logo URL')}\n </label>\n <Input\n id=\"organization-logo-url\"\n value={logoUrl}\n onChange={(event) => setLogoUrl(event.currentTarget.value)}\n placeholder={t('directory.branding.url.placeholder', 'https://example.com/logo.svg')}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.url.hint', 'Use an external image URL or leave empty to fall back to the default Open Mercato logo.')}\n </p>\n </div>\n\n <SwitchField\n id=\"organization-logo-preserve-aspect-ratio\"\n label={t('directory.branding.aspectRatio.label', 'Keep the aspect ratio')}\n description={t(\n 'directory.branding.aspectRatio.description',\n 'When disabled, the sidebar crops the logo into the standard icon shape.',\n )}\n checked={logoPreserveAspectRatio}\n disabled={saving}\n onCheckedChange={setLogoPreserveAspectRatio}\n />\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button type=\"submit\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 size-4 animate-spin\" aria-hidden /> : <Save className=\"mr-2 size-4\" aria-hidden />}\n {t('directory.branding.actions.save', 'Save branding')}\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={saving}\n onClick={() => {\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n setLogoUrl('')\n setLogoPreserveAspectRatio(false)\n void saveBranding('', { skipUpload: true, logoPreserveAspectRatio: false })\n }}\n >\n <RotateCcw className=\"mr-2 size-4\" aria-hidden />\n {t('directory.branding.actions.reset', 'Use default logo')}\n </Button>\n </div>\n </div>\n </div>\n </form>\n </PageBody>\n </Page>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { ImagePlus, Loader2, RotateCcw, Save } from 'lucide-react'\nimport { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'\nimport { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { Alert, AlertDescription } from '@open-mercato/ui/primitives/alert'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { SwitchField } from '@open-mercato/ui/primitives/switch-field'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeDetail, useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { getCurrentOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/organizationEvents'\n\ntype BrandingPayload = {\n organizationId: string\n organizationName: string\n tenantId: string\n logoUrl: string | null\n logoPreserveAspectRatio: boolean\n updatedAt: string | null\n}\n\ntype UploadPayload = {\n ok: true\n item: {\n id: string\n url: string\n thumbnailUrl?: string\n }\n}\n\nconst BRANDING_API = '/api/directory/organization-branding'\nconst BRANDING_ENTITY_ID = 'directory.organization'\n\nexport default function OrganizationBrandingPage() {\n const t = useT()\n const queryClient = useQueryClient()\n const { organizationId, tenantId } = useOrganizationScopeDetail()\n const scopeVersion = useOrganizationScopeVersion()\n const [logoUrl, setLogoUrl] = React.useState('')\n const [logoPreserveAspectRatio, setLogoPreserveAspectRatio] = React.useState(false)\n const [selectedFile, setSelectedFile] = React.useState<File | null>(null)\n const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n const { runMutation } = useGuardedMutation({\n contextId: 'directory.organization-branding',\n blockedMessage: t('directory.branding.errors.blocked', 'Branding save was blocked.'),\n })\n\n const { data, isLoading, error } = useQuery<BrandingPayload>({\n queryKey: ['directory-organization-branding', organizationId, tenantId],\n enabled: Boolean(organizationId),\n queryFn: () => readApiResultOrThrow<BrandingPayload>(\n BRANDING_API,\n undefined,\n { errorMessage: t('directory.branding.errors.load', 'Failed to load organization branding') },\n ),\n })\n\n React.useEffect(() => {\n setLogoUrl(data?.logoUrl ?? '')\n setLogoPreserveAspectRatio(data?.logoPreserveAspectRatio ?? false)\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n }, [scopeVersion, organizationId, tenantId, data?.organizationId, data?.logoPreserveAspectRatio, data?.logoUrl])\n\n React.useEffect(() => {\n if (!selectedFile || typeof URL === 'undefined') {\n setFilePreviewUrl(null)\n return\n }\n const nextPreviewUrl = URL.createObjectURL(selectedFile)\n setFilePreviewUrl(nextPreviewUrl)\n return () => URL.revokeObjectURL(nextPreviewUrl)\n }, [selectedFile])\n\n const currentPreviewUrl = filePreviewUrl ?? logoUrl\n\n const uploadLogo = React.useCallback(async (organizationId: string): Promise<string | null> => {\n if (!selectedFile) return null\n const form = new FormData()\n form.set('entityId', BRANDING_ENTITY_ID)\n form.set('recordId', organizationId)\n form.set('file', selectedFile)\n form.set('tags', JSON.stringify(['organization-logo']))\n\n const upload = await readApiResultOrThrow<UploadPayload>(\n '/api/attachments',\n {\n method: 'POST',\n body: form,\n },\n { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },\n )\n return upload?.item.url ?? upload?.item.thumbnailUrl ?? null\n }, [selectedFile, t])\n\n const saveBranding = React.useCallback(async (\n nextLogoUrl?: string,\n options?: { skipUpload?: boolean; logoPreserveAspectRatio?: boolean },\n ) => {\n if (!data) return\n const shouldUpload = Boolean(selectedFile && !options?.skipUpload)\n const resolvedLogoPreserveAspectRatio = options?.logoPreserveAspectRatio ?? logoPreserveAspectRatio\n setSaving(true)\n try {\n await runMutation({\n operation: async () => {\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) {\n throw new Error('[internal] Organization scope changed during branding save')\n }\n const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) {\n throw new Error('[internal] Organization scope changed during branding save')\n }\n const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()\n const response = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data.updatedAt),\n () => apiCallOrThrow<BrandingPayload>(\n BRANDING_API,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n logoUrl: resolvedLogoUrl || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n }),\n },\n { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },\n ),\n )\n return response.result\n },\n context: {\n entityId: BRANDING_ENTITY_ID,\n recordId: data.organizationId,\n operation: 'update-branding',\n },\n mutationPayload: {\n organizationId: data.organizationId,\n logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,\n logoPreserveAspectRatio: resolvedLogoPreserveAspectRatio,\n hasUpload: shouldUpload,\n },\n })\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) return\n await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) return\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')\n } catch (err: unknown) {\n if (getCurrentOrganizationScopeVersion() !== scopeVersion) return\n if (surfaceRecordConflict(err, t)) return\n const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n } finally {\n setSaving(false)\n }\n }, [data, logoPreserveAspectRatio, logoUrl, queryClient, runMutation, scopeVersion, selectedFile, t, uploadLogo])\n\n const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n void saveBranding()\n }, [saveBranding])\n\n if (!organizationId) {\n return (\n <Page>\n <PageHeader title={t('directory.branding.title', 'Organization branding')} />\n <PageBody>\n <Alert status=\"information\">\n <AlertDescription>\n {t(\n 'directory.branding.organizationRequired',\n 'Branding is managed for one organization at a time. Select a single organization to continue.',\n )}\n </AlertDescription>\n </Alert>\n </PageBody>\n </Page>\n )\n }\n\n if (isLoading) {\n return <LoadingMessage label={t('directory.branding.loading', 'Loading organization branding...')} />\n }\n\n if (error || !data) {\n return (\n <ErrorMessage\n label={t('directory.branding.errors.load', 'Failed to load organization branding')}\n description={error instanceof Error ? error.message : undefined}\n />\n )\n }\n\n return (\n <Page>\n <PageHeader\n title={t('directory.branding.title', 'Organization branding')}\n description={t(\n 'directory.branding.description',\n 'Set the logo used in the backend sidebar for the currently selected organization.',\n )}\n />\n <PageBody>\n <form className=\"space-y-5\" onSubmit={handleSubmit}>\n <div className=\"grid gap-5 lg:grid-cols-[260px_1fr]\">\n <div className=\"space-y-3\">\n <div className=\"flex aspect-square w-full max-w-[220px] items-center justify-center overflow-hidden rounded-lg border bg-muted/30\">\n {currentPreviewUrl ? (\n <img\n src={currentPreviewUrl}\n alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}\n className={`h-full w-full ${logoPreserveAspectRatio ? 'object-contain' : 'rounded-full object-cover'}`}\n />\n ) : (\n <ImagePlus className=\"size-10 text-muted-foreground\" aria-hidden />\n )}\n </div>\n <p className=\"text-sm font-medium text-foreground\">{data.organizationName}</p>\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.currentScope', 'Current organization')}\n </p>\n </div>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-file\" className=\"text-sm font-medium\">\n {t('directory.branding.file.label', 'Upload logo')}\n </label>\n <Input\n ref={fileInputRef}\n id=\"organization-logo-file\"\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp\"\n onChange={(event) => {\n const file = event.currentTarget.files?.[0]\n if (!file) return\n setSelectedFile(file)\n }}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.file.hint', 'PNG, JPG, or WebP works best. Uploaded files are stored as organization attachments.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-url\" className=\"text-sm font-medium\">\n {t('directory.branding.url.label', 'Logo URL')}\n </label>\n <Input\n id=\"organization-logo-url\"\n value={logoUrl}\n onChange={(event) => setLogoUrl(event.currentTarget.value)}\n placeholder={t('directory.branding.url.placeholder', 'https://example.com/logo.svg')}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.url.hint', 'Use an external image URL or leave empty to fall back to the default Open Mercato logo.')}\n </p>\n </div>\n\n <SwitchField\n id=\"organization-logo-preserve-aspect-ratio\"\n label={t('directory.branding.aspectRatio.label', 'Keep the aspect ratio')}\n description={t(\n 'directory.branding.aspectRatio.description',\n 'When disabled, the sidebar crops the logo into the standard icon shape.',\n )}\n checked={logoPreserveAspectRatio}\n disabled={saving}\n onCheckedChange={setLogoPreserveAspectRatio}\n />\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button type=\"submit\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 size-4 animate-spin\" aria-hidden /> : <Save className=\"mr-2 size-4\" aria-hidden />}\n {t('directory.branding.actions.save', 'Save branding')}\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={saving}\n onClick={() => {\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n setLogoUrl('')\n setLogoPreserveAspectRatio(false)\n void saveBranding('', { skipUpload: true, logoPreserveAspectRatio: false })\n }}\n >\n <RotateCcw className=\"mr-2 size-4\" aria-hidden />\n {t('directory.branding.actions.reset', 'Use default logo')}\n </Button>\n </div>\n </div>\n </div>\n </form>\n </PageBody>\n </Page>\n )\n}\n"],
5
+ "mappings": ";AAkLM,SACE,KADF;AAhLN,YAAY,WAAW;AACvB,SAAS,UAAU,sBAAsB;AACzC,SAAS,WAAW,SAAS,WAAW,YAAY;AACpD,SAAS,MAAM,UAAU,kBAAkB;AAC3C,SAAS,gBAAgB,oBAAoB;AAC7C,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,gBAAgB,sBAAsB,mCAAmC;AAClF,SAAS,iCAAiC;AAC1C,SAAS,6BAA6B;AACtC,SAAS,OAAO,wBAAwB;AACxC,SAAS,cAAc;AACvB,SAAS,aAAa;AACtB,SAAS,mBAAmB;AAC5B,SAAS,YAAY;AACrB,SAAS,4BAA4B,mCAAmC;AACxE,SAAS,0CAA0C;AAoBnD,MAAM,eAAe;AACrB,MAAM,qBAAqB;AAEZ,SAAR,2BAA4C;AACjD,QAAM,IAAI,KAAK;AACf,QAAM,cAAc,eAAe;AACnC,QAAM,EAAE,gBAAgB,SAAS,IAAI,2BAA2B;AAChE,QAAM,eAAe,4BAA4B;AACjD,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,EAAE;AAC/C,QAAM,CAAC,yBAAyB,0BAA0B,IAAI,MAAM,SAAS,KAAK;AAClF,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAsB,IAAI;AACxE,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM,SAAwB,IAAI;AAC9E,QAAM,CAAC,QAAQ,SAAS,IAAI,MAAM,SAAS,KAAK;AAChD,QAAM,eAAe,MAAM,OAAgC,IAAI;AAC/D,QAAM,EAAE,YAAY,IAAI,mBAAmB;AAAA,IACzC,WAAW;AAAA,IACX,gBAAgB,EAAE,qCAAqC,4BAA4B;AAAA,EACrF,CAAC;AAED,QAAM,EAAE,MAAM,WAAW,MAAM,IAAI,SAA0B;AAAA,IAC3D,UAAU,CAAC,mCAAmC,gBAAgB,QAAQ;AAAA,IACtE,SAAS,QAAQ,cAAc;AAAA,IAC/B,SAAS,MAAM;AAAA,MACb;AAAA,MACA;AAAA,MACA,EAAE,cAAc,EAAE,kCAAkC,sCAAsC,EAAE;AAAA,IAC9F;AAAA,EACF,CAAC;AAED,QAAM,UAAU,MAAM;AACpB,eAAW,MAAM,WAAW,EAAE;AAC9B,+BAA2B,MAAM,2BAA2B,KAAK;AACjE,oBAAgB,IAAI;AACpB,QAAI,aAAa,QAAS,cAAa,QAAQ,QAAQ;AAAA,EACzD,GAAG,CAAC,cAAc,gBAAgB,UAAU,MAAM,gBAAgB,MAAM,yBAAyB,MAAM,OAAO,CAAC;AAE/G,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,gBAAgB,OAAO,QAAQ,aAAa;AAC/C,wBAAkB,IAAI;AACtB;AAAA,IACF;AACA,UAAM,iBAAiB,IAAI,gBAAgB,YAAY;AACvD,sBAAkB,cAAc;AAChC,WAAO,MAAM,IAAI,gBAAgB,cAAc;AAAA,EACjD,GAAG,CAAC,YAAY,CAAC;AAEjB,QAAM,oBAAoB,kBAAkB;AAE5C,QAAM,aAAa,MAAM,YAAY,OAAOA,oBAAmD;AAC7F,QAAI,CAAC,aAAc,QAAO;AAC1B,UAAM,OAAO,IAAI,SAAS;AAC1B,SAAK,IAAI,YAAY,kBAAkB;AACvC,SAAK,IAAI,YAAYA,eAAc;AACnC,SAAK,IAAI,QAAQ,YAAY;AAC7B,SAAK,IAAI,QAAQ,KAAK,UAAU,CAAC,mBAAmB,CAAC,CAAC;AAEtD,UAAM,SAAS,MAAM;AAAA,MACnB;AAAA,MACA;AAAA,QACE,QAAQ;AAAA,QACR,MAAM;AAAA,MACR;AAAA,MACA,EAAE,cAAc,EAAE,oCAAoC,uBAAuB,EAAE;AAAA,IACjF;AACA,WAAO,QAAQ,KAAK,OAAO,QAAQ,KAAK,gBAAgB;AAAA,EAC1D,GAAG,CAAC,cAAc,CAAC,CAAC;AAEpB,QAAM,eAAe,MAAM,YAAY,OACrC,aACA,YACG;AACH,QAAI,CAAC,KAAM;AACX,UAAM,eAAe,QAAQ,gBAAgB,CAAC,SAAS,UAAU;AACjE,UAAM,kCAAkC,SAAS,2BAA2B;AAC5E,cAAU,IAAI;AACd,QAAI;AACF,YAAM,YAAY;AAAA,QAChB,WAAW,YAAY;AACrB,cAAI,mCAAmC,MAAM,cAAc;AACzD,kBAAM,IAAI,MAAM,4DAA4D;AAAA,UAC9E;AACA,gBAAM,kBAAkB,eAAe,MAAM,WAAW,KAAK,cAAc,IAAI;AAC/E,cAAI,mCAAmC,MAAM,cAAc;AACzD,kBAAM,IAAI,MAAM,4DAA4D;AAAA,UAC9E;AACA,gBAAM,kBAAkB,mBAAmB,eAAe,QAAQ,KAAK;AACvE,gBAAM,WAAW,MAAM;AAAA,YACrB,0BAA0B,KAAK,SAAS;AAAA,YACxC,MAAM;AAAA,cACJ;AAAA,cACA;AAAA,gBACE,QAAQ;AAAA,gBACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,gBAC9C,MAAM,KAAK,UAAU;AAAA,kBACnB,SAAS,mBAAmB;AAAA,kBAC5B,yBAAyB;AAAA,gBAC3B,CAAC;AAAA,cACH;AAAA,cACA,EAAE,cAAc,EAAE,kCAAkC,wCAAwC,EAAE;AAAA,YAChG;AAAA,UACF;AACA,iBAAO,SAAS;AAAA,QAClB;AAAA,QACA,SAAS;AAAA,UACP,UAAU;AAAA,UACV,UAAU,KAAK;AAAA,UACf,WAAW;AAAA,QACb;AAAA,QACA,iBAAiB;AAAA,UACf,gBAAgB,KAAK;AAAA,UACrB,UAAU,eAAe,QAAQ,KAAK,MAAM;AAAA,UAC5C,yBAAyB;AAAA,UACzB,WAAW;AAAA,QACb;AAAA,MACF,CAAC;AACD,UAAI,mCAAmC,MAAM,aAAc;AAC3D,YAAM,YAAY,kBAAkB,EAAE,UAAU,CAAC,iCAAiC,EAAE,CAAC;AACrF,UAAI,mCAAmC,MAAM,aAAc;AAC3D,aAAO,cAAc,IAAI,MAAM,oBAAoB,CAAC;AACpD,sBAAgB,IAAI;AACpB,UAAI,aAAa,QAAS,cAAa,QAAQ,QAAQ;AACvD,YAAM,EAAE,kCAAkC,+BAA+B,GAAG,SAAS;AAAA,IACvF,SAAS,KAAc;AACrB,UAAI,mCAAmC,MAAM,aAAc;AAC3D,UAAI,sBAAsB,KAAK,CAAC,EAAG;AACnC,YAAM,WAAW,EAAE,kCAAkC,wCAAwC;AAC7F,YAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,YAAM,SAAS,OAAO;AAAA,IACxB,UAAE;AACA,gBAAU,KAAK;AAAA,IACjB;AAAA,EACF,GAAG,CAAC,MAAM,yBAAyB,SAAS,aAAa,aAAa,cAAc,cAAc,GAAG,UAAU,CAAC;AAEhH,QAAM,eAAe,MAAM,YAAY,CAAC,UAA4C;AAClF,UAAM,eAAe;AACrB,SAAK,aAAa;AAAA,EACpB,GAAG,CAAC,YAAY,CAAC;AAEjB,MAAI,CAAC,gBAAgB;AACnB,WACE,qBAAC,QACC;AAAA,0BAAC,cAAW,OAAO,EAAE,4BAA4B,uBAAuB,GAAG;AAAA,MAC3E,oBAAC,YACC,8BAAC,SAAM,QAAO,eACZ,8BAAC,oBACE;AAAA,QACC;AAAA,QACA;AAAA,MACF,GACF,GACF,GACF;AAAA,OACF;AAAA,EAEJ;AAEA,MAAI,WAAW;AACb,WAAO,oBAAC,kBAAe,OAAO,EAAE,8BAA8B,kCAAkC,GAAG;AAAA,EACrG;AAEA,MAAI,SAAS,CAAC,MAAM;AAClB,WACE;AAAA,MAAC;AAAA;AAAA,QACC,OAAO,EAAE,kCAAkC,sCAAsC;AAAA,QACjF,aAAa,iBAAiB,QAAQ,MAAM,UAAU;AAAA;AAAA,IACxD;AAAA,EAEJ;AAEA,SACE,qBAAC,QACC;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,OAAO,EAAE,4BAA4B,uBAAuB;AAAA,QAC5D,aAAa;AAAA,UACX;AAAA,UACA;AAAA,QACF;AAAA;AAAA,IACF;AAAA,IACA,oBAAC,YACC,8BAAC,UAAK,WAAU,aAAY,UAAU,cACpC,+BAAC,SAAI,WAAU,uCACb;AAAA,2BAAC,SAAI,WAAU,aACb;AAAA,4BAAC,SAAI,WAAU,qHACZ,8BACC;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,KAAK,EAAE,iCAAiC,yBAAyB,EAAE,MAAM,KAAK,iBAAiB,CAAC;AAAA,YAChG,WAAW,iBAAiB,0BAA0B,mBAAmB,2BAA2B;AAAA;AAAA,QACtG,IAEA,oBAAC,aAAU,WAAU,iCAAgC,eAAW,MAAC,GAErE;AAAA,QACA,oBAAC,OAAE,WAAU,uCAAuC,eAAK,kBAAiB;AAAA,QAC1E,oBAAC,OAAE,WAAU,iCACV,YAAE,mCAAmC,sBAAsB,GAC9D;AAAA,SACF;AAAA,MAEA,qBAAC,SAAI,WAAU,aACb;AAAA,6BAAC,SAAI,WAAU,aACb;AAAA,8BAAC,WAAM,SAAQ,0BAAyB,WAAU,uBAC/C,YAAE,iCAAiC,aAAa,GACnD;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,KAAK;AAAA,cACL,IAAG;AAAA,cACH,MAAK;AAAA,cACL,QAAO;AAAA,cACP,UAAU,CAAC,UAAU;AACnB,sBAAM,OAAO,MAAM,cAAc,QAAQ,CAAC;AAC1C,oBAAI,CAAC,KAAM;AACX,gCAAgB,IAAI;AAAA,cACtB;AAAA;AAAA,UACF;AAAA,UACA,oBAAC,OAAE,WAAU,iCACV,YAAE,gCAAgC,sFAAsF,GAC3H;AAAA,WACF;AAAA,QAEA,qBAAC,SAAI,WAAU,aACb;AAAA,8BAAC,WAAM,SAAQ,yBAAwB,WAAU,uBAC9C,YAAE,gCAAgC,UAAU,GAC/C;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,IAAG;AAAA,cACH,OAAO;AAAA,cACP,UAAU,CAAC,UAAU,WAAW,MAAM,cAAc,KAAK;AAAA,cACzD,aAAa,EAAE,sCAAsC,8BAA8B;AAAA;AAAA,UACrF;AAAA,UACA,oBAAC,OAAE,WAAU,iCACV,YAAE,+BAA+B,yFAAyF,GAC7H;AAAA,WACF;AAAA,QAEA;AAAA,UAAC;AAAA;AAAA,YACC,IAAG;AAAA,YACH,OAAO,EAAE,wCAAwC,uBAAuB;AAAA,YACxE,aAAa;AAAA,cACX;AAAA,cACA;AAAA,YACF;AAAA,YACA,SAAS;AAAA,YACT,UAAU;AAAA,YACV,iBAAiB;AAAA;AAAA,QACnB;AAAA,QAEA,qBAAC,SAAI,WAAU,qCACb;AAAA,+BAAC,UAAO,MAAK,UAAS,UAAU,QAC7B;AAAA,qBAAS,oBAAC,WAAQ,WAAU,4BAA2B,eAAW,MAAC,IAAK,oBAAC,QAAK,WAAU,eAAc,eAAW,MAAC;AAAA,YAClH,EAAE,mCAAmC,eAAe;AAAA,aACvD;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAQ;AAAA,cACR,UAAU;AAAA,cACV,SAAS,MAAM;AACb,gCAAgB,IAAI;AACpB,oBAAI,aAAa,QAAS,cAAa,QAAQ,QAAQ;AACvD,2BAAW,EAAE;AACb,2CAA2B,KAAK;AAChC,qBAAK,aAAa,IAAI,EAAE,YAAY,MAAM,yBAAyB,MAAM,CAAC;AAAA,cAC5E;AAAA,cAEA;AAAA,oCAAC,aAAU,WAAU,eAAc,eAAW,MAAC;AAAA,gBAC9C,EAAE,oCAAoC,kBAAkB;AAAA;AAAA;AAAA,UAC3D;AAAA,WACF;AAAA,SACF;AAAA,OACF,GACF,GACF;AAAA,KACF;AAEJ;",
6
6
  "names": ["organizationId"]
7
7
  }
@@ -148,11 +148,13 @@ const CUSTOMER_FIELDS = ["id", "display_name"];
148
148
  async function attachVendorLabelsToListItems(payload, ctx) {
149
149
  const items = Array.isArray(payload?.items) ? payload.items : [];
150
150
  if (items.length === 0) return;
151
+ const customerEntityId = E.customers?.customer_entity;
152
+ if (!customerEntityId) return;
151
153
  const ids = uniqueIdsFromKeys(items, ["vendor_id"]);
152
154
  if (ids.length === 0) return;
153
155
  const map = await batchLoadById(
154
156
  ctx,
155
- E.customers.customer_entity,
157
+ customerEntityId,
156
158
  ids,
157
159
  CUSTOMER_FIELDS,
158
160
  "vendor"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/wms/api/listEnrichers.ts"],
4
- "sourcesContent": ["// Lightweight afterList enrichers shared across WMS list routes.\n//\n// These run inside `makeCrudRoute({ hooks: { afterList } })` and batch-resolve\n// foreign-key labels (e.g. warehouse_id -> warehouse_name) so backend tables\n// can render the human-readable label without N+1 lookups on the client.\n//\n// We deliberately use the `queryEngine` (entity-id-based) rather than ORM\n// entity classes so this file stays decoupled from `wms/data/entities`\n// internals and from the catalog ORM \u2014 same pattern used by High #5 in WMS\n// data enrichers.\n\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { E } from '#generated/entities.ids.generated'\n\nconst logger = createLogger('wms')\n\ntype AnyListPayload = {\n items?: Array<Record<string, unknown>>\n}\n\ntype LookupRow = Record<string, unknown> & { id?: string | null }\n\nconst LOOKUP_BATCH_SIZE = 500\n\nfunction uniqueIdsFromKeys(\n items: Array<Record<string, unknown>>,\n keys: string[],\n): string[] {\n const set = new Set<string>()\n for (const item of items) {\n for (const key of keys) {\n const value = item?.[key]\n if (typeof value === 'string' && value.length > 0) set.add(value)\n }\n }\n return Array.from(set)\n}\n\n/**\n * Internal: batch-load `entityId` rows by primary id through the QueryEngine\n * and return them keyed by id. Splits requests into chunks of 500. Errors are\n * logged (with a `source` tag for grep-ability) and degrade to an empty map so\n * the surrounding list response still goes through.\n */\nasync function batchLoadById(\n ctx: CrudCtx,\n entityId: string,\n ids: string[],\n fields: string[],\n source: string,\n): Promise<Map<string, LookupRow>> {\n if (ids.length === 0) return new Map()\n const tenantId = ctx.auth?.tenantId\n if (!tenantId) return new Map()\n const queryEngine = ctx.container.resolve('queryEngine') as QueryEngine\n const map = new Map<string, LookupRow>()\n for (let offset = 0; offset < ids.length; offset += LOOKUP_BATCH_SIZE) {\n const slice = ids.slice(offset, offset + LOOKUP_BATCH_SIZE)\n try {\n const result = await queryEngine.query<LookupRow>(entityId, {\n tenantId,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? undefined,\n organizationIds: ctx.organizationIds ?? undefined,\n filters: { id: { $in: slice } },\n fields,\n page: { page: 1, pageSize: slice.length },\n })\n for (const row of result.items ?? []) {\n if (row?.id && typeof row.id === 'string') map.set(row.id, row)\n }\n } catch (err) {\n logger.warn('batch lookup failed', { source, entityId, err })\n }\n }\n return map\n}\n\nfunction decorateOnce(item: Record<string, unknown>, key: string, value: unknown): void {\n if (item[key] === undefined) item[key] = value\n}\n\n// ---------------------------------------------------------------------------\n// Warehouses\n// ---------------------------------------------------------------------------\n\ntype WarehouseRow = LookupRow & { name?: string | null; code?: string | null }\n\nconst WAREHOUSE_FIELDS = ['id', 'name', 'code']\n\n/**\n * Decorate list items with `warehouse_name` and `warehouse_code` when they\n * carry a `warehouse_id` column. Mutates `payload.items` in place.\n */\nexport async function attachWarehouseLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const ids = uniqueIdsFromKeys(items, ['warehouse_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.wms.warehouse,\n ids,\n WAREHOUSE_FIELDS,\n 'warehouse',\n )) as Map<string, WarehouseRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.warehouse_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'warehouse_name', row.name ?? null)\n decorateOnce(item, 'warehouse_code', row.code ?? null)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Locations\n// ---------------------------------------------------------------------------\n\ntype LocationLookupRow = LookupRow & { code?: string | null; type?: string | null }\n\nconst LOCATION_FIELDS = ['id', 'code', 'type']\n\n/**\n * Decorate list items with location label/type for any of these foreign-key\n * columns: `location_id`, `location_from_id`, `location_to_id`. The decorated\n * fields follow the same prefix:\n *\n * location_id -> location_code, location_type\n * location_from_id -> location_from_code, location_from_type\n * location_to_id -> location_to_code, location_to_type\n *\n * Mutates `payload.items` in place.\n */\nexport async function attachLocationLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const fkColumns = ['location_id', 'location_from_id', 'location_to_id']\n const ids = uniqueIdsFromKeys(items, fkColumns)\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.wms.warehouse_location,\n ids,\n LOCATION_FIELDS,\n 'location',\n )) as Map<string, LocationLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n for (const fk of fkColumns) {\n const id = item?.[fk]\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n const prefix = fk.replace(/_id$/, '')\n decorateOnce(item, `${prefix}_code`, row.code ?? null)\n decorateOnce(item, `${prefix}_type`, row.type ?? null)\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Catalog variants\n// ---------------------------------------------------------------------------\n\ntype VariantLookupRow = LookupRow & {\n name?: string | null\n sku?: string | null\n product_id?: string | null\n}\n\nconst VARIANT_FIELDS = ['id', 'name', 'sku', 'product_id']\n\n/**\n * Decorate list items with `variant_name`, `variant_sku`, and `product_id`\n * when they carry a `catalog_variant_id` column. Mutates `payload.items` in\n * place. Uses `queryEngine.query(E.catalog.catalog_product_variant, ...)`\n * rather than importing the catalog ORM entity directly, to keep the WMS\n * module decoupled from the catalog package internals.\n */\nexport async function attachVariantLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const ids = uniqueIdsFromKeys(items, ['catalog_variant_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.catalog.catalog_product_variant,\n ids,\n VARIANT_FIELDS,\n 'variant',\n )) as Map<string, VariantLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.catalog_variant_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'variant_name', row.name ?? null)\n decorateOnce(item, 'variant_sku', row.sku ?? null)\n decorateOnce(item, 'catalog_product_id', row.product_id ?? null)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Catalog products\n// ---------------------------------------------------------------------------\n\ntype ProductLookupRow = LookupRow & { title?: string | null; sku?: string | null }\n\nconst PRODUCT_FIELDS = ['id', 'title', 'sku']\n\n/**\n * Decorate list items with `product_title` and `product_sku` when they carry a\n * `catalog_product_id` column. Mutates `payload.items` in place.\n */\nexport async function attachProductLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const ids = uniqueIdsFromKeys(items, ['catalog_product_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.catalog.catalog_product,\n ids,\n PRODUCT_FIELDS,\n 'product',\n )) as Map<string, ProductLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.catalog_product_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'product_title', row.title ?? null)\n decorateOnce(item, 'product_sku', row.sku ?? null)\n }\n}\n\n/**\n * Decorate inventory profile rows with catalog product and variant labels.\n * Mutates `payload.items` in place.\n */\nexport async function attachInventoryProfileCatalogLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n await Promise.all([\n attachProductLabelsToListItems(payload, ctx),\n attachVariantLabelsToListItems(payload, ctx),\n ])\n}\n\n// ---------------------------------------------------------------------------\n// Customers (ASN vendors)\n// ---------------------------------------------------------------------------\n\ntype CustomerLookupRow = LookupRow & { display_name?: string | null }\n\nconst CUSTOMER_FIELDS = ['id', 'display_name']\n\n/**\n * Decorate list items with `vendor_name` when they carry a `vendor_id` column\n * referencing `customers.customer_entity` by UUID only (no ORM cross-module\n * relations). Mutates `payload.items` in place.\n */\nexport async function attachVendorLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const ids = uniqueIdsFromKeys(items, ['vendor_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.customers.customer_entity,\n ids,\n CUSTOMER_FIELDS,\n 'vendor',\n )) as Map<string, CustomerLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.vendor_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'vendor_name', row.display_name ?? null)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Reservation sources\n// ---------------------------------------------------------------------------\n\ntype SalesOrderLookupRow = LookupRow & { order_number?: string | null }\n\nconst SALES_ORDER_FIELDS = ['id', 'order_number']\n\n/**\n * Decorate reservation rows with `source_label` when the source can be resolved\n * to a human-readable reference (currently sales orders). Mutates\n * `payload.items` in place.\n */\nexport async function attachReservationSourceLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n\n const orderIds = uniqueIdsFromKeys(\n items.filter((item) => item?.source_type === 'order'),\n ['source_id'],\n )\n if (orderIds.length === 0) return\n\n const map = (await batchLoadById(\n ctx,\n E.sales.sales_order,\n orderIds,\n SALES_ORDER_FIELDS,\n 'reservation-source',\n )) as Map<string, SalesOrderLookupRow>\n if (map.size === 0) return\n\n for (const item of items) {\n if (item?.source_type !== 'order') continue\n const id = item?.source_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n const orderNumber = row?.order_number?.trim()\n if (orderNumber) decorateOnce(item, 'source_label', orderNumber)\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// Lightweight afterList enrichers shared across WMS list routes.\n//\n// These run inside `makeCrudRoute({ hooks: { afterList } })` and batch-resolve\n// foreign-key labels (e.g. warehouse_id -> warehouse_name) so backend tables\n// can render the human-readable label without N+1 lookups on the client.\n//\n// We deliberately use the `queryEngine` (entity-id-based) rather than ORM\n// entity classes so this file stays decoupled from `wms/data/entities`\n// internals and from the catalog ORM \u2014 same pattern used by High #5 in WMS\n// data enrichers.\n\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { E } from '#generated/entities.ids.generated'\n\nconst logger = createLogger('wms')\n\ntype AnyListPayload = {\n items?: Array<Record<string, unknown>>\n}\n\ntype LookupRow = Record<string, unknown> & { id?: string | null }\n\nconst LOOKUP_BATCH_SIZE = 500\n\nfunction uniqueIdsFromKeys(\n items: Array<Record<string, unknown>>,\n keys: string[],\n): string[] {\n const set = new Set<string>()\n for (const item of items) {\n for (const key of keys) {\n const value = item?.[key]\n if (typeof value === 'string' && value.length > 0) set.add(value)\n }\n }\n return Array.from(set)\n}\n\n/**\n * Internal: batch-load `entityId` rows by primary id through the QueryEngine\n * and return them keyed by id. Splits requests into chunks of 500. Errors are\n * logged (with a `source` tag for grep-ability) and degrade to an empty map so\n * the surrounding list response still goes through.\n */\nasync function batchLoadById(\n ctx: CrudCtx,\n entityId: string,\n ids: string[],\n fields: string[],\n source: string,\n): Promise<Map<string, LookupRow>> {\n if (ids.length === 0) return new Map()\n const tenantId = ctx.auth?.tenantId\n if (!tenantId) return new Map()\n const queryEngine = ctx.container.resolve('queryEngine') as QueryEngine\n const map = new Map<string, LookupRow>()\n for (let offset = 0; offset < ids.length; offset += LOOKUP_BATCH_SIZE) {\n const slice = ids.slice(offset, offset + LOOKUP_BATCH_SIZE)\n try {\n const result = await queryEngine.query<LookupRow>(entityId, {\n tenantId,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? undefined,\n organizationIds: ctx.organizationIds ?? undefined,\n filters: { id: { $in: slice } },\n fields,\n page: { page: 1, pageSize: slice.length },\n })\n for (const row of result.items ?? []) {\n if (row?.id && typeof row.id === 'string') map.set(row.id, row)\n }\n } catch (err) {\n logger.warn('batch lookup failed', { source, entityId, err })\n }\n }\n return map\n}\n\nfunction decorateOnce(item: Record<string, unknown>, key: string, value: unknown): void {\n if (item[key] === undefined) item[key] = value\n}\n\n// ---------------------------------------------------------------------------\n// Warehouses\n// ---------------------------------------------------------------------------\n\ntype WarehouseRow = LookupRow & { name?: string | null; code?: string | null }\n\nconst WAREHOUSE_FIELDS = ['id', 'name', 'code']\n\n/**\n * Decorate list items with `warehouse_name` and `warehouse_code` when they\n * carry a `warehouse_id` column. Mutates `payload.items` in place.\n */\nexport async function attachWarehouseLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const ids = uniqueIdsFromKeys(items, ['warehouse_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.wms.warehouse,\n ids,\n WAREHOUSE_FIELDS,\n 'warehouse',\n )) as Map<string, WarehouseRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.warehouse_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'warehouse_name', row.name ?? null)\n decorateOnce(item, 'warehouse_code', row.code ?? null)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Locations\n// ---------------------------------------------------------------------------\n\ntype LocationLookupRow = LookupRow & { code?: string | null; type?: string | null }\n\nconst LOCATION_FIELDS = ['id', 'code', 'type']\n\n/**\n * Decorate list items with location label/type for any of these foreign-key\n * columns: `location_id`, `location_from_id`, `location_to_id`. The decorated\n * fields follow the same prefix:\n *\n * location_id -> location_code, location_type\n * location_from_id -> location_from_code, location_from_type\n * location_to_id -> location_to_code, location_to_type\n *\n * Mutates `payload.items` in place.\n */\nexport async function attachLocationLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const fkColumns = ['location_id', 'location_from_id', 'location_to_id']\n const ids = uniqueIdsFromKeys(items, fkColumns)\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.wms.warehouse_location,\n ids,\n LOCATION_FIELDS,\n 'location',\n )) as Map<string, LocationLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n for (const fk of fkColumns) {\n const id = item?.[fk]\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n const prefix = fk.replace(/_id$/, '')\n decorateOnce(item, `${prefix}_code`, row.code ?? null)\n decorateOnce(item, `${prefix}_type`, row.type ?? null)\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Catalog variants\n// ---------------------------------------------------------------------------\n\ntype VariantLookupRow = LookupRow & {\n name?: string | null\n sku?: string | null\n product_id?: string | null\n}\n\nconst VARIANT_FIELDS = ['id', 'name', 'sku', 'product_id']\n\n/**\n * Decorate list items with `variant_name`, `variant_sku`, and `product_id`\n * when they carry a `catalog_variant_id` column. Mutates `payload.items` in\n * place. Uses `queryEngine.query(E.catalog.catalog_product_variant, ...)`\n * rather than importing the catalog ORM entity directly, to keep the WMS\n * module decoupled from the catalog package internals.\n */\nexport async function attachVariantLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const ids = uniqueIdsFromKeys(items, ['catalog_variant_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.catalog.catalog_product_variant,\n ids,\n VARIANT_FIELDS,\n 'variant',\n )) as Map<string, VariantLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.catalog_variant_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'variant_name', row.name ?? null)\n decorateOnce(item, 'variant_sku', row.sku ?? null)\n decorateOnce(item, 'catalog_product_id', row.product_id ?? null)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Catalog products\n// ---------------------------------------------------------------------------\n\ntype ProductLookupRow = LookupRow & { title?: string | null; sku?: string | null }\n\nconst PRODUCT_FIELDS = ['id', 'title', 'sku']\n\n/**\n * Decorate list items with `product_title` and `product_sku` when they carry a\n * `catalog_product_id` column. Mutates `payload.items` in place.\n */\nexport async function attachProductLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const ids = uniqueIdsFromKeys(items, ['catalog_product_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n E.catalog.catalog_product,\n ids,\n PRODUCT_FIELDS,\n 'product',\n )) as Map<string, ProductLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.catalog_product_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'product_title', row.title ?? null)\n decorateOnce(item, 'product_sku', row.sku ?? null)\n }\n}\n\n/**\n * Decorate inventory profile rows with catalog product and variant labels.\n * Mutates `payload.items` in place.\n */\nexport async function attachInventoryProfileCatalogLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n await Promise.all([\n attachProductLabelsToListItems(payload, ctx),\n attachVariantLabelsToListItems(payload, ctx),\n ])\n}\n\n// ---------------------------------------------------------------------------\n// Customers (ASN vendors)\n// ---------------------------------------------------------------------------\n\ntype CustomerLookupRow = LookupRow & { display_name?: string | null }\n\nconst CUSTOMER_FIELDS = ['id', 'display_name']\n\n/**\n * Decorate list items with `vendor_name` when they carry a `vendor_id` column\n * referencing `customers.customer_entity` by UUID only (no ORM cross-module\n * relations). No-op when the optional `customers` module is not enabled.\n * Mutates `payload.items` in place.\n */\nexport async function attachVendorLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n const customerEntityId = E.customers?.customer_entity\n if (!customerEntityId) return\n const ids = uniqueIdsFromKeys(items, ['vendor_id'])\n if (ids.length === 0) return\n const map = (await batchLoadById(\n ctx,\n customerEntityId,\n ids,\n CUSTOMER_FIELDS,\n 'vendor',\n )) as Map<string, CustomerLookupRow>\n if (map.size === 0) return\n for (const item of items) {\n const id = item?.vendor_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n if (!row) continue\n decorateOnce(item, 'vendor_name', row.display_name ?? null)\n }\n}\n\n// ---------------------------------------------------------------------------\n// Reservation sources\n// ---------------------------------------------------------------------------\n\ntype SalesOrderLookupRow = LookupRow & { order_number?: string | null }\n\nconst SALES_ORDER_FIELDS = ['id', 'order_number']\n\n/**\n * Decorate reservation rows with `source_label` when the source can be resolved\n * to a human-readable reference (currently sales orders). Mutates\n * `payload.items` in place.\n */\nexport async function attachReservationSourceLabelsToListItems(\n payload: AnyListPayload,\n ctx: CrudCtx,\n): Promise<void> {\n const items = Array.isArray(payload?.items) ? payload.items : []\n if (items.length === 0) return\n\n const orderIds = uniqueIdsFromKeys(\n items.filter((item) => item?.source_type === 'order'),\n ['source_id'],\n )\n if (orderIds.length === 0) return\n\n const map = (await batchLoadById(\n ctx,\n E.sales.sales_order,\n orderIds,\n SALES_ORDER_FIELDS,\n 'reservation-source',\n )) as Map<string, SalesOrderLookupRow>\n if (map.size === 0) return\n\n for (const item of items) {\n if (item?.source_type !== 'order') continue\n const id = item?.source_id\n if (typeof id !== 'string' || !id) continue\n const row = map.get(id)\n const orderNumber = row?.order_number?.trim()\n if (orderNumber) decorateOnce(item, 'source_label', orderNumber)\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -7,7 +7,7 @@ const metadata = {
7
7
  author: "Open Mercato Team",
8
8
  license: "MIT",
9
9
  ejectable: true,
10
- requires: ["catalog", "sales", "feature_toggles"]
10
+ requires: ["catalog", "customers", "sales", "feature_toggles"]
11
11
  };
12
12
  import { features } from "./acl.js";
13
13
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/wms/index.ts"],
4
- "sourcesContent": ["import type { ModuleInfo } from '@open-mercato/shared/modules/registry'\nimport './commands'\n\nexport const metadata: ModuleInfo = {\n name: 'wms',\n title: 'Warehouse Management System',\n version: '0.1.0',\n description: 'Warehouse topology, inventory balances, reservations, and movement ledger.',\n author: 'Open Mercato Team',\n license: 'MIT',\n ejectable: true,\n requires: ['catalog', 'sales', 'feature_toggles'],\n}\n\nexport { features } from './acl'\n"],
5
- "mappings": "AACA,OAAO;AAEA,MAAM,WAAuB;AAAA,EAClC,MAAM;AAAA,EACN,OAAO;AAAA,EACP,SAAS;AAAA,EACT,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX,UAAU,CAAC,WAAW,SAAS,iBAAiB;AAClD;AAEA,SAAS,gBAAgB;",
4
+ "sourcesContent": ["import type { ModuleInfo } from '@open-mercato/shared/modules/registry'\nimport './commands'\n\nexport const metadata: ModuleInfo = {\n name: 'wms',\n title: 'Warehouse Management System',\n version: '0.1.0',\n description: 'Warehouse topology, inventory balances, reservations, and movement ledger.',\n author: 'Open Mercato Team',\n license: 'MIT',\n ejectable: true,\n requires: ['catalog', 'customers', 'sales', 'feature_toggles'],\n}\n\nexport { features } from './acl'\n"],
5
+ "mappings": "AACA,OAAO;AAEA,MAAM,WAAuB;AAAA,EAClC,MAAM;AAAA,EACN,OAAO;AAAA,EACP,SAAS;AAAA,EACT,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,WAAW;AAAA,EACX,UAAU,CAAC,WAAW,aAAa,SAAS,iBAAiB;AAC/D;AAEA,SAAS,gBAAgB;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7841.1.af87ac614d",
3
+ "version": "0.10.1-develop.7847.1.7d83dc5794",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.6.6"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7841.1.af87ac614d",
270
- "@open-mercato/shared": "0.10.1-develop.7841.1.af87ac614d",
271
- "@open-mercato/ui": "0.10.1-develop.7841.1.af87ac614d",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7847.1.7d83dc5794",
270
+ "@open-mercato/shared": "0.10.1-develop.7847.1.7d83dc5794",
271
+ "@open-mercato/ui": "0.10.1-develop.7847.1.7d83dc5794",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7841.1.af87ac614d",
277
- "@open-mercato/shared": "0.10.1-develop.7841.1.af87ac614d",
278
- "@open-mercato/ui": "0.10.1-develop.7841.1.af87ac614d",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7847.1.7d83dc5794",
277
+ "@open-mercato/shared": "0.10.1-develop.7847.1.7d83dc5794",
278
+ "@open-mercato/ui": "0.10.1-develop.7847.1.7d83dc5794",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -4,7 +4,7 @@ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
4
4
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
5
5
  import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
- import { Role } from '@open-mercato/core/modules/auth/data/entities'
7
+ import { Role, User } from '@open-mercato/core/modules/auth/data/entities'
8
8
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
9
9
  import { ApiKey } from '../../data/entities'
10
10
  import { createApiKeySchema } from '../../data/validators'
@@ -13,6 +13,7 @@ import { enforceTenantSelection, resolveIsSuperAdmin } from '@open-mercato/core/
13
13
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
14
14
  import { assertActorCanGrantRoles } from '@open-mercato/core/modules/auth/lib/grantChecks'
15
15
  import { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'
16
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
16
17
  import type { CreateApiKeyCommandInput } from '../../commands/keys'
17
18
 
18
19
  const listQuerySchema = z.object({
@@ -138,6 +139,19 @@ async function prepareCreateCommandInput(
138
139
  })) {
139
140
  throw json({ error: translate('api_keys.errors.organizationOutOfScope', 'Organization out of scope') }, { status: 403 })
140
141
  }
142
+ if (organizationId) {
143
+ const creator = await findOneWithDecryption(
144
+ em,
145
+ User,
146
+ { id: auth.sub, tenantId: targetTenantId, deletedAt: null },
147
+ { fields: ['organizationId'] },
148
+ { tenantId: targetTenantId, organizationId: null },
149
+ )
150
+ if (creator?.organizationId?.toLowerCase() !== organizationId.toLowerCase()) {
151
+ const message = translate('api_keys.errors.organizationMustMatchCreator')
152
+ throw json({ error: message, fieldErrors: { organizationId: message } }, { status: 400 })
153
+ }
154
+ }
141
155
 
142
156
  return {
143
157
  name: input.name,
@@ -5,6 +5,7 @@
5
5
  "api_keys.copy.warning": "Bewahre diesen Schlüssel sicher auf – er kann später nicht erneut abgerufen werden.",
6
6
  "api_keys.errors.forbidden": "Verboten",
7
7
  "api_keys.errors.notFound": "Nicht gefunden",
8
+ "api_keys.errors.organizationMustMatchCreator": "Ein organisationsgebundener API-Schlüssel muss die Organisation Ihres Kontos verwenden. Wählen Sie diese Organisation aus, um einen nutzbaren Schlüssel zu erstellen.",
8
9
  "api_keys.errors.organizationOutOfScope": "Organisation außerhalb des Geltungsbereichs",
9
10
  "api_keys.errors.roleNotFound": "Rolle {identifier} nicht gefunden",
10
11
  "api_keys.errors.roleWrongTenant": "Rolle {role} gehört zu einem anderen Mandanten",
@@ -5,6 +5,7 @@
5
5
  "api_keys.copy.warning": "Keep this key safe — it cannot be retrieved later.",
6
6
  "api_keys.errors.forbidden": "Forbidden",
7
7
  "api_keys.errors.notFound": "Not found",
8
+ "api_keys.errors.organizationMustMatchCreator": "An organization-scoped API key must use your account’s organization. Select that organization to create a usable key.",
8
9
  "api_keys.errors.organizationOutOfScope": "Organization out of scope",
9
10
  "api_keys.errors.roleNotFound": "Role {identifier} not found",
10
11
  "api_keys.errors.roleWrongTenant": "Role {role} belongs to another tenant",
@@ -5,6 +5,7 @@
5
5
  "api_keys.copy.warning": "Mantén esta clave a salvo; no se puede recuperar más tarde.",
6
6
  "api_keys.errors.forbidden": "Prohibido",
7
7
  "api_keys.errors.notFound": "No encontrado",
8
+ "api_keys.errors.organizationMustMatchCreator": "Una clave API limitada a una organización debe usar la organización de tu cuenta. Selecciona esa organización para crear una clave utilizable.",
8
9
  "api_keys.errors.organizationOutOfScope": "Organización fuera del ámbito",
9
10
  "api_keys.errors.roleNotFound": "Rol {identifier} no encontrado",
10
11
  "api_keys.errors.roleWrongTenant": "El rol {role} pertenece a otro inquilino",
@@ -5,6 +5,7 @@
5
5
  "api_keys.copy.warning": "이 키를 안전하게 보관하세요 — 나중에 다시 확인할 수 없습니다.",
6
6
  "api_keys.errors.forbidden": "접근이 거부되었습니다",
7
7
  "api_keys.errors.notFound": "찾을 수 없습니다",
8
+ "api_keys.errors.organizationMustMatchCreator": "조직 범위 API 키는 계정의 조직을 사용해야 합니다. 사용 가능한 키를 만들려면 해당 조직을 선택하세요.",
8
9
  "api_keys.errors.organizationOutOfScope": "조직이 범위를 벗어났습니다",
9
10
  "api_keys.errors.roleNotFound": "역할 {identifier}을(를) 찾을 수 없습니다",
10
11
  "api_keys.errors.roleWrongTenant": "역할 {role}은(는) 다른 테넌트에 속합니다",
@@ -5,6 +5,7 @@
5
5
  "api_keys.copy.warning": "Zachowaj ten klucz w bezpiecznym miejscu – nie można go później odzyskać.",
6
6
  "api_keys.errors.forbidden": "Brak uprawnień",
7
7
  "api_keys.errors.notFound": "Nie znaleziono",
8
+ "api_keys.errors.organizationMustMatchCreator": "Klucz API przypisany do organizacji musi używać organizacji Twojego konta. Wybierz tę organizację, aby utworzyć działający klucz.",
8
9
  "api_keys.errors.organizationOutOfScope": "Organizacja poza zakresem",
9
10
  "api_keys.errors.roleNotFound": "Nie znaleziono roli {identifier}",
10
11
  "api_keys.errors.roleWrongTenant": "Rola {role} należy do innego najemcy",
@@ -255,6 +255,23 @@ export async function GET(req: Request) {
255
255
  if (superAdminUserIds.size) {
256
256
  filters.push({ id: { $nin: Array.from(superAdminUserIds) as any } })
257
257
  }
258
+ // Browsing follows the topbar organization selection; explicit `?id=`/`?ids=` lookups stay
259
+ // tenant-wide so callers resolving a known user (author labels, assignee names) keep working.
260
+ if (userIdFilter.kind !== 'ids') {
261
+ const scope = await resolveOrganizationScopeForRequest({
262
+ container,
263
+ auth,
264
+ request: req,
265
+ tenantId: actorTenantId,
266
+ })
267
+ effectiveSelectedOrganizationId = scope.selectedId
268
+ if (Array.isArray(scope.filterIds)) {
269
+ if (scope.filterIds.length === 0) {
270
+ return NextResponse.json({ items: [], total: 0, totalPages: 1, isSuperAdmin })
271
+ }
272
+ effectiveOrganizationIds = scope.filterIds
273
+ }
274
+ }
258
275
  } else {
259
276
  const selectedTenantId = getSelectedTenantFromRequest(req)
260
277
  if (typeof selectedTenantId === 'string' && selectedTenantId.trim().length > 0) {
@@ -726,7 +743,7 @@ export const openApi: OpenApiRouteDoc = {
726
743
  GET: {
727
744
  summary: 'List users',
728
745
  description:
729
- 'Non-superadmins with auth.users.list receive a tenant-wide staff directory by default, excluding superadmin accounts. Neither the active organization nor the caller\'s organization visibility list narrows this default. Directory visibility does not grant access to organization-owned records. Super administrators may scope the response via the topbar tenant and organization selection; without a selected tenant, their list can span tenants. Search matches email, organization name, and role name. Pass organizationId for an exact organization filter, or scopeToActiveOrganization=1 to restrict results to the caller\'s exact active organization (used by recipient/assignee pickers); when the caller has no active organization, this selects only users with no organization. Both filters apply when supplied together. These are optional list filters, not authorization boundaries. Pass ids=<uuid>,<uuid> (max 100) to resolve a known set of users in one request, for example to label a list of foreign keys; it intersects with id and roleId, and a supplied ids value that contains no valid identifier matches nothing.',
746
+ 'Non-superadmins with auth.users.list browse users in the selected organization and its descendants, bounded by their tenant and organization visibility, excluding superadmin accounts. Selecting all organizations remains bounded by organization visibility. Explicit id/ids lookups retain tenant-wide staff-reference visibility, excluding superadmin accounts. Directory visibility does not grant access to organization-owned records. Super administrators may scope the response via the topbar tenant and organization selection; without a selected tenant, their list can span tenants. Search matches email, organization name, and role name. Pass organizationId for an exact organization filter, or scopeToActiveOrganization=1 to restrict results to the caller\'s exact active organization (used by recipient/assignee pickers); when the caller has no active organization, this selects only users with no organization. Both filters apply when supplied together. These optional list filters further narrow the effective scope. Pass ids=<uuid>,<uuid> (max 100) to resolve a known set of users in one request, for example to label a list of foreign keys; it intersects with id and roleId, and a supplied ids value that contains no valid identifier matches nothing.',
730
747
  query: querySchema,
731
748
  responses: [
732
749
  { status: 200, description: 'User collection', schema: userListResponseSchema },