@open-mercato/core 0.6.6-develop.6133.1.f01540250e → 0.6.6-develop.6140.1.d3fbb77495

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customer_accounts/api/admin/users-invite.js +9 -0
  3. package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
  4. package/dist/modules/customer_accounts/api/portal/users-invite.js +9 -0
  5. package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
  6. package/dist/modules/customer_accounts/events.js +1 -0
  7. package/dist/modules/customer_accounts/events.js.map +2 -2
  8. package/dist/modules/dashboards/api/layout/[itemId]/route.js +34 -1
  9. package/dist/modules/dashboards/api/layout/[itemId]/route.js.map +2 -2
  10. package/dist/modules/dashboards/api/layout/route.js +34 -1
  11. package/dist/modules/dashboards/api/layout/route.js.map +2 -2
  12. package/dist/modules/dashboards/api/roles/widgets/route.js +47 -1
  13. package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
  14. package/dist/modules/dashboards/api/users/widgets/route.js +47 -1
  15. package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
  16. package/dist/modules/directory/api/tenants/route.js +19 -23
  17. package/dist/modules/directory/api/tenants/route.js.map +2 -2
  18. package/dist/modules/directory/api/tenants/tenant-cf-filter.js +72 -0
  19. package/dist/modules/directory/api/tenants/tenant-cf-filter.js.map +7 -0
  20. package/dist/modules/feature_toggles/components/FeatureToggleDetailsCard.js +8 -6
  21. package/dist/modules/feature_toggles/components/FeatureToggleDetailsCard.js.map +2 -2
  22. package/dist/modules/integrations/api/[id]/health/route.js +33 -0
  23. package/dist/modules/integrations/api/[id]/health/route.js.map +2 -2
  24. package/dist/modules/notifications/api/unread-count/route.js +6 -3
  25. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  26. package/package.json +7 -7
  27. package/src/modules/customer_accounts/AGENTS.md +1 -0
  28. package/src/modules/customer_accounts/api/admin/users-invite.ts +10 -0
  29. package/src/modules/customer_accounts/api/portal/users-invite.ts +10 -0
  30. package/src/modules/customer_accounts/events.ts +1 -0
  31. package/src/modules/dashboards/api/layout/[itemId]/route.ts +36 -1
  32. package/src/modules/dashboards/api/layout/route.ts +36 -1
  33. package/src/modules/dashboards/api/roles/widgets/route.ts +49 -1
  34. package/src/modules/dashboards/api/users/widgets/route.ts +49 -1
  35. package/src/modules/directory/api/tenants/route.ts +25 -26
  36. package/src/modules/directory/api/tenants/tenant-cf-filter.ts +97 -0
  37. package/src/modules/feature_toggles/components/FeatureToggleDetailsCard.tsx +10 -8
  38. package/src/modules/integrations/api/[id]/health/route.ts +35 -0
  39. package/src/modules/notifications/api/unread-count/route.ts +6 -4
@@ -3,6 +3,11 @@ import { z } from "zod";
3
3
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
4
4
  import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
5
5
  import { getIntegration } from "@open-mercato/shared/modules/integrations/types";
6
+ import {
7
+ resolveUserFeatures,
8
+ runIntegrationMutationGuardAfterSuccess,
9
+ runIntegrationMutationGuards
10
+ } from "../../guards.js";
6
11
  const idParamsSchema = z.object({ id: z.string().min(1) });
7
12
  const metadata = {
8
13
  POST: { requireAuth: true, requireFeatures: ["integrations.manage"] }
@@ -26,11 +31,39 @@ async function POST(req, ctx) {
26
31
  return NextResponse.json({ error: "Integration not found" }, { status: 404 });
27
32
  }
28
33
  const container = await createRequestContainer();
34
+ const guardResult = await runIntegrationMutationGuards(
35
+ container,
36
+ {
37
+ tenantId: auth.tenantId,
38
+ organizationId: auth.orgId,
39
+ userId: auth.sub ?? "",
40
+ resourceKind: "integrations.integration",
41
+ resourceId: integration.id,
42
+ operation: "update",
43
+ requestMethod: req.method,
44
+ requestHeaders: req.headers,
45
+ mutationPayload: { integrationId: integration.id }
46
+ },
47
+ resolveUserFeatures(auth)
48
+ );
49
+ if (!guardResult.ok) {
50
+ return NextResponse.json(guardResult.errorBody ?? { error: "Operation blocked by guard" }, { status: guardResult.errorStatus ?? 422 });
51
+ }
29
52
  const healthService = container.resolve("integrationHealthService");
30
53
  const result = await healthService.runHealthCheck(
31
54
  integration.id,
32
55
  { organizationId: auth.orgId, tenantId: auth.tenantId }
33
56
  );
57
+ await runIntegrationMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
58
+ tenantId: auth.tenantId,
59
+ organizationId: auth.orgId,
60
+ userId: auth.sub ?? "",
61
+ resourceKind: "integrations.integration",
62
+ resourceId: integration.id,
63
+ operation: "update",
64
+ requestMethod: req.method,
65
+ requestHeaders: req.headers
66
+ });
34
67
  return NextResponse.json({
35
68
  status: result.status,
36
69
  message: result.message ?? null,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/integrations/api/%5Bid%5D/health/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getIntegration } from '@open-mercato/shared/modules/integrations/types'\nimport type { IntegrationHealthService } from '../../../lib/health-service'\n\nconst idParamsSchema = z.object({ id: z.string().min(1) })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['integrations.manage'] },\n}\n\nexport const openApi = {\n tags: ['Integrations'],\n summary: 'Run health check for an integration',\n}\n\nexport async function POST(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const rawParams = (ctx.params && typeof (ctx.params as Promise<unknown>).then === 'function')\n ? await (ctx.params as Promise<{ id?: string }>)\n : (ctx.params as { id?: string } | undefined)\n\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const healthService = container.resolve('integrationHealthService') as IntegrationHealthService\n\n const result = await healthService.runHealthCheck(\n integration.id,\n { organizationId: auth.orgId as string, tenantId: auth.tenantId },\n )\n\n return NextResponse.json({\n status: result.status,\n message: result.message ?? null,\n details: result.details ?? null,\n latencyMs: result.latencyMs,\n checkedAt: result.checkedAt,\n })\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAG/B,MAAM,iBAAiB,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC;AAElD,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,qBAAqB,EAAE;AACtE;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,cAAc;AAAA,EACrB,SAAS;AACX;AAEA,eAAsB,KAAK,KAAc,KAA8D;AACrG,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,YAAa,IAAI,UAAU,OAAQ,IAAI,OAA4B,SAAS,aAC9E,MAAO,IAAI,SACV,IAAI;AAET,QAAM,eAAe,eAAe,UAAU,SAAS;AACvD,MAAI,CAAC,aAAa,SAAS;AACzB,WAAO,aAAa,KAAK,EAAE,OAAO,yBAAyB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/E;AAEA,QAAM,cAAc,eAAe,aAAa,KAAK,EAAE;AACvD,MAAI,CAAC,aAAa;AAChB,WAAO,aAAa,KAAK,EAAE,OAAO,wBAAwB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9E;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,gBAAgB,UAAU,QAAQ,0BAA0B;AAElE,QAAM,SAAS,MAAM,cAAc;AAAA,IACjC,YAAY;AAAA,IACZ,EAAE,gBAAgB,KAAK,OAAiB,UAAU,KAAK,SAAS;AAAA,EAClE;AAEA,SAAO,aAAa,KAAK;AAAA,IACvB,QAAQ,OAAO;AAAA,IACf,SAAS,OAAO,WAAW;AAAA,IAC3B,SAAS,OAAO,WAAW;AAAA,IAC3B,WAAW,OAAO;AAAA,IAClB,WAAW,OAAO;AAAA,EACpB,CAAC;AACH;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getIntegration } from '@open-mercato/shared/modules/integrations/types'\nimport type { IntegrationHealthService } from '../../../lib/health-service'\nimport {\n resolveUserFeatures,\n runIntegrationMutationGuardAfterSuccess,\n runIntegrationMutationGuards,\n} from '../../guards'\n\nconst idParamsSchema = z.object({ id: z.string().min(1) })\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['integrations.manage'] },\n}\n\nexport const openApi = {\n tags: ['Integrations'],\n summary: 'Run health check for an integration',\n}\n\nexport async function POST(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const rawParams = (ctx.params && typeof (ctx.params as Promise<unknown>).then === 'function')\n ? await (ctx.params as Promise<{ id?: string }>)\n : (ctx.params as { id?: string } | undefined)\n\n const parsedParams = idParamsSchema.safeParse(rawParams)\n if (!parsedParams.success) {\n return NextResponse.json({ error: 'Invalid integration id' }, { status: 400 })\n }\n\n const integration = getIntegration(parsedParams.data.id)\n if (!integration) {\n return NextResponse.json({ error: 'Integration not found' }, { status: 404 })\n }\n\n const container = await createRequestContainer()\n const guardResult = await runIntegrationMutationGuards(\n container,\n {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: { integrationId: integration.id },\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })\n }\n\n const healthService = container.resolve('integrationHealthService') as IntegrationHealthService\n\n const result = await healthService.runHealthCheck(\n integration.id,\n { organizationId: auth.orgId as string, tenantId: auth.tenantId },\n )\n\n await runIntegrationMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub ?? '',\n resourceKind: 'integrations.integration',\n resourceId: integration.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n\n return NextResponse.json({\n status: result.status,\n message: result.message ?? null,\n details: result.details ?? null,\n latencyMs: result.latencyMs,\n checkedAt: result.checkedAt,\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,sBAAsB;AAE/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC;AAElD,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,qBAAqB,EAAE;AACtE;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,cAAc;AAAA,EACrB,SAAS;AACX;AAEA,eAAsB,KAAK,KAAc,KAA8D;AACrG,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,QAAM,YAAa,IAAI,UAAU,OAAQ,IAAI,OAA4B,SAAS,aAC9E,MAAO,IAAI,SACV,IAAI;AAET,QAAM,eAAe,eAAe,UAAU,SAAS;AACvD,MAAI,CAAC,aAAa,SAAS;AACzB,WAAO,aAAa,KAAK,EAAE,OAAO,yBAAyB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/E;AAEA,QAAM,cAAc,eAAe,aAAa,KAAK,EAAE;AACvD,MAAI,CAAC,aAAa;AAChB,WAAO,aAAa,KAAK,EAAE,OAAO,wBAAwB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC9E;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,cAAc,MAAM;AAAA,IACxB;AAAA,IACA;AAAA,MACE,UAAU,KAAK;AAAA,MACf,gBAAgB,KAAK;AAAA,MACrB,QAAQ,KAAK,OAAO;AAAA,MACpB,cAAc;AAAA,MACd,YAAY,YAAY;AAAA,MACxB,WAAW;AAAA,MACX,eAAe,IAAI;AAAA,MACnB,gBAAgB,IAAI;AAAA,MACpB,iBAAiB,EAAE,eAAe,YAAY,GAAG;AAAA,IACnD;AAAA,IACA,oBAAoB,IAAI;AAAA,EAC1B;AACA,MAAI,CAAC,YAAY,IAAI;AACnB,WAAO,aAAa,KAAK,YAAY,aAAa,EAAE,OAAO,6BAA6B,GAAG,EAAE,QAAQ,YAAY,eAAe,IAAI,CAAC;AAAA,EACvI;AAEA,QAAM,gBAAgB,UAAU,QAAQ,0BAA0B;AAElE,QAAM,SAAS,MAAM,cAAc;AAAA,IACjC,YAAY;AAAA,IACZ,EAAE,gBAAgB,KAAK,OAAiB,UAAU,KAAK,SAAS;AAAA,EAClE;AAEA,QAAM,wCAAwC,YAAY,uBAAuB;AAAA,IAC/E,UAAU,KAAK;AAAA,IACf,gBAAgB,KAAK;AAAA,IACrB,QAAQ,KAAK,OAAO;AAAA,IACpB,cAAc;AAAA,IACd,YAAY,YAAY;AAAA,IACxB,WAAW;AAAA,IACX,eAAe,IAAI;AAAA,IACnB,gBAAgB,IAAI;AAAA,EACtB,CAAC;AAED,SAAO,aAAa,KAAK;AAAA,IACvB,QAAQ,OAAO;AAAA,IACf,SAAS,OAAO,WAAW;AAAA,IAC3B,SAAS,OAAO,WAAW;AAAA,IAC3B,WAAW,OAAO;AAAA,IAClB,WAAW,OAAO;AAAA,EACpB,CAAC;AACH;",
6
6
  "names": []
7
7
  }
@@ -22,9 +22,12 @@ async function GET(req) {
22
22
  const cache = userId && isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null;
23
23
  const cacheKey = cache && userId ? buildUnreadCountCacheKey(userId) : null;
24
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 });
25
+ try {
26
+ const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey));
27
+ if (typeof cached === "number") {
28
+ return Response.json({ unreadCount: cached });
29
+ }
30
+ } catch {
28
31
  }
29
32
  }
30
33
  const count = await em.count(Notification, {
@@ -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 { 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;",
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 try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (typeof cached === 'number') {\n return Response.json({ unreadCount: cached })\n }\n } catch {}\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,QAAI;AACF,YAAM,SAAS,MAAM,mBAAmB,MAAM,UAAU,MAAM,MAAM,IAAI,QAAQ,CAAC;AACjF,UAAI,OAAO,WAAW,UAAU;AAC9B,eAAO,SAAS,KAAK,EAAE,aAAa,OAAO,CAAC;AAAA,MAC9C;AAAA,IACF,QAAQ;AAAA,IAAC;AAAA,EACX;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.6133.1.f01540250e",
3
+ "version": "0.6.6-develop.6140.1.d3fbb77495",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -246,16 +246,16 @@
246
246
  "zod": "^4.4.3"
247
247
  },
248
248
  "peerDependencies": {
249
- "@open-mercato/ai-assistant": "0.6.6-develop.6133.1.f01540250e",
250
- "@open-mercato/shared": "0.6.6-develop.6133.1.f01540250e",
251
- "@open-mercato/ui": "0.6.6-develop.6133.1.f01540250e",
249
+ "@open-mercato/ai-assistant": "0.6.6-develop.6140.1.d3fbb77495",
250
+ "@open-mercato/shared": "0.6.6-develop.6140.1.d3fbb77495",
251
+ "@open-mercato/ui": "0.6.6-develop.6140.1.d3fbb77495",
252
252
  "react": "^19.0.0",
253
253
  "react-dom": "^19.0.0"
254
254
  },
255
255
  "devDependencies": {
256
- "@open-mercato/ai-assistant": "0.6.6-develop.6133.1.f01540250e",
257
- "@open-mercato/shared": "0.6.6-develop.6133.1.f01540250e",
258
- "@open-mercato/ui": "0.6.6-develop.6133.1.f01540250e",
256
+ "@open-mercato/ai-assistant": "0.6.6-develop.6140.1.d3fbb77495",
257
+ "@open-mercato/shared": "0.6.6-develop.6140.1.d3fbb77495",
258
+ "@open-mercato/ui": "0.6.6-develop.6140.1.d3fbb77495",
259
259
  "@testing-library/dom": "^10.4.1",
260
260
  "@testing-library/jest-dom": "^6.9.1",
261
261
  "@testing-library/react": "^16.3.1",
@@ -262,6 +262,7 @@ Declared in `events.ts` via `createModuleEvents`. Emit with `emitCustomerAccount
262
262
  | `customer_accounts.role.created` | crud | No |
263
263
  | `customer_accounts.role.updated` | crud | No |
264
264
  | `customer_accounts.role.deleted` | crud | No |
265
+ | `customer_accounts.user.invited` | lifecycle | Yes |
265
266
  | `customer_accounts.invitation.accepted` | lifecycle | Yes |
266
267
 
267
268
  ## Subscribers
@@ -5,6 +5,7 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
  import { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
8
+ import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
8
9
  import { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
9
10
  import { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'
10
11
  import {
@@ -63,6 +64,15 @@ export async function POST(req: Request) {
63
64
  },
64
65
  )
65
66
 
67
+ void emitCustomerAccountsEvent('customer_accounts.user.invited', {
68
+ invitationId: invitation.id,
69
+ email: invitation.email,
70
+ customerEntityId: invitation.customerEntityId || null,
71
+ invitedByType: 'staff',
72
+ tenantId: auth.tenantId!,
73
+ organizationId: auth.orgId!,
74
+ }).catch(() => undefined)
75
+
66
76
  return NextResponse.json({
67
77
  ok: true,
68
78
  invitation: {
@@ -4,6 +4,7 @@ import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib
4
4
  import { getCustomerAuthFromRequest, requireCustomerFeature } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'
5
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { CustomerInvitationService } from '@open-mercato/core/modules/customer_accounts/services/customerInvitationService'
7
+ import { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'
7
8
  import { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'
8
9
  import { CustomerRole } from '@open-mercato/core/modules/customer_accounts/data/entities'
9
10
  import { inviteUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'
@@ -95,6 +96,15 @@ export async function POST(req: Request) {
95
96
  },
96
97
  )
97
98
 
99
+ void emitCustomerAccountsEvent('customer_accounts.user.invited', {
100
+ invitationId: invitation.id,
101
+ email: invitation.email,
102
+ customerEntityId: invitation.customerEntityId || null,
103
+ invitedByType: 'portal',
104
+ tenantId: auth.tenantId,
105
+ organizationId: auth.orgId,
106
+ }).catch(() => undefined)
107
+
98
108
  return NextResponse.json({
99
109
  ok: true,
100
110
  invitation: {
@@ -16,6 +16,7 @@ const events = [
16
16
  { id: 'customer_accounts.role.created', label: 'Customer Role Created', entity: 'role', category: 'crud' },
17
17
  { id: 'customer_accounts.role.updated', label: 'Customer Role Updated', entity: 'role', category: 'crud', portalBroadcast: true },
18
18
  { id: 'customer_accounts.role.deleted', label: 'Customer Role Deleted', entity: 'role', category: 'crud' },
19
+ { id: 'customer_accounts.user.invited', label: 'Customer User Invited', entity: 'user', category: 'lifecycle', clientBroadcast: true },
19
20
  { id: 'customer_accounts.invitation.accepted', label: 'Customer Invitation Accepted', category: 'lifecycle', clientBroadcast: true },
20
21
  { id: 'customer_accounts.password_reset.requested', label: 'Customer Password Reset Requested', category: 'lifecycle' },
21
22
  // Custom domain mapping lifecycle (see .ai/specs/implemented/2026-04-08-portal-custom-domain-routing.md)
@@ -5,6 +5,10 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import { DashboardLayout } from '@open-mercato/core/modules/dashboards/data/entities'
6
6
  import { dashboardLayoutItemPatchSchema } from '@open-mercato/core/modules/dashboards/data/validators'
7
7
  import { hasFeature } from '@open-mercato/shared/security/features'
8
+ import {
9
+ runCrudMutationGuardAfterSuccess,
10
+ validateCrudMutationGuard,
11
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
8
12
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
13
  import {
10
14
  dashboardsTag,
@@ -14,6 +18,7 @@ import {
14
18
  } from '../../openapi'
15
19
 
16
20
  const DEFAULT_SIZE = 'md'
21
+ const RESOURCE_KIND = 'dashboards.layout'
17
22
 
18
23
  export const metadata = {
19
24
  PATCH: { requireAuth: true, requireFeatures: ['dashboards.configure'] },
@@ -39,7 +44,8 @@ export async function PATCH(req: Request, ctx: { params?: { itemId?: string } })
39
44
  return NextResponse.json({ error: 'Invalid payload', issues: parsed.error.issues }, { status: 400 })
40
45
  }
41
46
 
42
- const { resolve } = await createRequestContainer()
47
+ const container = await createRequestContainer()
48
+ const { resolve } = container
43
49
  const em = resolve('em') as any
44
50
  const rbac = resolve('rbacService') as any
45
51
 
@@ -54,6 +60,21 @@ export async function PATCH(req: Request, ctx: { params?: { itemId?: string } })
54
60
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
55
61
  }
56
62
 
63
+ const guardResult = await validateCrudMutationGuard(container, {
64
+ tenantId: scope.tenantId ?? '',
65
+ organizationId: scope.organizationId,
66
+ userId: scope.userId,
67
+ resourceKind: RESOURCE_KIND,
68
+ resourceId: layoutItemId,
69
+ operation: 'update',
70
+ requestMethod: req.method,
71
+ requestHeaders: req.headers,
72
+ mutationPayload: { id: layoutItemId, size: parsed.data.size, settings: parsed.data.settings },
73
+ })
74
+ if (guardResult && !guardResult.ok) {
75
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
76
+ }
77
+
57
78
  const layout = await em.findOne(DashboardLayout, {
58
79
  userId: scope.userId,
59
80
  tenantId: scope.tenantId,
@@ -77,6 +98,20 @@ export async function PATCH(req: Request, ctx: { params?: { itemId?: string } })
77
98
  }
78
99
  await em.flush()
79
100
 
101
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
102
+ await runCrudMutationGuardAfterSuccess(container, {
103
+ tenantId: scope.tenantId ?? '',
104
+ organizationId: scope.organizationId,
105
+ userId: scope.userId,
106
+ resourceKind: RESOURCE_KIND,
107
+ resourceId: layoutItemId,
108
+ operation: 'update',
109
+ requestMethod: req.method,
110
+ requestHeaders: req.headers,
111
+ metadata: guardResult.metadata ?? null,
112
+ })
113
+ }
114
+
80
115
  return NextResponse.json({ ok: true })
81
116
  }
82
117
 
@@ -9,6 +9,10 @@ import { resolveAllowedWidgetIds } from '@open-mercato/core/modules/dashboards/l
9
9
  import { hasFeature } from '@open-mercato/shared/security/features'
10
10
  import { User } from '@open-mercato/core/modules/auth/data/entities'
11
11
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
12
+ import {
13
+ runCrudMutationGuardAfterSuccess,
14
+ validateCrudMutationGuard,
15
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
12
16
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
13
17
  import {
14
18
  dashboardsTag,
@@ -18,6 +22,7 @@ import {
18
22
  } from '../openapi'
19
23
 
20
24
  const DEFAULT_SIZE = 'md'
25
+ const RESOURCE_KIND = 'dashboards.layout'
21
26
 
22
27
  export const metadata = {
23
28
  GET: { requireAuth: true, requireFeatures: ['dashboards.view'] },
@@ -206,7 +211,8 @@ export async function PUT(req: Request) {
206
211
  return NextResponse.json({ error: 'Invalid layout payload', issues: parsed.error.issues }, { status: 400 })
207
212
  }
208
213
 
209
- const { resolve } = await createRequestContainer()
214
+ const container = await createRequestContainer()
215
+ const { resolve } = container
210
216
  const em = (resolve('em') as any).fork({ clear: true, freshEventManager: true, useContext: true })
211
217
  const rbac = resolve('rbacService') as any
212
218
 
@@ -221,6 +227,21 @@ export async function PUT(req: Request) {
221
227
  return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
222
228
  }
223
229
 
230
+ const guardResult = await validateCrudMutationGuard(container, {
231
+ tenantId: scope.tenantId ?? '',
232
+ organizationId: scope.organizationId,
233
+ userId: scope.userId,
234
+ resourceKind: RESOURCE_KIND,
235
+ resourceId: scope.userId,
236
+ operation: 'update',
237
+ requestMethod: req.method,
238
+ requestHeaders: req.headers,
239
+ mutationPayload: { items: parsed.data.items },
240
+ })
241
+ if (guardResult && !guardResult.ok) {
242
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
243
+ }
244
+
224
245
  const widgets = await loadAllWidgets()
225
246
  const allowedIds = await resolveAllowedWidgetIds(
226
247
  em,
@@ -266,6 +287,20 @@ export async function PUT(req: Request) {
266
287
  }
267
288
  await em.flush()
268
289
 
290
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
291
+ await runCrudMutationGuardAfterSuccess(container, {
292
+ tenantId: scope.tenantId ?? '',
293
+ organizationId: scope.organizationId,
294
+ userId: scope.userId,
295
+ resourceKind: RESOURCE_KIND,
296
+ resourceId: scope.userId,
297
+ operation: 'update',
298
+ requestMethod: req.method,
299
+ requestHeaders: req.headers,
300
+ metadata: guardResult.metadata ?? null,
301
+ })
302
+ }
303
+
269
304
  return NextResponse.json({ ok: true })
270
305
  }
271
306
 
@@ -8,6 +8,10 @@ import { roleWidgetSettingsSchema } from '@open-mercato/core/modules/dashboards/
8
8
  import { loadAllWidgets } from '@open-mercato/core/modules/dashboards/lib/widgets'
9
9
  import { resolveWidgetAssignmentReadScope } from '@open-mercato/core/modules/dashboards/lib/widgetAssignmentScope'
10
10
  import { hasFeature } from '@open-mercato/shared/security/features'
11
+ import {
12
+ runCrudMutationGuardAfterSuccess,
13
+ validateCrudMutationGuard,
14
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
11
15
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
12
16
  import {
13
17
  dashboardsTag,
@@ -17,6 +21,7 @@ import {
17
21
  } from '../../openapi'
18
22
 
19
23
  const FEATURE = 'dashboards.admin.assign-widgets'
24
+ const RESOURCE_KIND = 'dashboards.roleWidgets'
20
25
 
21
26
  function pickBestRecord(records: DashboardRoleWidgets[], tenantId: string | null, organizationId: string | null): DashboardRoleWidgets | null {
22
27
  let best: DashboardRoleWidgets | null = null
@@ -99,7 +104,8 @@ export async function PUT(req: Request) {
99
104
  return NextResponse.json({ error: 'Invalid payload', issues: parsed.error.issues }, { status: 400 })
100
105
  }
101
106
 
102
- const { resolve } = await createRequestContainer()
107
+ const container = await createRequestContainer()
108
+ const { resolve } = container
103
109
  const em = resolve('em') as any
104
110
  const rbac = resolve('rbacService') as any
105
111
  const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
@@ -119,6 +125,21 @@ export async function PUT(req: Request) {
119
125
  return NextResponse.json({ error: 'Role not found' }, { status: 404 })
120
126
  }
121
127
 
128
+ const guardResult = await validateCrudMutationGuard(container, {
129
+ tenantId: tenantId ?? '',
130
+ organizationId,
131
+ userId: String(auth.sub),
132
+ resourceKind: RESOURCE_KIND,
133
+ resourceId: parsed.data.roleId,
134
+ operation: 'update',
135
+ requestMethod: req.method,
136
+ requestHeaders: req.headers,
137
+ mutationPayload: { roleId: parsed.data.roleId, widgetIds },
138
+ })
139
+ if (guardResult && !guardResult.ok) {
140
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
141
+ }
142
+
122
143
  let record = await em.findOne(DashboardRoleWidgets, {
123
144
  roleId: parsed.data.roleId,
124
145
  tenantId,
@@ -130,6 +151,19 @@ export async function PUT(req: Request) {
130
151
  if (record) {
131
152
  await em.remove(record).flush()
132
153
  }
154
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
155
+ await runCrudMutationGuardAfterSuccess(container, {
156
+ tenantId: tenantId ?? '',
157
+ organizationId,
158
+ userId: String(auth.sub),
159
+ resourceKind: RESOURCE_KIND,
160
+ resourceId: parsed.data.roleId,
161
+ operation: 'update',
162
+ requestMethod: req.method,
163
+ requestHeaders: req.headers,
164
+ metadata: guardResult.metadata ?? null,
165
+ })
166
+ }
133
167
  return NextResponse.json({ ok: true, widgetIds: [] })
134
168
  }
135
169
 
@@ -146,6 +180,20 @@ export async function PUT(req: Request) {
146
180
  }
147
181
  await em.flush()
148
182
 
183
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
184
+ await runCrudMutationGuardAfterSuccess(container, {
185
+ tenantId: tenantId ?? '',
186
+ organizationId,
187
+ userId: String(auth.sub),
188
+ resourceKind: RESOURCE_KIND,
189
+ resourceId: parsed.data.roleId,
190
+ operation: 'update',
191
+ requestMethod: req.method,
192
+ requestHeaders: req.headers,
193
+ metadata: guardResult.metadata ?? null,
194
+ })
195
+ }
196
+
149
197
  return NextResponse.json({ ok: true, widgetIds })
150
198
  }
151
199
 
@@ -9,6 +9,10 @@ import { loadAllWidgets } from '@open-mercato/core/modules/dashboards/lib/widget
9
9
  import { resolveAllowedWidgetIds } from '@open-mercato/core/modules/dashboards/lib/access'
10
10
  import { resolveWidgetAssignmentReadScope } from '@open-mercato/core/modules/dashboards/lib/widgetAssignmentScope'
11
11
  import { hasFeature } from '@open-mercato/shared/security/features'
12
+ import {
13
+ runCrudMutationGuardAfterSuccess,
14
+ validateCrudMutationGuard,
15
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
12
16
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
13
17
  import {
14
18
  dashboardsTag,
@@ -18,6 +22,7 @@ import {
18
22
  } from '../../openapi'
19
23
 
20
24
  const FEATURE = 'dashboards.admin.assign-widgets'
25
+ const RESOURCE_KIND = 'dashboards.userWidgets'
21
26
 
22
27
  export const metadata = {
23
28
  GET: { requireAuth: true, requireFeatures: [FEATURE] },
@@ -99,7 +104,8 @@ export async function PUT(req: Request) {
99
104
  return NextResponse.json({ error: 'Invalid payload', issues: parsed.error.issues }, { status: 400 })
100
105
  }
101
106
 
102
- const { resolve } = await createRequestContainer()
107
+ const container = await createRequestContainer()
108
+ const { resolve } = container
103
109
  const em = resolve('em') as any
104
110
  const rbac = resolve('rbacService') as any
105
111
  const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
@@ -119,6 +125,21 @@ export async function PUT(req: Request) {
119
125
  return NextResponse.json({ error: 'User not found' }, { status: 404 })
120
126
  }
121
127
 
128
+ const guardResult = await validateCrudMutationGuard(container, {
129
+ tenantId: tenantId ?? '',
130
+ organizationId,
131
+ userId: String(auth.sub),
132
+ resourceKind: RESOURCE_KIND,
133
+ resourceId: parsed.data.userId,
134
+ operation: 'update',
135
+ requestMethod: req.method,
136
+ requestHeaders: req.headers,
137
+ mutationPayload: { userId: parsed.data.userId, mode: parsed.data.mode, widgetIds },
138
+ })
139
+ if (guardResult && !guardResult.ok) {
140
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
141
+ }
142
+
122
143
  let record = await em.findOne(DashboardUserWidgets, {
123
144
  userId: parsed.data.userId,
124
145
  tenantId,
@@ -130,6 +151,19 @@ export async function PUT(req: Request) {
130
151
  if (record) {
131
152
  await em.remove(record).flush()
132
153
  }
154
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
155
+ await runCrudMutationGuardAfterSuccess(container, {
156
+ tenantId: tenantId ?? '',
157
+ organizationId,
158
+ userId: String(auth.sub),
159
+ resourceKind: RESOURCE_KIND,
160
+ resourceId: parsed.data.userId,
161
+ operation: 'update',
162
+ requestMethod: req.method,
163
+ requestHeaders: req.headers,
164
+ metadata: guardResult.metadata ?? null,
165
+ })
166
+ }
133
167
  return NextResponse.json({ ok: true, mode: 'inherit', widgetIds: [] })
134
168
  }
135
169
 
@@ -148,6 +182,20 @@ export async function PUT(req: Request) {
148
182
  }
149
183
  await em.flush()
150
184
 
185
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
186
+ await runCrudMutationGuardAfterSuccess(container, {
187
+ tenantId: tenantId ?? '',
188
+ organizationId,
189
+ userId: String(auth.sub),
190
+ resourceKind: RESOURCE_KIND,
191
+ resourceId: parsed.data.userId,
192
+ operation: 'update',
193
+ requestMethod: req.method,
194
+ requestHeaders: req.headers,
195
+ metadata: guardResult.metadata ?? null,
196
+ })
197
+ }
198
+
151
199
  return NextResponse.json({ ok: true, mode: 'override', widgetIds })
152
200
  }
153
201
 
@@ -6,6 +6,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { Tenant } from '@open-mercato/core/modules/directory/data/entities'
7
7
  import { tenantCreateSchema, tenantUpdateSchema } from '@open-mercato/core/modules/directory/data/validators'
8
8
  import { loadCustomFieldValues, buildCustomFieldFiltersFromQuery } from '@open-mercato/shared/lib/crud/custom-fields'
9
+ import { resolveRecordIdsForCustomFieldFilters } from './tenant-cf-filter'
9
10
  import { E } from '#generated/entities.ids.generated'
10
11
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
11
12
  import type { EntityManager } from '@mikro-orm/postgresql'
@@ -87,11 +88,6 @@ const toRow = (tenant: Tenant, cf: Record<string, unknown>): TenantRow => ({
87
88
  ...cf,
88
89
  })
89
90
 
90
- const matchesValue = (current: unknown, expected: unknown): boolean => {
91
- if (Array.isArray(current)) return current.some((item) => item === expected)
92
- return current === expected
93
- }
94
-
95
91
  export async function GET(req: Request) {
96
92
  const auth = await getAuthFromRequest(req)
97
93
  if (!auth) {
@@ -180,28 +176,31 @@ export async function GET(req: Request) {
180
176
  let cfValues: Record<string, Record<string, unknown>>
181
177
 
182
178
  if (cfFilterEntries.length) {
183
- // Custom-field filters are matched in JS against separately-loaded values,
184
- // so the full result set must be fetched before filtering and paging.
185
- const all = await em.find(Tenant, where, { orderBy })
186
- cfValues = await loadCfValues(all)
187
- const filtered = all.filter((tenant) => {
188
- const rid = String(tenant.id)
189
- const payload = cfValues[rid] ?? {}
190
- return cfFilterEntries.every(([key, expected]) => {
191
- const value = payload[`cf_${key}`]
192
- if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
193
- const maybeIn = (expected as { $in?: unknown[] }).$in
194
- if (Array.isArray(maybeIn)) {
195
- if (value === undefined || value === null) return false
196
- if (Array.isArray(value)) return value.some((val) => maybeIn.includes(val))
197
- return maybeIn.includes(value)
198
- }
199
- }
200
- return matchesValue(value, expected)
201
- })
179
+ // Custom-field values live in a separate store, so first resolve the bounded
180
+ // set of matching tenant ids from that store, then push pagination to the
181
+ // database via findAndCount instead of loading the whole tenant table.
182
+ const matchedIds = await resolveRecordIdsForCustomFieldFilters({
183
+ em,
184
+ entityId: E.directory.tenant,
185
+ tenantId: auth.tenantId ?? null,
186
+ filters: cfFilterEntries,
202
187
  })
203
- total = filtered.length
204
- pageTenants = filtered.slice(start, start + pageSize)
188
+ const existingId = where.id
189
+ const idFilter = typeof existingId === 'string'
190
+ ? (matchedIds.has(existingId) ? [existingId] : [])
191
+ : Array.from(matchedIds)
192
+
193
+ if (!idFilter.length) {
194
+ total = 0
195
+ pageTenants = []
196
+ cfValues = {}
197
+ } else {
198
+ where.id = { $in: idFilter }
199
+ const [rows, count] = await em.findAndCount(Tenant, where, { orderBy, limit: pageSize, offset: start })
200
+ total = count
201
+ pageTenants = rows
202
+ cfValues = await loadCfValues(rows)
203
+ }
205
204
  } else {
206
205
  // No JS-side filtering applies, so pagination is pushed to the database
207
206
  // instead of fetching the whole table and slicing in memory.