@open-mercato/core 0.6.6-develop.5738.1.4182c2b2c7 → 0.6.6-develop.5754.1.c908b2af3f

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.
@@ -1,12 +1,23 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
+ import { runWithCacheTenant } from "@open-mercato/cache";
3
4
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
5
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
6
  import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
7
+ import {
8
+ buildCollectionTags,
9
+ isCrudCacheEnabled,
10
+ resolveCrudCache
11
+ } from "@open-mercato/shared/lib/crud/cache";
6
12
  import { Currency } from "../../../data/entities.js";
7
13
  const metadata = {
8
14
  GET: { requireAuth: true, requireFeatures: ["currencies.view"] }
9
15
  };
16
+ const CURRENCY_OPTIONS_RESOURCE = "currencies.currency";
17
+ const CURRENCY_OPTIONS_TTL_MS = 5 * 6e4;
18
+ function buildOptionsCacheKey(params) {
19
+ return `currencies:options:org=${params.orgId ?? "null"}:active=${params.includeInactive ? "all" : "active"}:limit=${params.limit}`;
20
+ }
10
21
  const optionsQuerySchema = z.object({
11
22
  q: z.string().optional(),
12
23
  query: z.string().optional(),
@@ -34,14 +45,25 @@ async function GET(req) {
34
45
  const em = container.resolve("em");
35
46
  const { q, query, search, includeInactive, limit } = parsed.data;
36
47
  const searchTerm = (q ?? query ?? search ?? "").trim();
48
+ const tenantId = auth.tenantId;
49
+ const orgId = auth.orgId ?? null;
50
+ const includeInactiveFlag = includeInactive === "true";
51
+ const cache = isCrudCacheEnabled() && !searchTerm ? resolveCrudCache(container) : null;
52
+ const cacheKey = cache ? buildOptionsCacheKey({ orgId, includeInactive: includeInactiveFlag, limit }) : null;
53
+ if (cache && cacheKey) {
54
+ const cached = await runWithCacheTenant(tenantId, () => cache.get(cacheKey));
55
+ if (cached) {
56
+ return NextResponse.json(cached);
57
+ }
58
+ }
37
59
  const filter = {
38
- tenantId: auth.tenantId,
60
+ tenantId,
39
61
  deletedAt: null
40
62
  };
41
- if (auth.orgId) {
42
- filter.organizationId = auth.orgId;
63
+ if (orgId) {
64
+ filter.organizationId = orgId;
43
65
  }
44
- if (includeInactive !== "true") {
66
+ if (!includeInactiveFlag) {
45
67
  filter.isActive = true;
46
68
  }
47
69
  if (searchTerm) {
@@ -59,7 +81,20 @@ async function GET(req) {
59
81
  value: String(currency.code),
60
82
  label: `${currency.code} - ${currency.name}`
61
83
  }));
62
- return NextResponse.json({ items });
84
+ const payload = { items };
85
+ if (cache && cacheKey) {
86
+ try {
87
+ await runWithCacheTenant(
88
+ tenantId,
89
+ () => cache.set(cacheKey, payload, {
90
+ ttl: CURRENCY_OPTIONS_TTL_MS,
91
+ tags: buildCollectionTags(CURRENCY_OPTIONS_RESOURCE, tenantId, [orgId])
92
+ })
93
+ );
94
+ } catch {
95
+ }
96
+ }
97
+ return NextResponse.json(payload);
63
98
  }
64
99
  const optionsResponseSchema = z.object({
65
100
  items: z.array(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/currencies/api/currencies/options/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { Currency } from '../../../data/entities'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['currencies.view'] },\n}\n\nconst optionsQuerySchema = z.object({\n q: z.string().optional(),\n query: z.string().optional(),\n search: z.string().optional(),\n includeInactive: z.enum(['true', 'false']).optional(),\n limit: z.coerce.number().min(1).max(100).default(50),\n}).loose()\n\ntype OptionsItem = {\n value: string\n label: string\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {\n return NextResponse.json({ items: [] }, { status: 401 })\n }\n\n const url = new URL(req.url)\n const parsed = optionsQuerySchema.safeParse({\n q: url.searchParams.get('q') ?? undefined,\n query: url.searchParams.get('query') ?? undefined,\n search: url.searchParams.get('search') ?? undefined,\n includeInactive: url.searchParams.get('includeInactive') ?? undefined,\n limit: url.searchParams.get('limit') ?? undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ items: [] }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n\n const { q, query, search, includeInactive, limit } = parsed.data\n const searchTerm = (q ?? query ?? search ?? '').trim()\n const filter: any = {\n tenantId: auth.tenantId,\n deletedAt: null,\n }\n if (auth.orgId) {\n filter.organizationId = auth.orgId\n }\n\n if (includeInactive !== 'true') {\n filter.isActive = true\n }\n\n if (searchTerm) {\n const escaped = escapeLikePattern(searchTerm)\n filter.$or = [\n { code: { $ilike: `%${escaped}%` } },\n { name: { $ilike: `%${escaped}%` } },\n ]\n }\n\n const rows = await em.find(Currency, filter, {\n orderBy: { code: 'ASC' },\n limit,\n })\n\n const items: OptionsItem[] = rows.map((currency) => ({\n value: String(currency.code),\n label: `${currency.code} - ${currency.name}`,\n }))\n\n return NextResponse.json({ items })\n}\n\nconst optionsResponseSchema = z.object({\n items: z.array(\n z.object({\n value: z.string(),\n label: z.string(),\n })\n ),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Currencies',\n summary: 'Currency options',\n methods: {\n GET: {\n summary: 'List currency options',\n description: 'Returns currencies formatted for select inputs.',\n query: optionsQuerySchema,\n responses: [\n { status: 200, description: 'Option list', schema: optionsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: z.object({ items: z.array(z.any()) }) },\n { status: 400, description: 'Invalid query', schema: z.object({ items: z.array(z.any()) }) },\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 { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport {\n buildCollectionTags,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { Currency } from '../../../data/entities'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['currencies.view'] },\n}\n\nconst CURRENCY_OPTIONS_RESOURCE = 'currencies.currency'\nconst CURRENCY_OPTIONS_TTL_MS = 5 * 60_000\n\nfunction buildOptionsCacheKey(params: {\n orgId: string | null\n includeInactive: boolean\n limit: number\n}): string {\n return `currencies:options:org=${params.orgId ?? 'null'}:active=${params.includeInactive ? 'all' : 'active'}:limit=${params.limit}`\n}\n\nconst optionsQuerySchema = z.object({\n q: z.string().optional(),\n query: z.string().optional(),\n search: z.string().optional(),\n includeInactive: z.enum(['true', 'false']).optional(),\n limit: z.coerce.number().min(1).max(100).default(50),\n}).loose()\n\ntype OptionsItem = {\n value: string\n label: string\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {\n return NextResponse.json({ items: [] }, { status: 401 })\n }\n\n const url = new URL(req.url)\n const parsed = optionsQuerySchema.safeParse({\n q: url.searchParams.get('q') ?? undefined,\n query: url.searchParams.get('query') ?? undefined,\n search: url.searchParams.get('search') ?? undefined,\n includeInactive: url.searchParams.get('includeInactive') ?? undefined,\n limit: url.searchParams.get('limit') ?? undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ items: [] }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n\n const { q, query, search, includeInactive, limit } = parsed.data\n const searchTerm = (q ?? query ?? search ?? '').trim()\n const tenantId = auth.tenantId\n const orgId = auth.orgId ?? null\n const includeInactiveFlag = includeInactive === 'true'\n\n // Only the unfiltered bootstrap call (no ILIKE search term) is the hot path\n // worth caching; search variants would multiply key cardinality with little\n // hit-rate benefit, so they always read straight through.\n const cache =\n isCrudCacheEnabled() && !searchTerm ? resolveCrudCache(container) : null\n const cacheKey = cache\n ? buildOptionsCacheKey({ orgId, includeInactive: includeInactiveFlag, limit })\n : null\n\n if (cache && cacheKey) {\n const cached = await runWithCacheTenant(tenantId, () => cache.get(cacheKey))\n if (cached) {\n return NextResponse.json(cached)\n }\n }\n\n const filter: any = {\n tenantId,\n deletedAt: null,\n }\n if (orgId) {\n filter.organizationId = orgId\n }\n\n if (!includeInactiveFlag) {\n filter.isActive = true\n }\n\n if (searchTerm) {\n const escaped = escapeLikePattern(searchTerm)\n filter.$or = [\n { code: { $ilike: `%${escaped}%` } },\n { name: { $ilike: `%${escaped}%` } },\n ]\n }\n\n const rows = await em.find(Currency, filter, {\n orderBy: { code: 'ASC' },\n limit,\n })\n\n const items: OptionsItem[] = rows.map((currency) => ({\n value: String(currency.code),\n label: `${currency.code} - ${currency.name}`,\n }))\n\n const payload = { items }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: CURRENCY_OPTIONS_TTL_MS,\n tags: buildCollectionTags(CURRENCY_OPTIONS_RESOURCE, tenantId, [orgId]),\n }),\n )\n } catch {}\n }\n\n return NextResponse.json(payload)\n}\n\nconst optionsResponseSchema = z.object({\n items: z.array(\n z.object({\n value: z.string(),\n label: z.string(),\n })\n ),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Currencies',\n summary: 'Currency options',\n methods: {\n GET: {\n summary: 'List currency options',\n description: 'Returns currencies formatted for select inputs.',\n query: optionsQuerySchema,\n responses: [\n { status: 200, description: 'Option list', schema: optionsResponseSchema },\n { status: 401, description: 'Unauthorized', schema: z.object({ items: z.array(z.any()) }) },\n { status: 400, description: 'Invalid query', schema: z.object({ items: z.array(z.any()) }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -24,45 +24,40 @@ async function resolveAllowedWidgetIds(em, ctx, widgets) {
24
24
  allowedByUser = null;
25
25
  }
26
26
  }
27
- if (allowedByUser && allowedByUser.size === 0) {
28
- return Array.from(allowedByUser);
29
- }
30
- const userRoles = await findWithDecryption(
31
- em,
32
- UserRole,
33
- { user: ctx.userId, deletedAt: null },
34
- { populate: ["role"] },
35
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
36
- );
37
- const roleRecords = await em.find(DashboardRoleWidgets, {
38
- roleId: { $in: userRoles.map((ur) => String(ur.role?.id || ur.role)) },
39
- deletedAt: null
40
- });
41
- const byRole = /* @__PURE__ */ new Map();
42
- for (const record of roleRecords) {
43
- const role = String(record.roleId);
44
- if (record.tenantId && ctx.tenantId && record.tenantId !== ctx.tenantId) continue;
45
- if (record.tenantId && !ctx.tenantId) continue;
46
- if (record.organizationId && ctx.organizationId && record.organizationId !== ctx.organizationId) continue;
47
- if (record.organizationId && !ctx.organizationId) continue;
48
- const current = byRole.get(role);
49
- if (!current || specificity(record) > specificity(current)) {
50
- byRole.set(role, record);
51
- }
52
- }
53
- const allowedByRole = /* @__PURE__ */ new Set();
54
- for (const record of byRole.values()) {
55
- for (const id of record.widgetIdsJson) {
56
- if (allWidgetIds.includes(id)) allowedByRole.add(id);
57
- }
58
- }
59
27
  let baseSet;
60
28
  if (allowedByUser) {
61
29
  baseSet = allowedByUser;
62
- } else if (allowedByRole.size > 0) {
63
- baseSet = allowedByRole;
64
30
  } else {
65
- baseSet = new Set(allWidgetIds);
31
+ const userRoles = await findWithDecryption(
32
+ em,
33
+ UserRole,
34
+ { user: ctx.userId, deletedAt: null },
35
+ { populate: ["role"] },
36
+ { tenantId: ctx.tenantId, organizationId: ctx.organizationId }
37
+ );
38
+ const roleRecords = await em.find(DashboardRoleWidgets, {
39
+ roleId: { $in: userRoles.map((ur) => String(ur.role?.id || ur.role)) },
40
+ deletedAt: null
41
+ });
42
+ const byRole = /* @__PURE__ */ new Map();
43
+ for (const record of roleRecords) {
44
+ const role = String(record.roleId);
45
+ if (record.tenantId && ctx.tenantId && record.tenantId !== ctx.tenantId) continue;
46
+ if (record.tenantId && !ctx.tenantId) continue;
47
+ if (record.organizationId && ctx.organizationId && record.organizationId !== ctx.organizationId) continue;
48
+ if (record.organizationId && !ctx.organizationId) continue;
49
+ const current = byRole.get(role);
50
+ if (!current || specificity(record) > specificity(current)) {
51
+ byRole.set(role, record);
52
+ }
53
+ }
54
+ const allowedByRole = /* @__PURE__ */ new Set();
55
+ for (const record of byRole.values()) {
56
+ for (const id of record.widgetIdsJson) {
57
+ if (allWidgetIds.includes(id)) allowedByRole.add(id);
58
+ }
59
+ }
60
+ baseSet = allowedByRole.size > 0 ? allowedByRole : new Set(allWidgetIds);
66
61
  }
67
62
  if (baseSet.size === 0) return [];
68
63
  const filtered = widgets.filter((widget) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/dashboards/lib/access.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { DashboardRoleWidgets, DashboardUserWidgets } from '../data/entities'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { hasAllFeatures as userHasAllFeatures } from '@open-mercato/shared/security/features'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\ntype LoadedWidget = {\n metadata: {\n id: string\n features?: string[]\n }\n}\n\ntype AccessContext = {\n userId: string\n tenantId: string | null\n organizationId: string | null\n features: string[]\n isSuperAdmin: boolean\n}\n\nfunction specificity(record: DashboardRoleWidgets): number {\n let score = 0\n if (record.tenantId) score += 1\n if (record.organizationId) score += 2\n return score\n}\n\nexport async function resolveAllowedWidgetIds(\n em: EntityManager,\n ctx: AccessContext,\n widgets: LoadedWidget[],\n): Promise<string[]> {\n const allWidgetIds = widgets.map((w) => w.metadata.id)\n\n // Load user override (if any)\n const userRecord = await em.findOne(DashboardUserWidgets, {\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n deletedAt: null,\n })\n\n let allowedByUser: Set<string> | null = null\n if (userRecord) {\n if (userRecord.mode === 'override') {\n allowedByUser = new Set(userRecord.widgetIdsJson.filter((id) => allWidgetIds.includes(id)))\n } else {\n allowedByUser = null\n }\n }\n\n if (allowedByUser && allowedByUser.size === 0) {\n return Array.from(allowedByUser)\n }\n\n // Aggregate role-level settings\n const userRoles = await findWithDecryption(\n em,\n UserRole,\n { user: ctx.userId as any, deletedAt: null },\n { populate: ['role'] },\n { tenantId: ctx.tenantId, organizationId: ctx.organizationId },\n )\n const roleRecords = await em.find(DashboardRoleWidgets, {\n roleId: { $in: userRoles.map((ur) => String(ur.role?.id || ur.role)) },\n deletedAt: null,\n })\n\n const byRole = new Map<string, DashboardRoleWidgets>()\n for (const record of roleRecords) {\n const role = String(record.roleId)\n if (record.tenantId && ctx.tenantId && record.tenantId !== ctx.tenantId) continue\n if (record.tenantId && !ctx.tenantId) continue\n if (record.organizationId && ctx.organizationId && record.organizationId !== ctx.organizationId) continue\n if (record.organizationId && !ctx.organizationId) continue\n const current = byRole.get(role)\n if (!current || specificity(record) > specificity(current)) {\n byRole.set(role, record)\n }\n }\n\n const allowedByRole = new Set<string>()\n for (const record of byRole.values()) {\n for (const id of record.widgetIdsJson) {\n if (allWidgetIds.includes(id)) allowedByRole.add(id)\n }\n }\n\n let baseSet: Set<string>\n if (allowedByUser) {\n baseSet = allowedByUser\n } else if (allowedByRole.size > 0) {\n baseSet = allowedByRole\n } else {\n baseSet = new Set(allWidgetIds)\n }\n\n if (baseSet.size === 0) return []\n\n const filtered = widgets.filter((widget) => {\n if (!baseSet.has(widget.metadata.id)) return false\n if (ctx.isSuperAdmin) return true\n return userHasAllFeatures(ctx.features, widget.metadata.features ?? [])\n })\n\n return filtered.map((widget) => widget.metadata.id)\n}\n"],
5
- "mappings": "AACA,SAAS,sBAAsB,4BAA4B;AAC3D,SAAS,gBAAgB;AACzB,SAAS,kBAAkB,0BAA0B;AACrD,SAAS,0BAA0B;AAiBnC,SAAS,YAAY,QAAsC;AACzD,MAAI,QAAQ;AACZ,MAAI,OAAO,SAAU,UAAS;AAC9B,MAAI,OAAO,eAAgB,UAAS;AACpC,SAAO;AACT;AAEA,eAAsB,wBACpB,IACA,KACA,SACmB;AACnB,QAAM,eAAe,QAAQ,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE;AAGrD,QAAM,aAAa,MAAM,GAAG,QAAQ,sBAAsB;AAAA,IACxD,QAAQ,IAAI;AAAA,IACZ,UAAU,IAAI;AAAA,IACd,gBAAgB,IAAI;AAAA,IACpB,WAAW;AAAA,EACb,CAAC;AAED,MAAI,gBAAoC;AACxC,MAAI,YAAY;AACd,QAAI,WAAW,SAAS,YAAY;AAClC,sBAAgB,IAAI,IAAI,WAAW,cAAc,OAAO,CAAC,OAAO,aAAa,SAAS,EAAE,CAAC,CAAC;AAAA,IAC5F,OAAO;AACL,sBAAgB;AAAA,IAClB;AAAA,EACF;AAEA,MAAI,iBAAiB,cAAc,SAAS,GAAG;AAC7C,WAAO,MAAM,KAAK,aAAa;AAAA,EACjC;AAGA,QAAM,YAAY,MAAM;AAAA,IACtB;AAAA,IACA;AAAA,IACA,EAAE,MAAM,IAAI,QAAe,WAAW,KAAK;AAAA,IAC3C,EAAE,UAAU,CAAC,MAAM,EAAE;AAAA,IACrB,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAAA,EAC/D;AACA,QAAM,cAAc,MAAM,GAAG,KAAK,sBAAsB;AAAA,IACtD,QAAQ,EAAE,KAAK,UAAU,IAAI,CAAC,OAAO,OAAO,GAAG,MAAM,MAAM,GAAG,IAAI,CAAC,EAAE;AAAA,IACrE,WAAW;AAAA,EACb,CAAC;AAED,QAAM,SAAS,oBAAI,IAAkC;AACrD,aAAW,UAAU,aAAa;AAChC,UAAM,OAAO,OAAO,OAAO,MAAM;AACjC,QAAI,OAAO,YAAY,IAAI,YAAY,OAAO,aAAa,IAAI,SAAU;AACzE,QAAI,OAAO,YAAY,CAAC,IAAI,SAAU;AACtC,QAAI,OAAO,kBAAkB,IAAI,kBAAkB,OAAO,mBAAmB,IAAI,eAAgB;AACjG,QAAI,OAAO,kBAAkB,CAAC,IAAI,eAAgB;AAClD,UAAM,UAAU,OAAO,IAAI,IAAI;AAC/B,QAAI,CAAC,WAAW,YAAY,MAAM,IAAI,YAAY,OAAO,GAAG;AAC1D,aAAO,IAAI,MAAM,MAAM;AAAA,IACzB;AAAA,EACF;AAEA,QAAM,gBAAgB,oBAAI,IAAY;AACtC,aAAW,UAAU,OAAO,OAAO,GAAG;AACpC,eAAW,MAAM,OAAO,eAAe;AACrC,UAAI,aAAa,SAAS,EAAE,EAAG,eAAc,IAAI,EAAE;AAAA,IACrD;AAAA,EACF;AAEA,MAAI;AACJ,MAAI,eAAe;AACjB,cAAU;AAAA,EACZ,WAAW,cAAc,OAAO,GAAG;AACjC,cAAU;AAAA,EACZ,OAAO;AACL,cAAU,IAAI,IAAI,YAAY;AAAA,EAChC;AAEA,MAAI,QAAQ,SAAS,EAAG,QAAO,CAAC;AAEhC,QAAM,WAAW,QAAQ,OAAO,CAAC,WAAW;AAC1C,QAAI,CAAC,QAAQ,IAAI,OAAO,SAAS,EAAE,EAAG,QAAO;AAC7C,QAAI,IAAI,aAAc,QAAO;AAC7B,WAAO,mBAAmB,IAAI,UAAU,OAAO,SAAS,YAAY,CAAC,CAAC;AAAA,EACxE,CAAC;AAED,SAAO,SAAS,IAAI,CAAC,WAAW,OAAO,SAAS,EAAE;AACpD;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { DashboardRoleWidgets, DashboardUserWidgets } from '../data/entities'\nimport { UserRole } from '@open-mercato/core/modules/auth/data/entities'\nimport { hasAllFeatures as userHasAllFeatures } from '@open-mercato/shared/security/features'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\ntype LoadedWidget = {\n metadata: {\n id: string\n features?: string[]\n }\n}\n\ntype AccessContext = {\n userId: string\n tenantId: string | null\n organizationId: string | null\n features: string[]\n isSuperAdmin: boolean\n}\n\nfunction specificity(record: DashboardRoleWidgets): number {\n let score = 0\n if (record.tenantId) score += 1\n if (record.organizationId) score += 2\n return score\n}\n\nexport async function resolveAllowedWidgetIds(\n em: EntityManager,\n ctx: AccessContext,\n widgets: LoadedWidget[],\n): Promise<string[]> {\n const allWidgetIds = widgets.map((w) => w.metadata.id)\n\n // Load user override (if any)\n const userRecord = await em.findOne(DashboardUserWidgets, {\n userId: ctx.userId,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n deletedAt: null,\n })\n\n let allowedByUser: Set<string> | null = null\n if (userRecord) {\n if (userRecord.mode === 'override') {\n allowedByUser = new Set(userRecord.widgetIdsJson.filter((id) => allWidgetIds.includes(id)))\n } else {\n allowedByUser = null\n }\n }\n\n let baseSet: Set<string>\n if (allowedByUser) {\n // A user override fully determines visibility, so role-level lookups would\n // be discarded \u2014 skip them entirely. An empty override is handled by the\n // shared `baseSet.size === 0` guard below.\n baseSet = allowedByUser\n } else {\n // No user override: aggregate role-level settings.\n const userRoles = await findWithDecryption(\n em,\n UserRole,\n { user: ctx.userId as any, deletedAt: null },\n { populate: ['role'] },\n { tenantId: ctx.tenantId, organizationId: ctx.organizationId },\n )\n const roleRecords = await em.find(DashboardRoleWidgets, {\n roleId: { $in: userRoles.map((ur) => String(ur.role?.id || ur.role)) },\n deletedAt: null,\n })\n\n const byRole = new Map<string, DashboardRoleWidgets>()\n for (const record of roleRecords) {\n const role = String(record.roleId)\n if (record.tenantId && ctx.tenantId && record.tenantId !== ctx.tenantId) continue\n if (record.tenantId && !ctx.tenantId) continue\n if (record.organizationId && ctx.organizationId && record.organizationId !== ctx.organizationId) continue\n if (record.organizationId && !ctx.organizationId) continue\n const current = byRole.get(role)\n if (!current || specificity(record) > specificity(current)) {\n byRole.set(role, record)\n }\n }\n\n const allowedByRole = new Set<string>()\n for (const record of byRole.values()) {\n for (const id of record.widgetIdsJson) {\n if (allWidgetIds.includes(id)) allowedByRole.add(id)\n }\n }\n\n baseSet = allowedByRole.size > 0 ? allowedByRole : new Set(allWidgetIds)\n }\n\n if (baseSet.size === 0) return []\n\n const filtered = widgets.filter((widget) => {\n if (!baseSet.has(widget.metadata.id)) return false\n if (ctx.isSuperAdmin) return true\n return userHasAllFeatures(ctx.features, widget.metadata.features ?? [])\n })\n\n return filtered.map((widget) => widget.metadata.id)\n}\n"],
5
+ "mappings": "AACA,SAAS,sBAAsB,4BAA4B;AAC3D,SAAS,gBAAgB;AACzB,SAAS,kBAAkB,0BAA0B;AACrD,SAAS,0BAA0B;AAiBnC,SAAS,YAAY,QAAsC;AACzD,MAAI,QAAQ;AACZ,MAAI,OAAO,SAAU,UAAS;AAC9B,MAAI,OAAO,eAAgB,UAAS;AACpC,SAAO;AACT;AAEA,eAAsB,wBACpB,IACA,KACA,SACmB;AACnB,QAAM,eAAe,QAAQ,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE;AAGrD,QAAM,aAAa,MAAM,GAAG,QAAQ,sBAAsB;AAAA,IACxD,QAAQ,IAAI;AAAA,IACZ,UAAU,IAAI;AAAA,IACd,gBAAgB,IAAI;AAAA,IACpB,WAAW;AAAA,EACb,CAAC;AAED,MAAI,gBAAoC;AACxC,MAAI,YAAY;AACd,QAAI,WAAW,SAAS,YAAY;AAClC,sBAAgB,IAAI,IAAI,WAAW,cAAc,OAAO,CAAC,OAAO,aAAa,SAAS,EAAE,CAAC,CAAC;AAAA,IAC5F,OAAO;AACL,sBAAgB;AAAA,IAClB;AAAA,EACF;AAEA,MAAI;AACJ,MAAI,eAAe;AAIjB,cAAU;AAAA,EACZ,OAAO;AAEL,UAAM,YAAY,MAAM;AAAA,MACtB;AAAA,MACA;AAAA,MACA,EAAE,MAAM,IAAI,QAAe,WAAW,KAAK;AAAA,MAC3C,EAAE,UAAU,CAAC,MAAM,EAAE;AAAA,MACrB,EAAE,UAAU,IAAI,UAAU,gBAAgB,IAAI,eAAe;AAAA,IAC/D;AACA,UAAM,cAAc,MAAM,GAAG,KAAK,sBAAsB;AAAA,MACtD,QAAQ,EAAE,KAAK,UAAU,IAAI,CAAC,OAAO,OAAO,GAAG,MAAM,MAAM,GAAG,IAAI,CAAC,EAAE;AAAA,MACrE,WAAW;AAAA,IACb,CAAC;AAED,UAAM,SAAS,oBAAI,IAAkC;AACrD,eAAW,UAAU,aAAa;AAChC,YAAM,OAAO,OAAO,OAAO,MAAM;AACjC,UAAI,OAAO,YAAY,IAAI,YAAY,OAAO,aAAa,IAAI,SAAU;AACzE,UAAI,OAAO,YAAY,CAAC,IAAI,SAAU;AACtC,UAAI,OAAO,kBAAkB,IAAI,kBAAkB,OAAO,mBAAmB,IAAI,eAAgB;AACjG,UAAI,OAAO,kBAAkB,CAAC,IAAI,eAAgB;AAClD,YAAM,UAAU,OAAO,IAAI,IAAI;AAC/B,UAAI,CAAC,WAAW,YAAY,MAAM,IAAI,YAAY,OAAO,GAAG;AAC1D,eAAO,IAAI,MAAM,MAAM;AAAA,MACzB;AAAA,IACF;AAEA,UAAM,gBAAgB,oBAAI,IAAY;AACtC,eAAW,UAAU,OAAO,OAAO,GAAG;AACpC,iBAAW,MAAM,OAAO,eAAe;AACrC,YAAI,aAAa,SAAS,EAAE,EAAG,eAAc,IAAI,EAAE;AAAA,MACrD;AAAA,IACF;AAEA,cAAU,cAAc,OAAO,IAAI,gBAAgB,IAAI,IAAI,YAAY;AAAA,EACzE;AAEA,MAAI,QAAQ,SAAS,EAAG,QAAO,CAAC;AAEhC,QAAM,WAAW,QAAQ,OAAO,CAAC,WAAW;AAC1C,QAAI,CAAC,QAAQ,IAAI,OAAO,SAAS,EAAE,EAAG,QAAO;AAC7C,QAAI,IAAI,aAAc,QAAO;AAC7B,WAAO,mBAAmB,IAAI,UAAU,OAAO,SAAS,YAAY,CAAC,CAAC;AAAA,EACxE,CAAC;AAED,SAAO,SAAS,IAAI,CAAC,WAAW,OAAO,SAAS,EAAE;AACpD;",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
+ import { runWithCacheTenant } from "@open-mercato/cache";
3
4
  import { Dictionary, DictionaryEntry } from "@open-mercato/core/modules/dictionaries/data/entities";
4
5
  import { resolveDictionariesRouteContext } from "@open-mercato/core/modules/dictionaries/api/context";
5
6
  import { createDictionaryEntrySchema } from "@open-mercato/core/modules/dictionaries/data/validators";
@@ -14,11 +15,23 @@ import {
14
15
  dictionariesTag
15
16
  } from "../../openapi.js";
16
17
  import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
18
+ import {
19
+ buildCollectionTags,
20
+ buildRecordTag,
21
+ isCrudCacheEnabled,
22
+ resolveCrudCache
23
+ } from "@open-mercato/shared/lib/crud/cache";
17
24
  import {
18
25
  resolveDictionaryEntrySortMode,
19
26
  sortDictionaryEntries
20
27
  } from "@open-mercato/core/modules/dictionaries/lib/entrySort";
21
28
  const paramsSchema = z.object({ dictionaryId: z.string().uuid() });
29
+ const DICTIONARY_ENTRY_RESOURCE = "dictionaries.entry";
30
+ const DICTIONARY_DEFINITION_RESOURCE = "dictionaries.dictionary";
31
+ const DICTIONARY_ENTRIES_TTL_MS = 5 * 6e4;
32
+ function buildEntriesCacheKey(params) {
33
+ return `dictionaries:entries:${params.dictionaryId}:org=${params.organizationId ?? "null"}:sort=${params.sortMode}`;
34
+ }
22
35
  async function loadDictionary(context, id, options = {}) {
23
36
  const { allowInherited = false } = options;
24
37
  if (!allowInherited && !context.organizationId) {
@@ -54,6 +67,17 @@ async function GET(req, ctx) {
54
67
  }
55
68
  const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId });
56
69
  const dictionary = await loadDictionary(context, dictionaryId, { allowInherited: true });
70
+ const dictionaryTenantId = dictionary.tenantId;
71
+ const dictionaryOrgId = dictionary.organizationId ?? null;
72
+ const sortMode = resolveDictionaryEntrySortMode(dictionary.entrySortMode);
73
+ const cache = isCrudCacheEnabled() ? resolveCrudCache(context.container) : null;
74
+ const cacheKey = cache ? buildEntriesCacheKey({ dictionaryId, organizationId: dictionaryOrgId, sortMode }) : null;
75
+ if (cache && cacheKey) {
76
+ const cached = await runWithCacheTenant(dictionaryTenantId, () => cache.get(cacheKey));
77
+ if (cached) {
78
+ return NextResponse.json(cached);
79
+ }
80
+ }
57
81
  const entries = await findWithDecryption(
58
82
  context.em,
59
83
  DictionaryEntry,
@@ -65,8 +89,8 @@ async function GET(req, ctx) {
65
89
  {},
66
90
  { tenantId: dictionary.tenantId, organizationId: dictionary.organizationId }
67
91
  );
68
- const sortedEntries = sortDictionaryEntries(entries, resolveDictionaryEntrySortMode(dictionary.entrySortMode));
69
- return NextResponse.json({
92
+ const sortedEntries = sortDictionaryEntries(entries, sortMode);
93
+ const payload = {
70
94
  items: sortedEntries.map((entry) => ({
71
95
  id: entry.id,
72
96
  value: entry.value,
@@ -78,7 +102,23 @@ async function GET(req, ctx) {
78
102
  createdAt: entry.createdAt,
79
103
  updatedAt: entry.updatedAt
80
104
  }))
81
- });
105
+ };
106
+ if (cache && cacheKey) {
107
+ try {
108
+ await runWithCacheTenant(
109
+ dictionaryTenantId,
110
+ () => cache.set(cacheKey, payload, {
111
+ ttl: DICTIONARY_ENTRIES_TTL_MS,
112
+ tags: [
113
+ ...buildCollectionTags(DICTIONARY_ENTRY_RESOURCE, dictionaryTenantId, [dictionaryOrgId]),
114
+ buildRecordTag(DICTIONARY_DEFINITION_RESOURCE, dictionaryTenantId, dictionaryId)
115
+ ]
116
+ })
117
+ );
118
+ } catch {
119
+ }
120
+ }
121
+ return NextResponse.json(payload);
82
122
  } catch (err) {
83
123
  if (isCrudHttpError(err)) {
84
124
  return NextResponse.json(err.body, { status: err.status });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/dictionaries/api/%5BdictionaryId%5D/entries/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'\nimport { resolveDictionariesRouteContext } from '@open-mercato/core/modules/dictionaries/api/context'\nimport { createDictionaryEntrySchema } from '@open-mercato/core/modules/dictionaries/data/validators'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n createDictionaryEntrySchema as createEntryDocSchema,\n dictionaryEntryListResponseSchema,\n dictionaryEntryResponseSchema,\n dictionaryIdParamsSchema,\n dictionariesErrorSchema,\n dictionariesTag,\n} from '../../openapi'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n resolveDictionaryEntrySortMode,\n sortDictionaryEntries,\n} from '@open-mercato/core/modules/dictionaries/lib/entrySort'\n\nconst paramsSchema = z.object({ dictionaryId: z.string().uuid() })\n\nasync function loadDictionary(\n context: Awaited<ReturnType<typeof resolveDictionariesRouteContext>>,\n id: string,\n options: { allowInherited?: boolean } = {},\n) {\n const { allowInherited = false } = options\n if (!allowInherited && !context.organizationId) {\n throw new CrudHttpError(400, { error: context.translate('dictionaries.errors.organization_required', 'Organization context is required') })\n }\n const baseFilter = {\n id,\n tenantId: context.tenantId,\n deletedAt: null,\n }\n const filter = allowInherited\n ? {\n ...baseFilter,\n ...(context.readableOrganizationIds.length\n ? { organizationId: { $in: context.readableOrganizationIds } }\n : {}),\n }\n : {\n ...baseFilter,\n organizationId: context.organizationId,\n }\n const dictionary = await context.em.findOne(Dictionary, filter)\n if (!dictionary) {\n throw new CrudHttpError(404, { error: context.translate('dictionaries.errors.not_found', 'Dictionary not found') })\n }\n return dictionary\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dictionaries.view'] },\n POST: { requireAuth: true, requireFeatures: ['dictionaries.manage'] },\n}\n\nexport async function GET(req: Request, ctx: { params?: { dictionaryId?: string } }) {\n try {\n const context = await resolveDictionariesRouteContext(req)\n if (!context.auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })\n const dictionary = await loadDictionary(context, dictionaryId, { allowInherited: true })\n const entries = await findWithDecryption(\n context.em,\n DictionaryEntry,\n {\n dictionary,\n organizationId: dictionary.organizationId,\n tenantId: dictionary.tenantId,\n },\n {},\n { tenantId: dictionary.tenantId, organizationId: dictionary.organizationId },\n )\n const sortedEntries = sortDictionaryEntries(entries, resolveDictionaryEntrySortMode(dictionary.entrySortMode))\n\n return NextResponse.json({\n items: sortedEntries.map((entry) => ({\n id: entry.id,\n value: entry.value,\n label: entry.label,\n color: entry.color,\n icon: entry.icon,\n position: entry.position ?? 0,\n isDefault: entry.isDefault ?? false,\n createdAt: entry.createdAt,\n updatedAt: entry.updatedAt,\n })),\n })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[dictionaries/:id/entries.GET] Unexpected error', err)\n return NextResponse.json({ error: 'Failed to load dictionary entries' }, { status: 500 })\n }\n}\n\nexport async function POST(req: Request, ctx: { params?: { dictionaryId?: string } }) {\n try {\n const context = await resolveDictionariesRouteContext(req)\n if (!context.auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })\n const payload = createDictionaryEntrySchema.parse(await req.json().catch(() => ({})))\n // These nested routes do not use makeCrudRoute, so we invoke the command bus directly.\n const commandBus = (context.container.resolve('commandBus') as CommandBus)\n const { result, logEntry } = await commandBus.execute('dictionaries.entries.create', {\n input: { ...payload, dictionaryId },\n ctx: context.ctx,\n })\n const createResult = (result ?? {}) as { entryId?: string | null }\n const createdEntryId = typeof createResult.entryId === 'string' ? createResult.entryId : null\n if (!createdEntryId) {\n throw new CrudHttpError(500, { error: context.translate('dictionaries.errors.entry_create_failed', 'Failed to create dictionary entry') })\n }\n const entry = await findOneWithDecryption(\n context.em.fork(),\n DictionaryEntry,\n createdEntryId,\n { populate: ['dictionary'] },\n { tenantId: context.auth.tenantId ?? null, organizationId: context.auth.orgId ?? null },\n )\n if (!entry) {\n throw new CrudHttpError(500, { error: context.translate('dictionaries.errors.entry_create_failed', 'Failed to create dictionary entry') })\n }\n const response = NextResponse.json({\n id: entry.id,\n value: entry.value,\n label: entry.label,\n color: entry.color,\n icon: entry.icon,\n position: entry.position ?? 0,\n isDefault: entry.isDefault ?? false,\n createdAt: entry.createdAt,\n updatedAt: entry.updatedAt,\n }, { status: 201 })\n if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {\n response.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? 'dictionaries.entry',\n resourceId: createdEntryId,\n executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : undefined,\n })\n )\n }\n return response\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[dictionaries/:id/entries.POST] Unexpected error', err)\n return NextResponse.json({ error: 'Failed to create dictionary entry' }, { status: 500 })\n }\n}\n\nconst dictionaryEntriesGetDoc: OpenApiMethodDoc = {\n summary: 'List dictionary entries',\n description: 'Returns entries for the specified dictionary ordered by its configured entry sort mode.',\n tags: [dictionariesTag],\n responses: [\n { status: 200, description: 'Dictionary entries.', schema: dictionaryEntryListResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid parameters', schema: dictionariesErrorSchema },\n { status: 401, description: 'Authentication required', schema: dictionariesErrorSchema },\n { status: 404, description: 'Dictionary not found', schema: dictionariesErrorSchema },\n { status: 500, description: 'Failed to load dictionary entries', schema: dictionariesErrorSchema },\n ],\n}\n\nconst dictionaryEntriesPostDoc: OpenApiMethodDoc = {\n summary: 'Create dictionary entry',\n description: 'Creates a new entry in the specified dictionary.',\n tags: [dictionariesTag],\n requestBody: {\n contentType: 'application/json',\n schema: createEntryDocSchema,\n description: 'Entry value, label, and optional presentation metadata.',\n },\n responses: [\n { status: 201, description: 'Dictionary entry created.', schema: dictionaryEntryResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: dictionariesErrorSchema },\n { status: 401, description: 'Authentication required', schema: dictionariesErrorSchema },\n { status: 404, description: 'Dictionary not found', schema: dictionariesErrorSchema },\n { status: 500, description: 'Failed to create dictionary entry', schema: dictionariesErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: dictionariesTag,\n summary: 'Dictionary entries collection',\n pathParams: dictionaryIdParamsSchema,\n methods: {\n GET: dictionaryEntriesGetDoc,\n POST: dictionaryEntriesPostDoc,\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'\nimport { resolveDictionariesRouteContext } from '@open-mercato/core/modules/dictionaries/api/context'\nimport { createDictionaryEntrySchema } from '@open-mercato/core/modules/dictionaries/data/validators'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n createDictionaryEntrySchema as createEntryDocSchema,\n dictionaryEntryListResponseSchema,\n dictionaryEntryResponseSchema,\n dictionaryIdParamsSchema,\n dictionariesErrorSchema,\n dictionariesTag,\n} from '../../openapi'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n buildCollectionTags,\n buildRecordTag,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport {\n resolveDictionaryEntrySortMode,\n sortDictionaryEntries,\n} from '@open-mercato/core/modules/dictionaries/lib/entrySort'\n\nconst paramsSchema = z.object({ dictionaryId: z.string().uuid() })\n\nconst DICTIONARY_ENTRY_RESOURCE = 'dictionaries.entry'\nconst DICTIONARY_DEFINITION_RESOURCE = 'dictionaries.dictionary'\nconst DICTIONARY_ENTRIES_TTL_MS = 5 * 60_000\n\nfunction buildEntriesCacheKey(params: {\n dictionaryId: string\n organizationId: string | null\n sortMode: string\n}): string {\n return `dictionaries:entries:${params.dictionaryId}:org=${params.organizationId ?? 'null'}:sort=${params.sortMode}`\n}\n\nasync function loadDictionary(\n context: Awaited<ReturnType<typeof resolveDictionariesRouteContext>>,\n id: string,\n options: { allowInherited?: boolean } = {},\n) {\n const { allowInherited = false } = options\n if (!allowInherited && !context.organizationId) {\n throw new CrudHttpError(400, { error: context.translate('dictionaries.errors.organization_required', 'Organization context is required') })\n }\n const baseFilter = {\n id,\n tenantId: context.tenantId,\n deletedAt: null,\n }\n const filter = allowInherited\n ? {\n ...baseFilter,\n ...(context.readableOrganizationIds.length\n ? { organizationId: { $in: context.readableOrganizationIds } }\n : {}),\n }\n : {\n ...baseFilter,\n organizationId: context.organizationId,\n }\n const dictionary = await context.em.findOne(Dictionary, filter)\n if (!dictionary) {\n throw new CrudHttpError(404, { error: context.translate('dictionaries.errors.not_found', 'Dictionary not found') })\n }\n return dictionary\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dictionaries.view'] },\n POST: { requireAuth: true, requireFeatures: ['dictionaries.manage'] },\n}\n\nexport async function GET(req: Request, ctx: { params?: { dictionaryId?: string } }) {\n try {\n const context = await resolveDictionariesRouteContext(req)\n if (!context.auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })\n const dictionary = await loadDictionary(context, dictionaryId, { allowInherited: true })\n const dictionaryTenantId = dictionary.tenantId\n const dictionaryOrgId = dictionary.organizationId ?? null\n const sortMode = resolveDictionaryEntrySortMode(dictionary.entrySortMode)\n\n // Dictionary CF selects hit this on every CrudForm open, so cache the\n // decrypted+sorted options payload. The entry writes flow through the\n // command bus (resourceKind dictionaries.entry), which already flushes the\n // matching collection tag post-commit \u2014 no new invalidation wiring needed.\n const cache = isCrudCacheEnabled() ? resolveCrudCache(context.container) : null\n const cacheKey = cache\n ? buildEntriesCacheKey({ dictionaryId, organizationId: dictionaryOrgId, sortMode })\n : null\n\n if (cache && cacheKey) {\n const cached = await runWithCacheTenant(dictionaryTenantId, () => cache.get(cacheKey))\n if (cached) {\n return NextResponse.json(cached)\n }\n }\n\n const entries = await findWithDecryption(\n context.em,\n DictionaryEntry,\n {\n dictionary,\n organizationId: dictionary.organizationId,\n tenantId: dictionary.tenantId,\n },\n {},\n { tenantId: dictionary.tenantId, organizationId: dictionary.organizationId },\n )\n const sortedEntries = sortDictionaryEntries(entries, sortMode)\n\n const payload = {\n items: sortedEntries.map((entry) => ({\n id: entry.id,\n value: entry.value,\n label: entry.label,\n color: entry.color,\n icon: entry.icon,\n position: entry.position ?? 0,\n isDefault: entry.isDefault ?? false,\n createdAt: entry.createdAt,\n updatedAt: entry.updatedAt,\n })),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(dictionaryTenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: DICTIONARY_ENTRIES_TTL_MS,\n tags: [\n ...buildCollectionTags(DICTIONARY_ENTRY_RESOURCE, dictionaryTenantId, [dictionaryOrgId]),\n buildRecordTag(DICTIONARY_DEFINITION_RESOURCE, dictionaryTenantId, dictionaryId),\n ],\n }),\n )\n } catch {}\n }\n\n return NextResponse.json(payload)\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[dictionaries/:id/entries.GET] Unexpected error', err)\n return NextResponse.json({ error: 'Failed to load dictionary entries' }, { status: 500 })\n }\n}\n\nexport async function POST(req: Request, ctx: { params?: { dictionaryId?: string } }) {\n try {\n const context = await resolveDictionariesRouteContext(req)\n if (!context.auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })\n const payload = createDictionaryEntrySchema.parse(await req.json().catch(() => ({})))\n // These nested routes do not use makeCrudRoute, so we invoke the command bus directly.\n const commandBus = (context.container.resolve('commandBus') as CommandBus)\n const { result, logEntry } = await commandBus.execute('dictionaries.entries.create', {\n input: { ...payload, dictionaryId },\n ctx: context.ctx,\n })\n const createResult = (result ?? {}) as { entryId?: string | null }\n const createdEntryId = typeof createResult.entryId === 'string' ? createResult.entryId : null\n if (!createdEntryId) {\n throw new CrudHttpError(500, { error: context.translate('dictionaries.errors.entry_create_failed', 'Failed to create dictionary entry') })\n }\n const entry = await findOneWithDecryption(\n context.em.fork(),\n DictionaryEntry,\n createdEntryId,\n { populate: ['dictionary'] },\n { tenantId: context.auth.tenantId ?? null, organizationId: context.auth.orgId ?? null },\n )\n if (!entry) {\n throw new CrudHttpError(500, { error: context.translate('dictionaries.errors.entry_create_failed', 'Failed to create dictionary entry') })\n }\n const response = NextResponse.json({\n id: entry.id,\n value: entry.value,\n label: entry.label,\n color: entry.color,\n icon: entry.icon,\n position: entry.position ?? 0,\n isDefault: entry.isDefault ?? false,\n createdAt: entry.createdAt,\n updatedAt: entry.updatedAt,\n }, { status: 201 })\n if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {\n response.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? 'dictionaries.entry',\n resourceId: createdEntryId,\n executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : undefined,\n })\n )\n }\n return response\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n console.error('[dictionaries/:id/entries.POST] Unexpected error', err)\n return NextResponse.json({ error: 'Failed to create dictionary entry' }, { status: 500 })\n }\n}\n\nconst dictionaryEntriesGetDoc: OpenApiMethodDoc = {\n summary: 'List dictionary entries',\n description: 'Returns entries for the specified dictionary ordered by its configured entry sort mode.',\n tags: [dictionariesTag],\n responses: [\n { status: 200, description: 'Dictionary entries.', schema: dictionaryEntryListResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid parameters', schema: dictionariesErrorSchema },\n { status: 401, description: 'Authentication required', schema: dictionariesErrorSchema },\n { status: 404, description: 'Dictionary not found', schema: dictionariesErrorSchema },\n { status: 500, description: 'Failed to load dictionary entries', schema: dictionariesErrorSchema },\n ],\n}\n\nconst dictionaryEntriesPostDoc: OpenApiMethodDoc = {\n summary: 'Create dictionary entry',\n description: 'Creates a new entry in the specified dictionary.',\n tags: [dictionariesTag],\n requestBody: {\n contentType: 'application/json',\n schema: createEntryDocSchema,\n description: 'Entry value, label, and optional presentation metadata.',\n },\n responses: [\n { status: 201, description: 'Dictionary entry created.', schema: dictionaryEntryResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Validation failed', schema: dictionariesErrorSchema },\n { status: 401, description: 'Authentication required', schema: dictionariesErrorSchema },\n { status: 404, description: 'Dictionary not found', schema: dictionariesErrorSchema },\n { status: 500, description: 'Failed to create dictionary entry', schema: dictionariesErrorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: dictionariesTag,\n summary: 'Dictionary entries collection',\n pathParams: dictionaryIdParamsSchema,\n methods: {\n GET: dictionaryEntriesGetDoc,\n POST: dictionaryEntriesPostDoc,\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { FieldRegistry } from "@open-mercato/ui/backend/fields/registry";
5
5
  import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
6
6
  import { useT } from "@open-mercato/shared/lib/i18n/context";
7
+ import { Checkbox } from "@open-mercato/ui/primitives/checkbox";
7
8
  import {
8
9
  Select,
9
10
  SelectContent,
@@ -36,7 +37,7 @@ function DictionaryDefaultSelector({
36
37
  }
37
38
  ),
38
39
  isLoading ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("dictionaries.customFields.loading", "Loading dictionaries\u2026") }) : null,
39
- isStale ? /* @__PURE__ */ jsx("p", { className: "text-xs text-amber-600", children: t("dictionaries.customFields.defaultValueStale", "Default entry not found \u2014 it may have been deleted or renamed.") }) : null
40
+ isStale ? /* @__PURE__ */ jsx("p", { className: "text-xs text-status-warning-text", children: t("dictionaries.customFields.defaultValueStale", "Default entry not found \u2014 it may have been deleted or renamed.") }) : null
40
41
  ] });
41
42
  }
42
43
  function DictionaryFieldDefEditor({ def, onChange }) {
@@ -44,6 +45,7 @@ function DictionaryFieldDefEditor({ def, onChange }) {
44
45
  const [items, setItems] = React.useState([]);
45
46
  const [loading, setLoading] = React.useState(false);
46
47
  const [error, setError] = React.useState(null);
48
+ const inlineCreateId = React.useId();
47
49
  const selectedId = typeof def?.configJson?.dictionaryId === "string" ? def?.configJson?.dictionaryId : "";
48
50
  const inlineCreate = def?.configJson?.dictionaryInlineCreate !== false;
49
51
  React.useEffect(() => {
@@ -106,24 +108,31 @@ function DictionaryFieldDefEditor({ def, onChange }) {
106
108
  }
107
109
  ),
108
110
  loading ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("dictionaries.customFields.loading", "Loading dictionaries\u2026") }) : null,
109
- error ? /* @__PURE__ */ jsx("p", { className: "text-xs text-red-600", children: error }) : null,
111
+ error ? /* @__PURE__ */ jsx("p", { className: "text-xs text-status-error-text", children: error }) : null,
110
112
  !loading && !error && items.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("dictionaries.customFields.empty", "No dictionaries available yet. Create one first.") }) : null
111
113
  ] }),
112
114
  selectedId ? /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2 rounded bg-background/80 px-2 py-1 text-xs text-muted-foreground", children: [
113
115
  /* @__PURE__ */ jsx("span", { children: t("dictionaries.customFields.selectedHint", "Entries from this dictionary populate the field.") }),
114
116
  /* @__PURE__ */ jsx("a", { href: manageHref, className: "font-medium text-primary hover:underline", target: "_blank", rel: "noreferrer", children: t("dictionaries.customFields.manageLink", "Manage dictionaries") })
115
117
  ] }) : null,
116
- /* @__PURE__ */ jsxs("label", { className: "inline-flex items-center gap-2 text-xs", children: [
118
+ /* @__PURE__ */ jsxs("div", { className: "inline-flex items-center gap-2 text-xs", children: [
117
119
  /* @__PURE__ */ jsx(
118
- "input",
120
+ Checkbox,
119
121
  {
120
- type: "checkbox",
122
+ id: inlineCreateId,
121
123
  checked: inlineCreate,
122
- onChange: (event) => onChange({ dictionaryInlineCreate: event.target.checked }),
124
+ onCheckedChange: (checked) => onChange({ dictionaryInlineCreate: checked === true }),
123
125
  disabled: !selectedId
124
126
  }
125
127
  ),
126
- t("dictionaries.customFields.allowInlineCreate", "Allow inline creation inside forms")
128
+ /* @__PURE__ */ jsx(
129
+ "label",
130
+ {
131
+ htmlFor: inlineCreateId,
132
+ className: selectedId ? "cursor-pointer select-none" : "cursor-not-allowed opacity-60",
133
+ children: t("dictionaries.customFields.allowInlineCreate", "Allow inline creation inside forms")
134
+ }
135
+ )
127
136
  ] }),
128
137
  selectedId ? /* @__PURE__ */ jsx(
129
138
  DictionaryDefaultSelector,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/dictionaries/fields/dictionary.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { CrudCustomFieldRenderProps } from '@open-mercato/ui/backend/CrudForm'\nimport { FieldRegistry } from '@open-mercato/ui/backend/fields/registry'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { DictionarySelectControl } from '../components/DictionarySelectControl'\nimport { useDictionaryEntries } from '../components/hooks/useDictionaryEntries'\n\ntype DictionaryFieldDefinition = {\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string\n}\n\ntype Props = CrudCustomFieldRenderProps & { def?: DictionaryFieldDefinition }\n\ntype DictionarySummary = {\n id: string\n name: string\n key: string\n isActive: boolean\n}\n\nfunction DictionaryDefaultSelector({\n dictionaryId,\n defaultValue,\n onChange,\n}: {\n dictionaryId: string\n defaultValue: string\n onChange: (value: string) => void\n}) {\n const t = useT()\n const { data, isLoading } = useDictionaryEntries(dictionaryId)\n const entries = data?.entries ?? []\n const isStale = defaultValue && entries.length > 0 && !entries.some((e) => e.value === defaultValue)\n\n return (\n <div className=\"space-y-1\">\n <label className=\"text-xs font-medium text-muted-foreground\">\n {t('dictionaries.customFields.defaultValue', 'Default value')}\n </label>\n <Select\n value={defaultValue ?? ''}\n onValueChange={(next) => onChange(next ?? '')}\n >\n <SelectTrigger size=\"sm\">\n <SelectValue placeholder={t('dictionaries.customFields.defaultValueNone', 'No default')} />\n </SelectTrigger>\n <SelectContent>\n {entries.map((entry) => (\n <SelectItem key={entry.value} value={entry.value}>\n {entry.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {isLoading ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('dictionaries.customFields.loading', 'Loading dictionaries\u2026')}\n </p>\n ) : null}\n {isStale ? (\n <p className=\"text-xs text-amber-600\">\n {t('dictionaries.customFields.defaultValueStale', 'Default entry not found \u2014 it may have been deleted or renamed.')}\n </p>\n ) : null}\n </div>\n )\n}\n\nfunction DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: DictionaryFieldDefinition } | undefined; onChange: (patch: Partial<DictionaryFieldDefinition>) => void }) {\n const t = useT()\n const [items, setItems] = React.useState<DictionarySummary[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const selectedId = typeof def?.configJson?.dictionaryId === 'string' ? def?.configJson?.dictionaryId : ''\n const inlineCreate = def?.configJson?.dictionaryInlineCreate !== false\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setLoading(true)\n setError(null)\n try {\n const call = await apiCall<{ items?: unknown[]; error?: string }>(\n '/api/dictionaries?includeInactive=true',\n )\n if (!call.ok) {\n const message =\n typeof call.result?.error === 'string' ? call.result.error : 'Failed to load dictionaries'\n throw new Error(message)\n }\n const entries = Array.isArray(call.result?.items) ? call.result!.items : []\n if (!cancelled) {\n setItems(\n entries.map((entry: any) => ({\n id: String(entry.id),\n name: typeof entry.name === 'string' && entry.name.trim().length ? entry.name : String(entry.key ?? entry.id),\n key: typeof entry.key === 'string' ? entry.key : '',\n isActive: entry.isActive !== false,\n })),\n )\n }\n } catch (err) {\n if (!cancelled) {\n console.error('Failed to load dictionaries list', err)\n setError(t('dictionaries.customFields.errorLoad', 'Failed to load dictionaries.'))\n }\n } finally {\n if (!cancelled) {\n setLoading(false)\n }\n }\n }\n load().catch(() => {})\n return () => {\n cancelled = true\n }\n }, [t])\n\n const manageHref = '/backend/config/dictionaries'\n\n return (\n <div className=\"mt-3 space-y-3 rounded border border-dashed border-muted-foreground/40 bg-muted/30 p-3\">\n <div className=\"space-y-1\">\n <label className=\"text-xs font-medium text-muted-foreground\">\n {t('dictionaries.customFields.dictionaryLabel', 'Dictionary source')}\n </label>\n <Select\n value={selectedId ?? ''}\n onValueChange={(next) => onChange({ dictionaryId: next || undefined })}\n >\n <SelectTrigger size=\"sm\">\n <SelectValue placeholder={t('dictionaries.customFields.dictionaryPlaceholder', 'Select a dictionary')} />\n </SelectTrigger>\n <SelectContent>\n {items.map((item) => (\n <SelectItem key={item.id} value={item.id}>\n {item.name}\n {item.isActive ? '' : ` (${t('dictionaries.customFields.inactive', 'inactive')})`}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {loading ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('dictionaries.customFields.loading', 'Loading dictionaries\u2026')}\n </p>\n ) : null}\n {error ? <p className=\"text-xs text-red-600\">{error}</p> : null}\n {!loading && !error && items.length === 0 ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('dictionaries.customFields.empty', 'No dictionaries available yet. Create one first.')}\n </p>\n ) : null}\n </div>\n {selectedId ? (\n <div className=\"flex flex-wrap items-center justify-between gap-2 rounded bg-background/80 px-2 py-1 text-xs text-muted-foreground\">\n <span>{t('dictionaries.customFields.selectedHint', 'Entries from this dictionary populate the field.')}</span>\n <a href={manageHref} className=\"font-medium text-primary hover:underline\" target=\"_blank\" rel=\"noreferrer\">\n {t('dictionaries.customFields.manageLink', 'Manage dictionaries')}\n </a>\n </div>\n ) : null}\n <label className=\"inline-flex items-center gap-2 text-xs\">\n <input\n type=\"checkbox\"\n checked={inlineCreate}\n onChange={(event) => onChange({ dictionaryInlineCreate: event.target.checked })}\n disabled={!selectedId}\n />\n {t('dictionaries.customFields.allowInlineCreate', 'Allow inline creation inside forms')}\n </label>\n {selectedId ? (\n <DictionaryDefaultSelector\n dictionaryId={selectedId}\n defaultValue={typeof def?.configJson?.defaultValue === 'string' ? def.configJson.defaultValue : ''}\n onChange={(value) => onChange({ defaultValue: value || undefined })}\n />\n ) : null}\n </div>\n )\n}\n\nfunction DictionaryFieldInput({ value, setValue, disabled, def }: Props) {\n const t = useT()\n const dictionaryId = def?.dictionaryId\n if (!dictionaryId) {\n return (\n <div className=\"rounded border border-dashed p-3 text-sm text-muted-foreground\">\n {t('dictionaries.config.entries.error.load', 'Failed to load dictionary entries.')}\n </div>\n )\n }\n const normalizedValue = typeof value === 'string' ? value : Array.isArray(value) ? String(value[0] ?? '') : undefined\n return (\n <DictionarySelectControl\n dictionaryId={dictionaryId}\n value={normalizedValue ?? ''}\n onChange={(next) => setValue(next ?? undefined)}\n allowInlineCreate={def?.dictionaryInlineCreate !== false}\n disabled={disabled}\n />\n )\n}\n\nFieldRegistry.register('dictionary', {\n input: DictionaryFieldInput,\n defEditor: (props) => <DictionaryFieldDefEditor {...props} />,\n})\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { CrudCustomFieldRenderProps } from '@open-mercato/ui/backend/CrudForm'\nimport { FieldRegistry } from '@open-mercato/ui/backend/fields/registry'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Checkbox } from '@open-mercato/ui/primitives/checkbox'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { DictionarySelectControl } from '../components/DictionarySelectControl'\nimport { useDictionaryEntries } from '../components/hooks/useDictionaryEntries'\n\ntype DictionaryFieldDefinition = {\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string\n}\n\ntype Props = CrudCustomFieldRenderProps & { def?: DictionaryFieldDefinition }\n\ntype DictionarySummary = {\n id: string\n name: string\n key: string\n isActive: boolean\n}\n\nfunction DictionaryDefaultSelector({\n dictionaryId,\n defaultValue,\n onChange,\n}: {\n dictionaryId: string\n defaultValue: string\n onChange: (value: string) => void\n}) {\n const t = useT()\n const { data, isLoading } = useDictionaryEntries(dictionaryId)\n const entries = data?.entries ?? []\n const isStale = defaultValue && entries.length > 0 && !entries.some((e) => e.value === defaultValue)\n\n return (\n <div className=\"space-y-1\">\n <label className=\"text-xs font-medium text-muted-foreground\">\n {t('dictionaries.customFields.defaultValue', 'Default value')}\n </label>\n <Select\n value={defaultValue ?? ''}\n onValueChange={(next) => onChange(next ?? '')}\n >\n <SelectTrigger size=\"sm\">\n <SelectValue placeholder={t('dictionaries.customFields.defaultValueNone', 'No default')} />\n </SelectTrigger>\n <SelectContent>\n {entries.map((entry) => (\n <SelectItem key={entry.value} value={entry.value}>\n {entry.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {isLoading ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('dictionaries.customFields.loading', 'Loading dictionaries\u2026')}\n </p>\n ) : null}\n {isStale ? (\n <p className=\"text-xs text-status-warning-text\">\n {t('dictionaries.customFields.defaultValueStale', 'Default entry not found \u2014 it may have been deleted or renamed.')}\n </p>\n ) : null}\n </div>\n )\n}\n\nfunction DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: DictionaryFieldDefinition } | undefined; onChange: (patch: Partial<DictionaryFieldDefinition>) => void }) {\n const t = useT()\n const [items, setItems] = React.useState<DictionarySummary[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const inlineCreateId = React.useId()\n const selectedId = typeof def?.configJson?.dictionaryId === 'string' ? def?.configJson?.dictionaryId : ''\n const inlineCreate = def?.configJson?.dictionaryInlineCreate !== false\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setLoading(true)\n setError(null)\n try {\n const call = await apiCall<{ items?: unknown[]; error?: string }>(\n '/api/dictionaries?includeInactive=true',\n )\n if (!call.ok) {\n const message =\n typeof call.result?.error === 'string' ? call.result.error : 'Failed to load dictionaries'\n throw new Error(message)\n }\n const entries = Array.isArray(call.result?.items) ? call.result!.items : []\n if (!cancelled) {\n setItems(\n entries.map((entry: any) => ({\n id: String(entry.id),\n name: typeof entry.name === 'string' && entry.name.trim().length ? entry.name : String(entry.key ?? entry.id),\n key: typeof entry.key === 'string' ? entry.key : '',\n isActive: entry.isActive !== false,\n })),\n )\n }\n } catch (err) {\n if (!cancelled) {\n console.error('Failed to load dictionaries list', err)\n setError(t('dictionaries.customFields.errorLoad', 'Failed to load dictionaries.'))\n }\n } finally {\n if (!cancelled) {\n setLoading(false)\n }\n }\n }\n load().catch(() => {})\n return () => {\n cancelled = true\n }\n }, [t])\n\n const manageHref = '/backend/config/dictionaries'\n\n return (\n <div className=\"mt-3 space-y-3 rounded border border-dashed border-muted-foreground/40 bg-muted/30 p-3\">\n <div className=\"space-y-1\">\n <label className=\"text-xs font-medium text-muted-foreground\">\n {t('dictionaries.customFields.dictionaryLabel', 'Dictionary source')}\n </label>\n <Select\n value={selectedId ?? ''}\n onValueChange={(next) => onChange({ dictionaryId: next || undefined })}\n >\n <SelectTrigger size=\"sm\">\n <SelectValue placeholder={t('dictionaries.customFields.dictionaryPlaceholder', 'Select a dictionary')} />\n </SelectTrigger>\n <SelectContent>\n {items.map((item) => (\n <SelectItem key={item.id} value={item.id}>\n {item.name}\n {item.isActive ? '' : ` (${t('dictionaries.customFields.inactive', 'inactive')})`}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n {loading ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('dictionaries.customFields.loading', 'Loading dictionaries\u2026')}\n </p>\n ) : null}\n {error ? <p className=\"text-xs text-status-error-text\">{error}</p> : null}\n {!loading && !error && items.length === 0 ? (\n <p className=\"text-xs text-muted-foreground\">\n {t('dictionaries.customFields.empty', 'No dictionaries available yet. Create one first.')}\n </p>\n ) : null}\n </div>\n {selectedId ? (\n <div className=\"flex flex-wrap items-center justify-between gap-2 rounded bg-background/80 px-2 py-1 text-xs text-muted-foreground\">\n <span>{t('dictionaries.customFields.selectedHint', 'Entries from this dictionary populate the field.')}</span>\n <a href={manageHref} className=\"font-medium text-primary hover:underline\" target=\"_blank\" rel=\"noreferrer\">\n {t('dictionaries.customFields.manageLink', 'Manage dictionaries')}\n </a>\n </div>\n ) : null}\n <div className=\"inline-flex items-center gap-2 text-xs\">\n <Checkbox\n id={inlineCreateId}\n checked={inlineCreate}\n onCheckedChange={(checked) => onChange({ dictionaryInlineCreate: checked === true })}\n disabled={!selectedId}\n />\n <label\n htmlFor={inlineCreateId}\n className={selectedId ? 'cursor-pointer select-none' : 'cursor-not-allowed opacity-60'}\n >\n {t('dictionaries.customFields.allowInlineCreate', 'Allow inline creation inside forms')}\n </label>\n </div>\n {selectedId ? (\n <DictionaryDefaultSelector\n dictionaryId={selectedId}\n defaultValue={typeof def?.configJson?.defaultValue === 'string' ? def.configJson.defaultValue : ''}\n onChange={(value) => onChange({ defaultValue: value || undefined })}\n />\n ) : null}\n </div>\n )\n}\n\nfunction DictionaryFieldInput({ value, setValue, disabled, def }: Props) {\n const t = useT()\n const dictionaryId = def?.dictionaryId\n if (!dictionaryId) {\n return (\n <div className=\"rounded border border-dashed p-3 text-sm text-muted-foreground\">\n {t('dictionaries.config.entries.error.load', 'Failed to load dictionary entries.')}\n </div>\n )\n }\n const normalizedValue = typeof value === 'string' ? value : Array.isArray(value) ? String(value[0] ?? '') : undefined\n return (\n <DictionarySelectControl\n dictionaryId={dictionaryId}\n value={normalizedValue ?? ''}\n onChange={(next) => setValue(next ?? undefined)}\n allowInlineCreate={def?.dictionaryInlineCreate !== false}\n disabled={disabled}\n />\n )\n}\n\nFieldRegistry.register('dictionary', {\n input: DictionaryFieldInput,\n defEditor: (props) => <DictionaryFieldDefEditor {...props} />,\n})\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,17 +1,49 @@
1
+ import { runWithCacheTenant } from "@open-mercato/cache";
2
+ import {
3
+ buildCollectionTags,
4
+ isCrudCacheEnabled,
5
+ resolveCrudCache
6
+ } from "@open-mercato/shared/lib/crud/cache";
1
7
  import { Notification } from "../../data/entities.js";
2
8
  import { unreadCountResponseSchema } from "../openapi.js";
3
9
  import { resolveNotificationContext } from "../../lib/routeHelpers.js";
4
10
  const metadata = {
5
11
  GET: { requireAuth: true }
6
12
  };
13
+ const UNREAD_COUNT_RESOURCE = "notifications.notification";
14
+ const UNREAD_COUNT_TTL_MS = 1e4;
15
+ function buildUnreadCountCacheKey(userId) {
16
+ return `notifications:unread-count:u=${userId}`;
17
+ }
7
18
  async function GET(req) {
8
19
  const { scope, ctx } = await resolveNotificationContext(req);
9
20
  const em = ctx.container.resolve("em");
21
+ const userId = scope.userId;
22
+ const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null;
23
+ const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null;
24
+ if (cache && cacheKey) {
25
+ const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey));
26
+ if (typeof cached === "number") {
27
+ return Response.json({ unreadCount: cached });
28
+ }
29
+ }
10
30
  const count = await em.count(Notification, {
11
- recipientUserId: scope.userId,
31
+ recipientUserId: userId,
12
32
  tenantId: scope.tenantId,
13
33
  status: "unread"
14
34
  });
35
+ if (cache && cacheKey) {
36
+ try {
37
+ await runWithCacheTenant(
38
+ scope.tenantId,
39
+ () => cache.set(cacheKey, count, {
40
+ ttl: UNREAD_COUNT_TTL_MS,
41
+ tags: buildCollectionTags(UNREAD_COUNT_RESOURCE, scope.tenantId, [null])
42
+ })
43
+ );
44
+ } catch {
45
+ }
46
+ }
15
47
  return Response.json({ unreadCount: count });
16
48
  }
17
49
  const openApi = {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/notifications/api/unread-count/route.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { Notification } from '../../data/entities'\nimport { unreadCountResponseSchema } from '../openapi'\nimport { resolveNotificationContext } from '../../lib/routeHelpers'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nexport async function GET(req: Request) {\n const { scope, ctx } = await resolveNotificationContext(req)\n const em = ctx.container.resolve('em') as EntityManager\n\n const count = await em.count(Notification, {\n recipientUserId: scope.userId,\n tenantId: scope.tenantId,\n status: 'unread',\n })\n\n return Response.json({ unreadCount: count })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Get unread notification count',\n tags: ['Notifications'],\n responses: {\n 200: {\n description: 'Unread count',\n content: {\n 'application/json': {\n schema: unreadCountResponseSchema,\n },\n },\n },\n },\n },\n}\n"],
5
- "mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAC1C,SAAS,kCAAkC;AAEpC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,KAAK;AAC3B;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,OAAO,IAAI,IAAI,MAAM,2BAA2B,GAAG;AAC3D,QAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AAErC,QAAM,QAAQ,MAAM,GAAG,MAAM,cAAc;AAAA,IACzC,iBAAiB,MAAM;AAAA,IACvB,UAAU,MAAM;AAAA,IAChB,QAAQ;AAAA,EACV,CAAC;AAED,SAAO,SAAS,KAAK,EAAE,aAAa,MAAM,CAAC;AAC7C;AAEO,MAAM,UAAU;AAAA,EACrB,KAAK;AAAA,IACH,SAAS;AAAA,IACT,MAAM,CAAC,eAAe;AAAA,IACtB,WAAW;AAAA,MACT,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,oBAAoB;AAAA,YAClB,QAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport {\n buildCollectionTags,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { Notification } from '../../data/entities'\nimport { unreadCountResponseSchema } from '../openapi'\nimport { resolveNotificationContext } from '../../lib/routeHelpers'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst UNREAD_COUNT_RESOURCE = 'notifications.notification'\nconst UNREAD_COUNT_TTL_MS = 10_000\n\nfunction buildUnreadCountCacheKey(userId: string): string {\n return `notifications:unread-count:u=${userId}`\n}\n\nexport async function GET(req: Request) {\n const { scope, ctx } = await resolveNotificationContext(req)\n const em = ctx.container.resolve('em') as EntityManager\n\n const userId = scope.userId\n const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null\n const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null\n\n if (cache && cacheKey) {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (typeof cached === 'number') {\n return Response.json({ unreadCount: cached })\n }\n }\n\n const count = await em.count(Notification, {\n recipientUserId: userId,\n tenantId: scope.tenantId,\n status: 'unread',\n })\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, count, {\n ttl: UNREAD_COUNT_TTL_MS,\n tags: buildCollectionTags(UNREAD_COUNT_RESOURCE, scope.tenantId, [null]),\n }),\n )\n } catch {}\n }\n\n return Response.json({ unreadCount: count })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Get unread notification count',\n tags: ['Notifications'],\n responses: {\n 200: {\n description: 'Unread count',\n content: {\n 'application/json': {\n schema: unreadCountResponseSchema,\n },\n },\n },\n },\n },\n}\n"],
5
+ "mappings": "AACA,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAC7B,SAAS,iCAAiC;AAC1C,SAAS,kCAAkC;AAEpC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,KAAK;AAC3B;AAEA,MAAM,wBAAwB;AAC9B,MAAM,sBAAsB;AAE5B,SAAS,yBAAyB,QAAwB;AACxD,SAAO,gCAAgC,MAAM;AAC/C;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,OAAO,IAAI,IAAI,MAAM,2BAA2B,GAAG;AAC3D,QAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AAErC,QAAM,SAAS,MAAM;AACrB,QAAM,QAAQ,UAAU,mBAAmB,IAAI,iBAAiB,IAAI,SAAS,IAAI;AACjF,QAAM,WAAW,SAAS,SAAS,yBAAyB,MAAM,IAAI;AAEtE,MAAI,SAAS,UAAU;AACrB,UAAM,SAAS,MAAM,mBAAmB,MAAM,UAAU,MAAM,MAAM,IAAI,QAAQ,CAAC;AACjF,QAAI,OAAO,WAAW,UAAU;AAC9B,aAAO,SAAS,KAAK,EAAE,aAAa,OAAO,CAAC;AAAA,IAC9C;AAAA,EACF;AAEA,QAAM,QAAQ,MAAM,GAAG,MAAM,cAAc;AAAA,IACzC,iBAAiB;AAAA,IACjB,UAAU,MAAM;AAAA,IAChB,QAAQ;AAAA,EACV,CAAC;AAED,MAAI,SAAS,UAAU;AACrB,QAAI;AACF,YAAM;AAAA,QAAmB,MAAM;AAAA,QAAU,MACvC,MAAM,IAAI,UAAU,OAAO;AAAA,UACzB,KAAK;AAAA,UACL,MAAM,oBAAoB,uBAAuB,MAAM,UAAU,CAAC,IAAI,CAAC;AAAA,QACzE,CAAC;AAAA,MACH;AAAA,IACF,QAAQ;AAAA,IAAC;AAAA,EACX;AAEA,SAAO,SAAS,KAAK,EAAE,aAAa,MAAM,CAAC;AAC7C;AAEO,MAAM,UAAU;AAAA,EACrB,KAAK;AAAA,IACH,SAAS;AAAA,IACT,MAAM,CAAC,eAAe;AAAA,IACtB,WAAW;AAAA,MACT,KAAK;AAAA,QACH,aAAa;AAAA,QACb,SAAS;AAAA,UACP,oBAAoB;AAAA,YAClB,QAAQ;AAAA,UACV;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.5738.1.4182c2b2c7",
3
+ "version": "0.6.6-develop.5754.1.c908b2af3f",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -245,16 +245,16 @@
245
245
  "zod": "^4.4.3"
246
246
  },
247
247
  "peerDependencies": {
248
- "@open-mercato/ai-assistant": "0.6.6-develop.5738.1.4182c2b2c7",
249
- "@open-mercato/shared": "0.6.6-develop.5738.1.4182c2b2c7",
250
- "@open-mercato/ui": "0.6.6-develop.5738.1.4182c2b2c7",
248
+ "@open-mercato/ai-assistant": "0.6.6-develop.5754.1.c908b2af3f",
249
+ "@open-mercato/shared": "0.6.6-develop.5754.1.c908b2af3f",
250
+ "@open-mercato/ui": "0.6.6-develop.5754.1.c908b2af3f",
251
251
  "react": "^19.0.0",
252
252
  "react-dom": "^19.0.0"
253
253
  },
254
254
  "devDependencies": {
255
- "@open-mercato/ai-assistant": "0.6.6-develop.5738.1.4182c2b2c7",
256
- "@open-mercato/shared": "0.6.6-develop.5738.1.4182c2b2c7",
257
- "@open-mercato/ui": "0.6.6-develop.5738.1.4182c2b2c7",
255
+ "@open-mercato/ai-assistant": "0.6.6-develop.5754.1.c908b2af3f",
256
+ "@open-mercato/shared": "0.6.6-develop.5754.1.c908b2af3f",
257
+ "@open-mercato/ui": "0.6.6-develop.5754.1.c908b2af3f",
258
258
  "@testing-library/dom": "^10.4.1",
259
259
  "@testing-library/jest-dom": "^6.9.1",
260
260
  "@testing-library/react": "^16.3.1",
@@ -1,9 +1,15 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { runWithCacheTenant } from '@open-mercato/cache'
4
5
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
7
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
8
+ import {
9
+ buildCollectionTags,
10
+ isCrudCacheEnabled,
11
+ resolveCrudCache,
12
+ } from '@open-mercato/shared/lib/crud/cache'
7
13
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
14
  import { Currency } from '../../../data/entities'
9
15
 
@@ -11,6 +17,17 @@ export const metadata = {
11
17
  GET: { requireAuth: true, requireFeatures: ['currencies.view'] },
12
18
  }
13
19
 
20
+ const CURRENCY_OPTIONS_RESOURCE = 'currencies.currency'
21
+ const CURRENCY_OPTIONS_TTL_MS = 5 * 60_000
22
+
23
+ function buildOptionsCacheKey(params: {
24
+ orgId: string | null
25
+ includeInactive: boolean
26
+ limit: number
27
+ }): string {
28
+ return `currencies:options:org=${params.orgId ?? 'null'}:active=${params.includeInactive ? 'all' : 'active'}:limit=${params.limit}`
29
+ }
30
+
14
31
  const optionsQuerySchema = z.object({
15
32
  q: z.string().optional(),
16
33
  query: z.string().optional(),
@@ -47,15 +64,35 @@ export async function GET(req: Request) {
47
64
 
48
65
  const { q, query, search, includeInactive, limit } = parsed.data
49
66
  const searchTerm = (q ?? query ?? search ?? '').trim()
67
+ const tenantId = auth.tenantId
68
+ const orgId = auth.orgId ?? null
69
+ const includeInactiveFlag = includeInactive === 'true'
70
+
71
+ // Only the unfiltered bootstrap call (no ILIKE search term) is the hot path
72
+ // worth caching; search variants would multiply key cardinality with little
73
+ // hit-rate benefit, so they always read straight through.
74
+ const cache =
75
+ isCrudCacheEnabled() && !searchTerm ? resolveCrudCache(container) : null
76
+ const cacheKey = cache
77
+ ? buildOptionsCacheKey({ orgId, includeInactive: includeInactiveFlag, limit })
78
+ : null
79
+
80
+ if (cache && cacheKey) {
81
+ const cached = await runWithCacheTenant(tenantId, () => cache.get(cacheKey))
82
+ if (cached) {
83
+ return NextResponse.json(cached)
84
+ }
85
+ }
86
+
50
87
  const filter: any = {
51
- tenantId: auth.tenantId,
88
+ tenantId,
52
89
  deletedAt: null,
53
90
  }
54
- if (auth.orgId) {
55
- filter.organizationId = auth.orgId
91
+ if (orgId) {
92
+ filter.organizationId = orgId
56
93
  }
57
94
 
58
- if (includeInactive !== 'true') {
95
+ if (!includeInactiveFlag) {
59
96
  filter.isActive = true
60
97
  }
61
98
 
@@ -77,7 +114,20 @@ export async function GET(req: Request) {
77
114
  label: `${currency.code} - ${currency.name}`,
78
115
  }))
79
116
 
80
- return NextResponse.json({ items })
117
+ const payload = { items }
118
+
119
+ if (cache && cacheKey) {
120
+ try {
121
+ await runWithCacheTenant(tenantId, () =>
122
+ cache.set(cacheKey, payload, {
123
+ ttl: CURRENCY_OPTIONS_TTL_MS,
124
+ tags: buildCollectionTags(CURRENCY_OPTIONS_RESOURCE, tenantId, [orgId]),
125
+ }),
126
+ )
127
+ } catch {}
128
+ }
129
+
130
+ return NextResponse.json(payload)
81
131
  }
82
132
 
83
133
  const optionsResponseSchema = z.object({
@@ -50,50 +50,47 @@ export async function resolveAllowedWidgetIds(
50
50
  }
51
51
  }
52
52
 
53
- if (allowedByUser && allowedByUser.size === 0) {
54
- return Array.from(allowedByUser)
55
- }
56
-
57
- // Aggregate role-level settings
58
- const userRoles = await findWithDecryption(
59
- em,
60
- UserRole,
61
- { user: ctx.userId as any, deletedAt: null },
62
- { populate: ['role'] },
63
- { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
64
- )
65
- const roleRecords = await em.find(DashboardRoleWidgets, {
66
- roleId: { $in: userRoles.map((ur) => String(ur.role?.id || ur.role)) },
67
- deletedAt: null,
68
- })
53
+ let baseSet: Set<string>
54
+ if (allowedByUser) {
55
+ // A user override fully determines visibility, so role-level lookups would
56
+ // be discarded — skip them entirely. An empty override is handled by the
57
+ // shared `baseSet.size === 0` guard below.
58
+ baseSet = allowedByUser
59
+ } else {
60
+ // No user override: aggregate role-level settings.
61
+ const userRoles = await findWithDecryption(
62
+ em,
63
+ UserRole,
64
+ { user: ctx.userId as any, deletedAt: null },
65
+ { populate: ['role'] },
66
+ { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
67
+ )
68
+ const roleRecords = await em.find(DashboardRoleWidgets, {
69
+ roleId: { $in: userRoles.map((ur) => String(ur.role?.id || ur.role)) },
70
+ deletedAt: null,
71
+ })
69
72
 
70
- const byRole = new Map<string, DashboardRoleWidgets>()
71
- for (const record of roleRecords) {
72
- const role = String(record.roleId)
73
- if (record.tenantId && ctx.tenantId && record.tenantId !== ctx.tenantId) continue
74
- if (record.tenantId && !ctx.tenantId) continue
75
- if (record.organizationId && ctx.organizationId && record.organizationId !== ctx.organizationId) continue
76
- if (record.organizationId && !ctx.organizationId) continue
77
- const current = byRole.get(role)
78
- if (!current || specificity(record) > specificity(current)) {
79
- byRole.set(role, record)
73
+ const byRole = new Map<string, DashboardRoleWidgets>()
74
+ for (const record of roleRecords) {
75
+ const role = String(record.roleId)
76
+ if (record.tenantId && ctx.tenantId && record.tenantId !== ctx.tenantId) continue
77
+ if (record.tenantId && !ctx.tenantId) continue
78
+ if (record.organizationId && ctx.organizationId && record.organizationId !== ctx.organizationId) continue
79
+ if (record.organizationId && !ctx.organizationId) continue
80
+ const current = byRole.get(role)
81
+ if (!current || specificity(record) > specificity(current)) {
82
+ byRole.set(role, record)
83
+ }
80
84
  }
81
- }
82
85
 
83
- const allowedByRole = new Set<string>()
84
- for (const record of byRole.values()) {
85
- for (const id of record.widgetIdsJson) {
86
- if (allWidgetIds.includes(id)) allowedByRole.add(id)
86
+ const allowedByRole = new Set<string>()
87
+ for (const record of byRole.values()) {
88
+ for (const id of record.widgetIdsJson) {
89
+ if (allWidgetIds.includes(id)) allowedByRole.add(id)
90
+ }
87
91
  }
88
- }
89
92
 
90
- let baseSet: Set<string>
91
- if (allowedByUser) {
92
- baseSet = allowedByUser
93
- } else if (allowedByRole.size > 0) {
94
- baseSet = allowedByRole
95
- } else {
96
- baseSet = new Set(allWidgetIds)
93
+ baseSet = allowedByRole.size > 0 ? allowedByRole : new Set(allWidgetIds)
97
94
  }
98
95
 
99
96
  if (baseSet.size === 0) return []
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
4
  import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
4
5
  import { resolveDictionariesRouteContext } from '@open-mercato/core/modules/dictionaries/api/context'
5
6
  import { createDictionaryEntrySchema } from '@open-mercato/core/modules/dictionaries/data/validators'
@@ -16,6 +17,12 @@ import {
16
17
  dictionariesTag,
17
18
  } from '../../openapi'
18
19
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
20
+ import {
21
+ buildCollectionTags,
22
+ buildRecordTag,
23
+ isCrudCacheEnabled,
24
+ resolveCrudCache,
25
+ } from '@open-mercato/shared/lib/crud/cache'
19
26
  import {
20
27
  resolveDictionaryEntrySortMode,
21
28
  sortDictionaryEntries,
@@ -23,6 +30,18 @@ import {
23
30
 
24
31
  const paramsSchema = z.object({ dictionaryId: z.string().uuid() })
25
32
 
33
+ const DICTIONARY_ENTRY_RESOURCE = 'dictionaries.entry'
34
+ const DICTIONARY_DEFINITION_RESOURCE = 'dictionaries.dictionary'
35
+ const DICTIONARY_ENTRIES_TTL_MS = 5 * 60_000
36
+
37
+ function buildEntriesCacheKey(params: {
38
+ dictionaryId: string
39
+ organizationId: string | null
40
+ sortMode: string
41
+ }): string {
42
+ return `dictionaries:entries:${params.dictionaryId}:org=${params.organizationId ?? 'null'}:sort=${params.sortMode}`
43
+ }
44
+
26
45
  async function loadDictionary(
27
46
  context: Awaited<ReturnType<typeof resolveDictionariesRouteContext>>,
28
47
  id: string,
@@ -68,6 +87,26 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
68
87
  }
69
88
  const { dictionaryId } = paramsSchema.parse({ dictionaryId: ctx.params?.dictionaryId })
70
89
  const dictionary = await loadDictionary(context, dictionaryId, { allowInherited: true })
90
+ const dictionaryTenantId = dictionary.tenantId
91
+ const dictionaryOrgId = dictionary.organizationId ?? null
92
+ const sortMode = resolveDictionaryEntrySortMode(dictionary.entrySortMode)
93
+
94
+ // Dictionary CF selects hit this on every CrudForm open, so cache the
95
+ // decrypted+sorted options payload. The entry writes flow through the
96
+ // command bus (resourceKind dictionaries.entry), which already flushes the
97
+ // matching collection tag post-commit — no new invalidation wiring needed.
98
+ const cache = isCrudCacheEnabled() ? resolveCrudCache(context.container) : null
99
+ const cacheKey = cache
100
+ ? buildEntriesCacheKey({ dictionaryId, organizationId: dictionaryOrgId, sortMode })
101
+ : null
102
+
103
+ if (cache && cacheKey) {
104
+ const cached = await runWithCacheTenant(dictionaryTenantId, () => cache.get(cacheKey))
105
+ if (cached) {
106
+ return NextResponse.json(cached)
107
+ }
108
+ }
109
+
71
110
  const entries = await findWithDecryption(
72
111
  context.em,
73
112
  DictionaryEntry,
@@ -79,9 +118,9 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
79
118
  {},
80
119
  { tenantId: dictionary.tenantId, organizationId: dictionary.organizationId },
81
120
  )
82
- const sortedEntries = sortDictionaryEntries(entries, resolveDictionaryEntrySortMode(dictionary.entrySortMode))
121
+ const sortedEntries = sortDictionaryEntries(entries, sortMode)
83
122
 
84
- return NextResponse.json({
123
+ const payload = {
85
124
  items: sortedEntries.map((entry) => ({
86
125
  id: entry.id,
87
126
  value: entry.value,
@@ -93,7 +132,23 @@ export async function GET(req: Request, ctx: { params?: { dictionaryId?: string
93
132
  createdAt: entry.createdAt,
94
133
  updatedAt: entry.updatedAt,
95
134
  })),
96
- })
135
+ }
136
+
137
+ if (cache && cacheKey) {
138
+ try {
139
+ await runWithCacheTenant(dictionaryTenantId, () =>
140
+ cache.set(cacheKey, payload, {
141
+ ttl: DICTIONARY_ENTRIES_TTL_MS,
142
+ tags: [
143
+ ...buildCollectionTags(DICTIONARY_ENTRY_RESOURCE, dictionaryTenantId, [dictionaryOrgId]),
144
+ buildRecordTag(DICTIONARY_DEFINITION_RESOURCE, dictionaryTenantId, dictionaryId),
145
+ ],
146
+ }),
147
+ )
148
+ } catch {}
149
+ }
150
+
151
+ return NextResponse.json(payload)
97
152
  } catch (err) {
98
153
  if (isCrudHttpError(err)) {
99
154
  return NextResponse.json(err.body, { status: err.status })
@@ -5,6 +5,7 @@ import type { CrudCustomFieldRenderProps } from '@open-mercato/ui/backend/CrudFo
5
5
  import { FieldRegistry } from '@open-mercato/ui/backend/fields/registry'
6
6
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
8
9
  import {
9
10
  Select,
10
11
  SelectContent,
@@ -70,7 +71,7 @@ function DictionaryDefaultSelector({
70
71
  </p>
71
72
  ) : null}
72
73
  {isStale ? (
73
- <p className="text-xs text-amber-600">
74
+ <p className="text-xs text-status-warning-text">
74
75
  {t('dictionaries.customFields.defaultValueStale', 'Default entry not found — it may have been deleted or renamed.')}
75
76
  </p>
76
77
  ) : null}
@@ -83,6 +84,7 @@ function DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: Dicti
83
84
  const [items, setItems] = React.useState<DictionarySummary[]>([])
84
85
  const [loading, setLoading] = React.useState(false)
85
86
  const [error, setError] = React.useState<string | null>(null)
87
+ const inlineCreateId = React.useId()
86
88
  const selectedId = typeof def?.configJson?.dictionaryId === 'string' ? def?.configJson?.dictionaryId : ''
87
89
  const inlineCreate = def?.configJson?.dictionaryInlineCreate !== false
88
90
 
@@ -157,7 +159,7 @@ function DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: Dicti
157
159
  {t('dictionaries.customFields.loading', 'Loading dictionaries…')}
158
160
  </p>
159
161
  ) : null}
160
- {error ? <p className="text-xs text-red-600">{error}</p> : null}
162
+ {error ? <p className="text-xs text-status-error-text">{error}</p> : null}
161
163
  {!loading && !error && items.length === 0 ? (
162
164
  <p className="text-xs text-muted-foreground">
163
165
  {t('dictionaries.customFields.empty', 'No dictionaries available yet. Create one first.')}
@@ -172,15 +174,20 @@ function DictionaryFieldDefEditor({ def, onChange }: { def: { configJson?: Dicti
172
174
  </a>
173
175
  </div>
174
176
  ) : null}
175
- <label className="inline-flex items-center gap-2 text-xs">
176
- <input
177
- type="checkbox"
177
+ <div className="inline-flex items-center gap-2 text-xs">
178
+ <Checkbox
179
+ id={inlineCreateId}
178
180
  checked={inlineCreate}
179
- onChange={(event) => onChange({ dictionaryInlineCreate: event.target.checked })}
181
+ onCheckedChange={(checked) => onChange({ dictionaryInlineCreate: checked === true })}
180
182
  disabled={!selectedId}
181
183
  />
182
- {t('dictionaries.customFields.allowInlineCreate', 'Allow inline creation inside forms')}
183
- </label>
184
+ <label
185
+ htmlFor={inlineCreateId}
186
+ className={selectedId ? 'cursor-pointer select-none' : 'cursor-not-allowed opacity-60'}
187
+ >
188
+ {t('dictionaries.customFields.allowInlineCreate', 'Allow inline creation inside forms')}
189
+ </label>
190
+ </div>
184
191
  {selectedId ? (
185
192
  <DictionaryDefaultSelector
186
193
  dictionaryId={selectedId}
@@ -1,4 +1,10 @@
1
1
  import type { EntityManager } from '@mikro-orm/core'
2
+ import { runWithCacheTenant } from '@open-mercato/cache'
3
+ import {
4
+ buildCollectionTags,
5
+ isCrudCacheEnabled,
6
+ resolveCrudCache,
7
+ } from '@open-mercato/shared/lib/crud/cache'
2
8
  import { Notification } from '../../data/entities'
3
9
  import { unreadCountResponseSchema } from '../openapi'
4
10
  import { resolveNotificationContext } from '../../lib/routeHelpers'
@@ -7,16 +13,45 @@ export const metadata = {
7
13
  GET: { requireAuth: true },
8
14
  }
9
15
 
16
+ const UNREAD_COUNT_RESOURCE = 'notifications.notification'
17
+ const UNREAD_COUNT_TTL_MS = 10_000
18
+
19
+ function buildUnreadCountCacheKey(userId: string): string {
20
+ return `notifications:unread-count:u=${userId}`
21
+ }
22
+
10
23
  export async function GET(req: Request) {
11
24
  const { scope, ctx } = await resolveNotificationContext(req)
12
25
  const em = ctx.container.resolve('em') as EntityManager
13
26
 
27
+ const userId = scope.userId
28
+ const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null
29
+ const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null
30
+
31
+ if (cache && cacheKey) {
32
+ const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))
33
+ if (typeof cached === 'number') {
34
+ return Response.json({ unreadCount: cached })
35
+ }
36
+ }
37
+
14
38
  const count = await em.count(Notification, {
15
- recipientUserId: scope.userId,
39
+ recipientUserId: userId,
16
40
  tenantId: scope.tenantId,
17
41
  status: 'unread',
18
42
  })
19
43
 
44
+ if (cache && cacheKey) {
45
+ try {
46
+ await runWithCacheTenant(scope.tenantId, () =>
47
+ cache.set(cacheKey, count, {
48
+ ttl: UNREAD_COUNT_TTL_MS,
49
+ tags: buildCollectionTags(UNREAD_COUNT_RESOURCE, scope.tenantId, [null]),
50
+ }),
51
+ )
52
+ } catch {}
53
+ }
54
+
20
55
  return Response.json({ unreadCount: count })
21
56
  }
22
57