@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/api/keys/route.js +15 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +16 -1
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/backend/users/page.js +13 -2
- package/dist/modules/auth/backend/users/page.js.map +2 -2
- package/dist/modules/catalog/commands/products.js +4 -2
- package/dist/modules/catalog/commands/products.js.map +2 -2
- package/dist/modules/directory/api/organization-branding/route.js +6 -9
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/backend/directory/branding/page.js +15 -3
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/wms/api/listEnrichers.js +3 -1
- package/dist/modules/wms/api/listEnrichers.js.map +2 -2
- package/dist/modules/wms/index.js +1 -1
- package/dist/modules/wms/index.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/api/keys/route.ts +15 -1
- package/src/modules/api_keys/i18n/de.json +1 -0
- package/src/modules/api_keys/i18n/en.json +1 -0
- package/src/modules/api_keys/i18n/es.json +1 -0
- package/src/modules/api_keys/i18n/ko.json +1 -0
- package/src/modules/api_keys/i18n/pl.json +1 -0
- package/src/modules/auth/api/users/route.ts +18 -1
- package/src/modules/auth/backend/users/page.tsx +14 -2
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/ko.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/catalog/commands/products.ts +4 -1
- package/src/modules/directory/api/organization-branding/route.ts +5 -8
- package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
- package/src/modules/wms/api/listEnrichers.ts +5 -2
- 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 {
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AAGnC,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,oCAAoC;AAC7C,SAAS,6BAA6B;AACtC,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAC7B,SAAS,gCAAgC;AACzC,SAAS,0CAA0C;AACnD,SAAS,
|
|
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
|
-
|
|
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": ";
|
|
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": "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",
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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;
|
|
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.
|
|
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.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
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
|
|
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 },
|