@open-mercato/core 0.6.8-develop.6964.1.36b364cfd8 → 0.6.8-develop.6971.1.20c09ca9ea

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 (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_docs/frontend/docs/api/page.js +8 -1
  3. package/dist/modules/api_docs/frontend/docs/api/page.js.map +2 -2
  4. package/dist/modules/api_docs/lib/document.js +60 -0
  5. package/dist/modules/api_docs/lib/document.js.map +7 -0
  6. package/dist/modules/customer_accounts/api/admin/users/[id].js +4 -1
  7. package/dist/modules/customer_accounts/api/admin/users/[id].js.map +2 -2
  8. package/dist/modules/customer_accounts/services/customerUserService.js +16 -5
  9. package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
  10. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js +1 -0
  11. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js.map +2 -2
  12. package/dist/modules/customers/api/dashboard/widgets/new-customers/route.js +1 -0
  13. package/dist/modules/customers/api/dashboard/widgets/new-customers/route.js.map +2 -2
  14. package/dist/modules/customers/api/dashboard/widgets/new-deals/route.js +1 -0
  15. package/dist/modules/customers/api/dashboard/widgets/new-deals/route.js.map +2 -2
  16. package/dist/modules/customers/api/dashboard/widgets/next-interactions/route.js +1 -0
  17. package/dist/modules/customers/api/dashboard/widgets/next-interactions/route.js.map +2 -2
  18. package/dist/modules/dashboards/lib/widgetScope.js +29 -3
  19. package/dist/modules/dashboards/lib/widgetScope.js.map +2 -2
  20. package/package.json +7 -7
  21. package/src/modules/api_docs/frontend/docs/api/page.tsx +8 -1
  22. package/src/modules/api_docs/lib/document.ts +84 -0
  23. package/src/modules/customer_accounts/api/admin/users/[id].ts +10 -1
  24. package/src/modules/customer_accounts/services/customerUserService.ts +16 -5
  25. package/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts +1 -0
  26. package/src/modules/customers/api/dashboard/widgets/new-customers/route.ts +1 -0
  27. package/src/modules/customers/api/dashboard/widgets/new-deals/route.ts +1 -0
  28. package/src/modules/customers/api/dashboard/widgets/next-interactions/route.ts +1 -0
  29. package/src/modules/dashboards/i18n/de.json +1 -0
  30. package/src/modules/dashboards/i18n/en.json +1 -0
  31. package/src/modules/dashboards/i18n/es.json +1 -0
  32. package/src/modules/dashboards/i18n/ko.json +1 -0
  33. package/src/modules/dashboards/i18n/pl.json +1 -0
  34. package/src/modules/dashboards/lib/widgetScope.ts +45 -3
@@ -1,4 +1,4 @@
1
- [build:core] found 3948 entry points
1
+ [build:core] found 3949 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 204 entry points
4
4
  [build:core:generated] built successfully
@@ -1,6 +1,8 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
+ import { headers } from "next/headers";
2
3
  import ApiDocsExplorer from "./Explorer.js";
3
4
  import { resolveApiDocsBaseUrl } from "@open-mercato/core/modules/api_docs/lib/resources";
5
+ import { resolveForwardableCookieHeader } from "@open-mercato/core/modules/api_docs/lib/document";
4
6
  import { APP_VERSION } from "@open-mercato/shared/lib/version";
5
7
  function collectOperations(doc) {
6
8
  const operations = [];
@@ -38,7 +40,12 @@ function buildTagOrder(doc, operations) {
38
40
  }
39
41
  async function ApiDocsViewerPage() {
40
42
  const baseUrl = resolveApiDocsBaseUrl();
41
- const response = await fetch(`${baseUrl}/docs/openapi`, { cache: "no-store" });
43
+ const requestHeaders = await headers();
44
+ const forwardedCookie = resolveForwardableCookieHeader(baseUrl, requestHeaders);
45
+ const response = await fetch(`${baseUrl}/docs/openapi`, {
46
+ cache: "no-store",
47
+ headers: forwardedCookie ? { cookie: forwardedCookie } : void 0
48
+ });
42
49
  const doc = response.ok ? await response.json() : {
43
50
  openapi: "3.1.0",
44
51
  info: {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/api_docs/frontend/docs/api/page.tsx"],
4
- "sourcesContent": ["import ApiDocsExplorer from './Explorer'\nimport { resolveApiDocsBaseUrl } from '@open-mercato/core/modules/api_docs/lib/resources'\nimport { APP_VERSION } from '@open-mercato/shared/lib/version'\nimport type { OpenApiDocument } from '@open-mercato/shared/lib/openapi'\n\ntype ExplorerOperation = {\n id: string\n path: string\n method: string\n tag: string\n summary?: string\n description?: string\n operation: any\n}\n\nfunction collectOperations(doc: any): ExplorerOperation[] {\n const operations: ExplorerOperation[] = []\n const paths = Object.keys(doc.paths ?? {}).sort((a, b) => a.localeCompare(b))\n for (const path of paths) {\n const methodEntries = Object.entries(doc.paths[path] ?? {})\n for (const [method, operation] of methodEntries) {\n const methodUpper = method.toUpperCase()\n const op = (operation ?? {}) as { summary?: unknown; description?: unknown; tags?: unknown }\n const summary: string | undefined = typeof op.summary === 'string' ? op.summary : undefined\n const description: string | undefined = typeof op.description === 'string' ? op.description : undefined\n const tag = Array.isArray(op.tags) && typeof op.tags[0] === 'string' ? op.tags[0] : 'General'\n operations.push({\n id: `${methodUpper}-${path.replace(/[^\\w]+/g, '-')}`,\n path,\n method: methodUpper,\n tag,\n summary,\n description,\n operation,\n })\n }\n }\n return operations\n}\n\nfunction buildTagOrder(doc: any, operations: ExplorerOperation[]): string[] {\n const fromDoc = Array.isArray(doc.tags) ? doc.tags.map((tag: any) => tag?.name).filter(Boolean) : []\n const fromOps = Array.from(new Set(operations.map((operation) => operation.tag)))\n const order: string[] = []\n for (const tag of [...fromDoc, ...fromOps]) {\n if (typeof tag !== 'string') continue\n if (!order.includes(tag)) order.push(tag)\n }\n return order\n}\n\nexport default async function ApiDocsViewerPage() {\n const baseUrl = resolveApiDocsBaseUrl()\n const response = await fetch(`${baseUrl}/docs/openapi`, { cache: 'no-store' })\n const doc = response.ok\n ? await response.json() as OpenApiDocument\n : {\n openapi: '3.1.0',\n info: {\n title: 'Open Mercato API',\n version: APP_VERSION,\n description: 'Auto-generated OpenAPI definition for all enabled modules.',\n },\n servers: [{ url: baseUrl, description: 'Default environment' }],\n paths: {},\n } satisfies OpenApiDocument\n\n const operations = collectOperations(doc)\n const tagOrder = buildTagOrder(doc, operations)\n\n return (\n <ApiDocsExplorer\n title={doc.info?.title ?? 'Open Mercato API'}\n version={doc.info?.version ?? APP_VERSION}\n description={doc.info?.description}\n operations={operations}\n tagOrder={tagOrder}\n servers={doc.servers ?? []}\n docsUrl=\"https://docs.openmercato.com\"\n jsonSpecUrl=\"/api/docs/openapi\"\n markdownSpecUrl=\"/api/docs/markdown\"\n />\n )\n}\n"],
5
- "mappings": "AAuEI;AAvEJ,OAAO,qBAAqB;AAC5B,SAAS,6BAA6B;AACtC,SAAS,mBAAmB;AAa5B,SAAS,kBAAkB,KAA+B;AACxD,QAAM,aAAkC,CAAC;AACzC,QAAM,QAAQ,OAAO,KAAK,IAAI,SAAS,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAC5E,aAAW,QAAQ,OAAO;AACxB,UAAM,gBAAgB,OAAO,QAAQ,IAAI,MAAM,IAAI,KAAK,CAAC,CAAC;AAC1D,eAAW,CAAC,QAAQ,SAAS,KAAK,eAAe;AAC/C,YAAM,cAAc,OAAO,YAAY;AACvC,YAAM,KAAM,aAAa,CAAC;AAC1B,YAAM,UAA8B,OAAO,GAAG,YAAY,WAAW,GAAG,UAAU;AAClF,YAAM,cAAkC,OAAO,GAAG,gBAAgB,WAAW,GAAG,cAAc;AAC9F,YAAM,MAAM,MAAM,QAAQ,GAAG,IAAI,KAAK,OAAO,GAAG,KAAK,CAAC,MAAM,WAAW,GAAG,KAAK,CAAC,IAAI;AACpF,iBAAW,KAAK;AAAA,QACd,IAAI,GAAG,WAAW,IAAI,KAAK,QAAQ,WAAW,GAAG,CAAC;AAAA,QAClD;AAAA,QACA,QAAQ;AAAA,QACR;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,cAAc,KAAU,YAA2C;AAC1E,QAAM,UAAU,MAAM,QAAQ,IAAI,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC,QAAa,KAAK,IAAI,EAAE,OAAO,OAAO,IAAI,CAAC;AACnG,QAAM,UAAU,MAAM,KAAK,IAAI,IAAI,WAAW,IAAI,CAAC,cAAc,UAAU,GAAG,CAAC,CAAC;AAChF,QAAM,QAAkB,CAAC;AACzB,aAAW,OAAO,CAAC,GAAG,SAAS,GAAG,OAAO,GAAG;AAC1C,QAAI,OAAO,QAAQ,SAAU;AAC7B,QAAI,CAAC,MAAM,SAAS,GAAG,EAAG,OAAM,KAAK,GAAG;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,eAAO,oBAA2C;AAChD,QAAM,UAAU,sBAAsB;AACtC,QAAM,WAAW,MAAM,MAAM,GAAG,OAAO,iBAAiB,EAAE,OAAO,WAAW,CAAC;AAC7E,QAAM,MAAM,SAAS,KACjB,MAAM,SAAS,KAAK,IACpB;AAAA,IACE,SAAS;AAAA,IACT,MAAM;AAAA,MACJ,OAAO;AAAA,MACP,SAAS;AAAA,MACT,aAAa;AAAA,IACf;AAAA,IACA,SAAS,CAAC,EAAE,KAAK,SAAS,aAAa,sBAAsB,CAAC;AAAA,IAC9D,OAAO,CAAC;AAAA,EACV;AAEJ,QAAM,aAAa,kBAAkB,GAAG;AACxC,QAAM,WAAW,cAAc,KAAK,UAAU;AAE9C,SACE;AAAA,IAAC;AAAA;AAAA,MACC,OAAO,IAAI,MAAM,SAAS;AAAA,MAC1B,SAAS,IAAI,MAAM,WAAW;AAAA,MAC9B,aAAa,IAAI,MAAM;AAAA,MACvB;AAAA,MACA;AAAA,MACA,SAAS,IAAI,WAAW,CAAC;AAAA,MACzB,SAAQ;AAAA,MACR,aAAY;AAAA,MACZ,iBAAgB;AAAA;AAAA,EAClB;AAEJ;",
4
+ "sourcesContent": ["import { headers } from 'next/headers'\nimport ApiDocsExplorer from './Explorer'\nimport { resolveApiDocsBaseUrl } from '@open-mercato/core/modules/api_docs/lib/resources'\nimport { resolveForwardableCookieHeader } from '@open-mercato/core/modules/api_docs/lib/document'\nimport { APP_VERSION } from '@open-mercato/shared/lib/version'\nimport type { OpenApiDocument } from '@open-mercato/shared/lib/openapi'\n\ntype ExplorerOperation = {\n id: string\n path: string\n method: string\n tag: string\n summary?: string\n description?: string\n operation: any\n}\n\nfunction collectOperations(doc: any): ExplorerOperation[] {\n const operations: ExplorerOperation[] = []\n const paths = Object.keys(doc.paths ?? {}).sort((a, b) => a.localeCompare(b))\n for (const path of paths) {\n const methodEntries = Object.entries(doc.paths[path] ?? {})\n for (const [method, operation] of methodEntries) {\n const methodUpper = method.toUpperCase()\n const op = (operation ?? {}) as { summary?: unknown; description?: unknown; tags?: unknown }\n const summary: string | undefined = typeof op.summary === 'string' ? op.summary : undefined\n const description: string | undefined = typeof op.description === 'string' ? op.description : undefined\n const tag = Array.isArray(op.tags) && typeof op.tags[0] === 'string' ? op.tags[0] : 'General'\n operations.push({\n id: `${methodUpper}-${path.replace(/[^\\w]+/g, '-')}`,\n path,\n method: methodUpper,\n tag,\n summary,\n description,\n operation,\n })\n }\n }\n return operations\n}\n\nfunction buildTagOrder(doc: any, operations: ExplorerOperation[]): string[] {\n const fromDoc = Array.isArray(doc.tags) ? doc.tags.map((tag: any) => tag?.name).filter(Boolean) : []\n const fromOps = Array.from(new Set(operations.map((operation) => operation.tag)))\n const order: string[] = []\n for (const tag of [...fromDoc, ...fromOps]) {\n if (typeof tag !== 'string') continue\n if (!order.includes(tag)) order.push(tag)\n }\n return order\n}\n\nexport default async function ApiDocsViewerPage() {\n const baseUrl = resolveApiDocsBaseUrl()\n const requestHeaders = await headers()\n const forwardedCookie = resolveForwardableCookieHeader(baseUrl, requestHeaders)\n const response = await fetch(`${baseUrl}/docs/openapi`, {\n cache: 'no-store',\n headers: forwardedCookie ? { cookie: forwardedCookie } : undefined,\n })\n const doc = response.ok\n ? await response.json() as OpenApiDocument\n : {\n openapi: '3.1.0',\n info: {\n title: 'Open Mercato API',\n version: APP_VERSION,\n description: 'Auto-generated OpenAPI definition for all enabled modules.',\n },\n servers: [{ url: baseUrl, description: 'Default environment' }],\n paths: {},\n } satisfies OpenApiDocument\n\n const operations = collectOperations(doc)\n const tagOrder = buildTagOrder(doc, operations)\n\n return (\n <ApiDocsExplorer\n title={doc.info?.title ?? 'Open Mercato API'}\n version={doc.info?.version ?? APP_VERSION}\n description={doc.info?.description}\n operations={operations}\n tagOrder={tagOrder}\n servers={doc.servers ?? []}\n docsUrl=\"https://docs.openmercato.com\"\n jsonSpecUrl=\"/api/docs/openapi\"\n markdownSpecUrl=\"/api/docs/markdown\"\n />\n )\n}\n"],
5
+ "mappings": "AA8EI;AA9EJ,SAAS,eAAe;AACxB,OAAO,qBAAqB;AAC5B,SAAS,6BAA6B;AACtC,SAAS,sCAAsC;AAC/C,SAAS,mBAAmB;AAa5B,SAAS,kBAAkB,KAA+B;AACxD,QAAM,aAAkC,CAAC;AACzC,QAAM,QAAQ,OAAO,KAAK,IAAI,SAAS,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAC5E,aAAW,QAAQ,OAAO;AACxB,UAAM,gBAAgB,OAAO,QAAQ,IAAI,MAAM,IAAI,KAAK,CAAC,CAAC;AAC1D,eAAW,CAAC,QAAQ,SAAS,KAAK,eAAe;AAC/C,YAAM,cAAc,OAAO,YAAY;AACvC,YAAM,KAAM,aAAa,CAAC;AAC1B,YAAM,UAA8B,OAAO,GAAG,YAAY,WAAW,GAAG,UAAU;AAClF,YAAM,cAAkC,OAAO,GAAG,gBAAgB,WAAW,GAAG,cAAc;AAC9F,YAAM,MAAM,MAAM,QAAQ,GAAG,IAAI,KAAK,OAAO,GAAG,KAAK,CAAC,MAAM,WAAW,GAAG,KAAK,CAAC,IAAI;AACpF,iBAAW,KAAK;AAAA,QACd,IAAI,GAAG,WAAW,IAAI,KAAK,QAAQ,WAAW,GAAG,CAAC;AAAA,QAClD;AAAA,QACA,QAAQ;AAAA,QACR;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,MACF,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,cAAc,KAAU,YAA2C;AAC1E,QAAM,UAAU,MAAM,QAAQ,IAAI,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC,QAAa,KAAK,IAAI,EAAE,OAAO,OAAO,IAAI,CAAC;AACnG,QAAM,UAAU,MAAM,KAAK,IAAI,IAAI,WAAW,IAAI,CAAC,cAAc,UAAU,GAAG,CAAC,CAAC;AAChF,QAAM,QAAkB,CAAC;AACzB,aAAW,OAAO,CAAC,GAAG,SAAS,GAAG,OAAO,GAAG;AAC1C,QAAI,OAAO,QAAQ,SAAU;AAC7B,QAAI,CAAC,MAAM,SAAS,GAAG,EAAG,OAAM,KAAK,GAAG;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,eAAO,oBAA2C;AAChD,QAAM,UAAU,sBAAsB;AACtC,QAAM,iBAAiB,MAAM,QAAQ;AACrC,QAAM,kBAAkB,+BAA+B,SAAS,cAAc;AAC9E,QAAM,WAAW,MAAM,MAAM,GAAG,OAAO,iBAAiB;AAAA,IACtD,OAAO;AAAA,IACP,SAAS,kBAAkB,EAAE,QAAQ,gBAAgB,IAAI;AAAA,EAC3D,CAAC;AACD,QAAM,MAAM,SAAS,KACjB,MAAM,SAAS,KAAK,IACpB;AAAA,IACE,SAAS;AAAA,IACT,MAAM;AAAA,MACJ,OAAO;AAAA,MACP,SAAS;AAAA,MACT,aAAa;AAAA,IACf;AAAA,IACA,SAAS,CAAC,EAAE,KAAK,SAAS,aAAa,sBAAsB,CAAC;AAAA,IAC9D,OAAO,CAAC;AAAA,EACV;AAEJ,QAAM,aAAa,kBAAkB,GAAG;AACxC,QAAM,WAAW,cAAc,KAAK,UAAU;AAE9C,SACE;AAAA,IAAC;AAAA;AAAA,MACC,OAAO,IAAI,MAAM,SAAS;AAAA,MAC1B,SAAS,IAAI,MAAM,WAAW;AAAA,MAC9B,aAAa,IAAI,MAAM;AAAA,MACvB;AAAA,MACA;AAAA,MACA,SAAS,IAAI,WAAW,CAAC;AAAA,MACzB,SAAQ;AAAA,MACR,aAAY;AAAA,MACZ,iBAAgB;AAAA;AAAA,EAClB;AAEJ;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,60 @@
1
+ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
2
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
3
+ import {
4
+ attachOpenApiDocsToModules,
5
+ buildOpenApiDocument,
6
+ sanitizeOpenApiDocument
7
+ } from "@open-mercato/shared/lib/openapi";
8
+ import { APP_VERSION } from "@open-mercato/shared/lib/version";
9
+ import { resolveApiDocsBaseUrl } from "./resources.js";
10
+ const API_DOCS_CALLER_SCOPED_HEADERS = {
11
+ "cache-control": "no-store",
12
+ vary: "Cookie, Authorization"
13
+ };
14
+ function resolveForwardableCookieHeader(targetUrl, requestHeaders) {
15
+ const cookieHeader = requestHeaders.get("cookie");
16
+ if (!cookieHeader) return null;
17
+ const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
18
+ if (!host) return null;
19
+ const protocol = requestHeaders.get("x-forwarded-proto") ?? "https";
20
+ try {
21
+ const target = new URL(targetUrl);
22
+ const origin = new URL(`${protocol}://${host}`);
23
+ return target.origin === origin.origin ? cookieHeader : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ async function shouldExposeAccessControlMetadata(req) {
29
+ try {
30
+ return Boolean(await getAuthFromRequest(req));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+ async function buildApiDocsOpenApiDocument({
36
+ modules,
37
+ apiRoutes,
38
+ includeAccessControlMetadata
39
+ }) {
40
+ const { t } = await resolveTranslations();
41
+ const baseUrl = resolveApiDocsBaseUrl();
42
+ const docModules = await attachOpenApiDocsToModules(modules, apiRoutes);
43
+ const rawDoc = buildOpenApiDocument(docModules, {
44
+ title: t("api.docs.title", "Open Mercato API"),
45
+ version: APP_VERSION,
46
+ description: t("api.docs.description", "Auto-generated OpenAPI definition for all enabled modules."),
47
+ servers: [{ url: baseUrl, description: t("api.docs.serverDescription", "Default environment") }],
48
+ baseUrlForExamples: baseUrl,
49
+ defaultSecurity: ["bearerAuth"],
50
+ includeAccessControlMetadata
51
+ });
52
+ return sanitizeOpenApiDocument(rawDoc);
53
+ }
54
+ export {
55
+ API_DOCS_CALLER_SCOPED_HEADERS,
56
+ buildApiDocsOpenApiDocument,
57
+ resolveForwardableCookieHeader,
58
+ shouldExposeAccessControlMetadata
59
+ };
60
+ //# sourceMappingURL=document.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/api_docs/lib/document.ts"],
4
+ "sourcesContent": ["import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n attachOpenApiDocsToModules,\n buildOpenApiDocument,\n sanitizeOpenApiDocument,\n} from '@open-mercato/shared/lib/openapi'\nimport type { OpenApiDocument } from '@open-mercato/shared/lib/openapi'\nimport type { ApiRouteManifestEntry, Module } from '@open-mercato/shared/modules/registry'\nimport { APP_VERSION } from '@open-mercato/shared/lib/version'\nimport { resolveApiDocsBaseUrl } from './resources'\n\n/**\n * The exports render differently for anonymous and authenticated callers, so\n * they must never be served from a shared cache keyed on the URL alone.\n */\nexport const API_DOCS_CALLER_SCOPED_HEADERS = {\n 'cache-control': 'no-store',\n vary: 'Cookie, Authorization',\n} as const\n\n/**\n * The Explorer renders server-side and needs the visitor's session to receive\n * the full document, but `resolveApiDocsBaseUrl()` is operator-configurable \u2014\n * so the session cookie only travels when the export route lives on the very\n * origin that served the page.\n */\nexport function resolveForwardableCookieHeader(\n targetUrl: string,\n requestHeaders: Pick<Headers, 'get'>,\n): string | null {\n const cookieHeader = requestHeaders.get('cookie')\n if (!cookieHeader) return null\n const host = requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host')\n if (!host) return null\n const protocol = requestHeaders.get('x-forwarded-proto') ?? 'https'\n try {\n const target = new URL(targetUrl)\n const origin = new URL(`${protocol}://${host}`)\n return target.origin === origin.origin ? cookieHeader : null\n } catch {\n return null\n }\n}\n\nexport type ApiDocsDocumentInput = {\n modules: Module[]\n apiRoutes: ApiRouteManifestEntry[]\n includeAccessControlMetadata: boolean\n}\n\n/**\n * The docs export routes stay publicly reachable, so the ACL metadata they\n * carry (`Requires features/roles`, `x-require-features`, `x-require-roles`)\n * is only rendered for authenticated staff callers. Anonymous callers get the\n * same document with those identifiers stripped.\n */\nexport async function shouldExposeAccessControlMetadata(req: Request): Promise<boolean> {\n try {\n return Boolean(await getAuthFromRequest(req))\n } catch {\n return false\n }\n}\n\nexport async function buildApiDocsOpenApiDocument({\n modules,\n apiRoutes,\n includeAccessControlMetadata,\n}: ApiDocsDocumentInput): Promise<OpenApiDocument> {\n const { t } = await resolveTranslations()\n const baseUrl = resolveApiDocsBaseUrl()\n const docModules = await attachOpenApiDocsToModules(modules, apiRoutes)\n const rawDoc = buildOpenApiDocument(docModules, {\n title: t('api.docs.title', 'Open Mercato API'),\n version: APP_VERSION,\n description: t('api.docs.description', 'Auto-generated OpenAPI definition for all enabled modules.'),\n servers: [{ url: baseUrl, description: t('api.docs.serverDescription', 'Default environment') }],\n baseUrlForExamples: baseUrl,\n defaultSecurity: ['bearerAuth'],\n includeAccessControlMetadata,\n })\n return sanitizeOpenApiDocument(rawDoc)\n}\n"],
5
+ "mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,mBAAmB;AAC5B,SAAS,6BAA6B;AAM/B,MAAM,iCAAiC;AAAA,EAC5C,iBAAiB;AAAA,EACjB,MAAM;AACR;AAQO,SAAS,+BACd,WACA,gBACe;AACf,QAAM,eAAe,eAAe,IAAI,QAAQ;AAChD,MAAI,CAAC,aAAc,QAAO;AAC1B,QAAM,OAAO,eAAe,IAAI,kBAAkB,KAAK,eAAe,IAAI,MAAM;AAChF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,WAAW,eAAe,IAAI,mBAAmB,KAAK;AAC5D,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,SAAS;AAChC,UAAM,SAAS,IAAI,IAAI,GAAG,QAAQ,MAAM,IAAI,EAAE;AAC9C,WAAO,OAAO,WAAW,OAAO,SAAS,eAAe;AAAA,EAC1D,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAcA,eAAsB,kCAAkC,KAAgC;AACtF,MAAI;AACF,WAAO,QAAQ,MAAM,mBAAmB,GAAG,CAAC;AAAA,EAC9C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,4BAA4B;AAAA,EAChD;AAAA,EACA;AAAA,EACA;AACF,GAAmD;AACjD,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,UAAU,sBAAsB;AACtC,QAAM,aAAa,MAAM,2BAA2B,SAAS,SAAS;AACtE,QAAM,SAAS,qBAAqB,YAAY;AAAA,IAC9C,OAAO,EAAE,kBAAkB,kBAAkB;AAAA,IAC7C,SAAS;AAAA,IACT,aAAa,EAAE,wBAAwB,4DAA4D;AAAA,IACnG,SAAS,CAAC,EAAE,KAAK,SAAS,aAAa,EAAE,8BAA8B,qBAAqB,EAAE,CAAC;AAAA,IAC/F,oBAAoB;AAAA,IACpB,iBAAiB,CAAC,YAAY;AAAA,IAC9B;AAAA,EACF,CAAC;AACD,SAAO,wBAAwB,MAAM;AACvC;",
6
+ "names": []
7
+ }
@@ -145,9 +145,12 @@ async function PUT(req, { params }) {
145
145
  return NextResponse.json({ ok: false, error: "Person not found" }, { status: 400 });
146
146
  }
147
147
  }
148
+ if (parsed.data.displayName !== void 0) {
149
+ const customerUserService = container.resolve("customerUserService");
150
+ await customerUserService.updateProfile(user, { displayName: parsed.data.displayName });
151
+ }
148
152
  const nextUpdatedAt = /* @__PURE__ */ new Date();
149
153
  const updates = { updatedAt: nextUpdatedAt };
150
- if (parsed.data.displayName !== void 0) updates.displayName = parsed.data.displayName;
151
154
  if (parsed.data.isActive !== void 0) updates.isActive = parsed.data.isActive;
152
155
  if (parsed.data.lockedUntil !== void 0) updates.lockedUntil = parsed.data.lockedUntil ? new Date(parsed.data.lockedUntil) : null;
153
156
  if (parsed.data.personEntityId !== void 0) updates.personEntityId = parsed.data.personEntityId;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/customer_accounts/api/admin/users/%5Bid%5D.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CustomerUser, CustomerUserRole, CustomerRole, CustomerUserSession } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { CustomerUserService } from '@open-mercato/core/modules/customer_accounts/services/customerUserService'\nimport { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'\nimport { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { adminUpdateUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isOwnedCompanyEntity, isOwnedPersonEntity } from '@open-mercato/core/modules/customer_accounts/lib/customerEntityOwnership'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\n\nexport const metadata = {}\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nexport async function GET(req: Request, { params }: { params: { id: string } }) {\n if (!UUID_RE.test(params.id)) {\n return NextResponse.json({ ok: false, error: 'Invalid user ID' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.view'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n const user = await findOneWithDecryption(\n em,\n CustomerUser,\n { id: params.id, tenantId: auth.tenantId, organizationId: auth.orgId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!user) {\n return NextResponse.json({ ok: false, error: 'User not found' }, { status: 404 })\n }\n\n const userRoles = await findWithDecryption(\n em,\n CustomerUserRole,\n { user: user.id as any, deletedAt: null } as any,\n { populate: ['role'] },\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n const roles = userRoles.map((ur) => ({\n id: (ur.role as any).id,\n name: (ur.role as any).name,\n slug: (ur.role as any).slug,\n }))\n\n const activeSessions = await findWithDecryption(\n em,\n CustomerUserSession,\n {\n user: user.id as any,\n deletedAt: null,\n expiresAt: { $gt: new Date() },\n } as any,\n { orderBy: { lastUsedAt: 'DESC' } },\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n\n const sessions = activeSessions.map((session) => ({\n id: session.id,\n ipAddress: (session as any).ipAddress || null,\n userAgent: (session as any).userAgent || null,\n lastUsedAt: (session as any).lastUsedAt || null,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n }))\n\n return NextResponse.json({\n ok: true,\n id: user.id,\n email: user.email,\n displayName: user.displayName,\n emailVerifiedAt: user.emailVerifiedAt || null,\n isActive: user.isActive,\n lockedUntil: user.lockedUntil || null,\n lastLoginAt: user.lastLoginAt || null,\n customerEntityId: user.customerEntityId || null,\n personEntityId: user.personEntityId || null,\n createdAt: user.createdAt,\n updatedAt: user.updatedAt || null,\n roles,\n sessions,\n })\n}\n\nexport async function PUT(req: Request, { params }: { params: { id: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.manage'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = adminUpdateUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n const user = await findOneWithDecryption(\n em,\n CustomerUser,\n { id: params.id, tenantId: auth.tenantId, organizationId: auth.orgId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!user) {\n return NextResponse.json({ ok: false, error: 'User not found' }, { status: 404 })\n }\n\n // Optimistic lock: refuse a stale overwrite so two admins editing the same\n // customer user in parallel cannot silently clobber each other (#2055). The\n // check is strictly additive \u2014 a no-op when the client sends no expected-version header.\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'customer_accounts.user',\n resourceId: user.id,\n current: user.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n // Reject a customerEntityId the caller does not own before persisting it.\n // Without this check a mislinked company FK cross-links the user into another\n // org/company's portal context and persists indefinitely (#2693). A null value\n // (unlink) needs no ownership check.\n if (parsed.data.customerEntityId) {\n const owned = await isOwnedCompanyEntity(em, parsed.data.customerEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Company not found' }, { status: 400 })\n }\n }\n\n // Same guard for the person FK. Without it the invite-side check is trivially\n // bypassable: create the user normally, then PUT an unowned personEntityId.\n // It persists (autoLinkCrm short-circuits on any non-null value) and leaks\n // account status into the other org's people list via the account-status\n // enricher. A null value (unlink) needs no ownership check.\n if (parsed.data.personEntityId) {\n const owned = await isOwnedPersonEntity(em, parsed.data.personEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Person not found' }, { status: 400 })\n }\n }\n\n // Always bump updated_at so the optimistic-lock version advances on every save.\n // `nativeUpdate` bypasses MikroORM's `onUpdate` hook, so set it explicitly \u2014 without\n // this the version never changes and concurrent edits cannot be detected (#2055).\n const nextUpdatedAt = new Date()\n const updates: Record<string, unknown> = { updatedAt: nextUpdatedAt }\n if (parsed.data.displayName !== undefined) updates.displayName = parsed.data.displayName\n if (parsed.data.isActive !== undefined) updates.isActive = parsed.data.isActive\n if (parsed.data.lockedUntil !== undefined) updates.lockedUntil = parsed.data.lockedUntil ? new Date(parsed.data.lockedUntil) : null\n if (parsed.data.personEntityId !== undefined) updates.personEntityId = parsed.data.personEntityId\n if (parsed.data.customerEntityId !== undefined) updates.customerEntityId = parsed.data.customerEntityId\n\n await em.nativeUpdate(CustomerUser, {\n id: user.id,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n }, updates)\n\n let rolesChanged = false\n if (parsed.data.roleIds !== undefined) {\n const requestedRoleIds = parsed.data.roleIds\n // Scope role resolution to the caller's organization too \u2014 CustomerRole is\n // org-scoped, so a tenant-only filter would let an admin link roles from\n // another org in the same tenant (#2693).\n const validRoles = requestedRoleIds.length > 0\n ? await findWithDecryption(\n em,\n CustomerRole,\n {\n id: { $in: requestedRoleIds } as any,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n deletedAt: null,\n } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n : []\n if (validRoles.length !== requestedRoleIds.length) {\n const foundIds = new Set(validRoles.map((role) => role.id))\n const missingId = requestedRoleIds.find((roleId) => !foundIds.has(roleId))\n return NextResponse.json({ ok: false, error: `Role ${missingId} not found` }, { status: 400 })\n }\n\n await em.nativeDelete(CustomerUserRole, { user: user.id } as Record<string, unknown>)\n\n for (const role of validRoles) {\n const userRole = em.create(CustomerUserRole, {\n user,\n role,\n createdAt: new Date(),\n } as any)\n em.persist(userRole)\n }\n await em.flush()\n rolesChanged = true\n }\n\n if (rolesChanged) {\n const customerRbacService = container.resolve('customerRbacService') as CustomerRbacService\n await customerRbacService.invalidateUserCache(user.id)\n }\n\n void emitCustomerAccountsEvent('customer_accounts.user.updated', {\n id: user.id,\n recipientUserId: user.id,\n email: user.email,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n updatedBy: auth.sub,\n }).catch(() => undefined)\n\n return NextResponse.json({ ok: true, updatedAt: nextUpdatedAt.toISOString() })\n}\n\nexport async function DELETE(req: Request, { params }: { params: { id: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.manage'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n const user = await findOneWithDecryption(\n em,\n CustomerUser,\n { id: params.id, tenantId: auth.tenantId, organizationId: auth.orgId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!user) {\n return NextResponse.json({ ok: false, error: 'User not found' }, { status: 404 })\n }\n\n // Optimistic lock: refuse a stale delete (e.g. deleting a record another admin\n // already modified). Strictly additive \u2014 a no-op without the expected-version header.\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'customer_accounts.user',\n resourceId: user.id,\n current: user.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n const customerUserService = container.resolve('customerUserService') as CustomerUserService\n const customerSessionService = container.resolve('customerSessionService') as CustomerSessionService\n\n await customerUserService.softDelete(user.id, {\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n })\n await customerSessionService.revokeAllUserSessions(user.id)\n\n void emitCustomerAccountsEvent('customer_accounts.user.deleted', {\n id: user.id,\n email: user.email,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n deletedBy: auth.sub,\n }).catch(() => undefined)\n\n return NextResponse.json({ ok: true })\n}\n\nconst roleSchema = z.object({ id: z.string().uuid(), name: z.string(), slug: z.string() })\nconst userDetailSchema = z.object({\n id: z.string().uuid(),\n email: z.string(),\n displayName: z.string(),\n emailVerified: z.boolean(),\n isActive: z.boolean(),\n lockedUntil: z.string().datetime().nullable(),\n lastLoginAt: z.string().datetime().nullable(),\n failedLoginAttempts: z.number(),\n customerEntityId: z.string().uuid().nullable(),\n personEntityId: z.string().uuid().nullable(),\n createdAt: z.string().datetime(),\n updatedAt: z.string().datetime().nullable(),\n roles: z.array(roleSchema),\n activeSessionCount: z.number(),\n})\n\nconst successSchema = z.object({ ok: z.literal(true) })\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst getMethodDoc: OpenApiMethodDoc = {\n summary: 'Get customer user detail (admin)',\n description: 'Returns full customer user details including CRM links, roles, and active session count.',\n tags: ['Customer Accounts Admin'],\n responses: [{\n status: 200,\n description: 'User detail',\n schema: z.object({ ok: z.literal(true), user: userDetailSchema }),\n }],\n errors: [\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 404, description: 'User not found', schema: errorSchema },\n ],\n}\n\nconst putMethodDoc: OpenApiMethodDoc = {\n summary: 'Update customer user (admin)',\n description: 'Updates a customer user. Staff can update status, lock, CRM links, and roles. Role assignment bypasses customer_assignable check.',\n tags: ['Customer Accounts Admin'],\n requestBody: { schema: adminUpdateUserSchema },\n responses: [{ status: 200, description: 'User updated', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed or role not found', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 404, description: 'User not found', schema: errorSchema },\n ],\n}\n\nconst deleteMethodDoc: OpenApiMethodDoc = {\n summary: 'Delete customer user (admin)',\n description: 'Soft deletes a customer user and revokes all their active sessions.',\n tags: ['Customer Accounts Admin'],\n responses: [{ status: 200, description: 'User deleted', schema: successSchema }],\n errors: [\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 404, description: 'User not found', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Customer user detail management (admin)',\n pathParams: z.object({ id: z.string().uuid() }),\n methods: {\n GET: getMethodDoc,\n PUT: putMethodDoc,\n DELETE: deleteMethodDoc,\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { CustomerUser, CustomerUserRole, CustomerRole, CustomerUserSession } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { CustomerUserService } from '@open-mercato/core/modules/customer_accounts/services/customerUserService'\nimport { CustomerSessionService } from '@open-mercato/core/modules/customer_accounts/services/customerSessionService'\nimport { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { adminUpdateUserSchema } from '@open-mercato/core/modules/customer_accounts/data/validators'\nimport { emitCustomerAccountsEvent } from '@open-mercato/core/modules/customer_accounts/events'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isOwnedCompanyEntity, isOwnedPersonEntity } from '@open-mercato/core/modules/customer_accounts/lib/customerEntityOwnership'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\n\nexport const metadata = {}\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nexport async function GET(req: Request, { params }: { params: { id: string } }) {\n if (!UUID_RE.test(params.id)) {\n return NextResponse.json({ ok: false, error: 'Invalid user ID' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.view'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n const user = await findOneWithDecryption(\n em,\n CustomerUser,\n { id: params.id, tenantId: auth.tenantId, organizationId: auth.orgId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!user) {\n return NextResponse.json({ ok: false, error: 'User not found' }, { status: 404 })\n }\n\n const userRoles = await findWithDecryption(\n em,\n CustomerUserRole,\n { user: user.id as any, deletedAt: null } as any,\n { populate: ['role'] },\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n const roles = userRoles.map((ur) => ({\n id: (ur.role as any).id,\n name: (ur.role as any).name,\n slug: (ur.role as any).slug,\n }))\n\n const activeSessions = await findWithDecryption(\n em,\n CustomerUserSession,\n {\n user: user.id as any,\n deletedAt: null,\n expiresAt: { $gt: new Date() },\n } as any,\n { orderBy: { lastUsedAt: 'DESC' } },\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n\n const sessions = activeSessions.map((session) => ({\n id: session.id,\n ipAddress: (session as any).ipAddress || null,\n userAgent: (session as any).userAgent || null,\n lastUsedAt: (session as any).lastUsedAt || null,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n }))\n\n return NextResponse.json({\n ok: true,\n id: user.id,\n email: user.email,\n displayName: user.displayName,\n emailVerifiedAt: user.emailVerifiedAt || null,\n isActive: user.isActive,\n lockedUntil: user.lockedUntil || null,\n lastLoginAt: user.lastLoginAt || null,\n customerEntityId: user.customerEntityId || null,\n personEntityId: user.personEntityId || null,\n createdAt: user.createdAt,\n updatedAt: user.updatedAt || null,\n roles,\n sessions,\n })\n}\n\nexport async function PUT(req: Request, { params }: { params: { id: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.manage'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n let body: unknown\n try {\n body = await req.json()\n } catch {\n return NextResponse.json({ ok: false, error: 'Invalid request body' }, { status: 400 })\n }\n\n const parsed = adminUpdateUserSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json({ ok: false, error: 'Validation failed', details: parsed.error.flatten().fieldErrors }, { status: 400 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n const user = await findOneWithDecryption(\n em,\n CustomerUser,\n { id: params.id, tenantId: auth.tenantId, organizationId: auth.orgId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!user) {\n return NextResponse.json({ ok: false, error: 'User not found' }, { status: 404 })\n }\n\n // Optimistic lock: refuse a stale overwrite so two admins editing the same\n // customer user in parallel cannot silently clobber each other (#2055). The\n // check is strictly additive \u2014 a no-op when the client sends no expected-version header.\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'customer_accounts.user',\n resourceId: user.id,\n current: user.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n // Reject a customerEntityId the caller does not own before persisting it.\n // Without this check a mislinked company FK cross-links the user into another\n // org/company's portal context and persists indefinitely (#2693). A null value\n // (unlink) needs no ownership check.\n if (parsed.data.customerEntityId) {\n const owned = await isOwnedCompanyEntity(em, parsed.data.customerEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Company not found' }, { status: 400 })\n }\n }\n\n // Same guard for the person FK. Without it the invite-side check is trivially\n // bypassable: create the user normally, then PUT an unowned personEntityId.\n // It persists (autoLinkCrm short-circuits on any non-null value) and leaks\n // account status into the other org's people list via the account-status\n // enricher. A null value (unlink) needs no ownership check.\n if (parsed.data.personEntityId) {\n const owned = await isOwnedPersonEntity(em, parsed.data.personEntityId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!owned) {\n return NextResponse.json({ ok: false, error: 'Person not found' }, { status: 400 })\n }\n }\n\n // `display_name` is encrypted at rest, and `nativeUpdate` skips the flush hooks the\n // tenant-encryption subscriber relies on, so persisting it below would write plaintext PII\n // into a ciphertext column (#3837). Route it through the service, which writes it via the\n // managed entity. Runs before the `nativeUpdate` so the explicit `updated_at` below stays\n // the value this response reports back as the optimistic-lock version.\n if (parsed.data.displayName !== undefined) {\n const customerUserService = container.resolve('customerUserService') as CustomerUserService\n await customerUserService.updateProfile(user, { displayName: parsed.data.displayName })\n }\n\n // Always bump updated_at so the optimistic-lock version advances on every save.\n // `nativeUpdate` bypasses MikroORM's `onUpdate` hook, so set it explicitly \u2014 without\n // this the version never changes and concurrent edits cannot be detected (#2055).\n const nextUpdatedAt = new Date()\n const updates: Record<string, unknown> = { updatedAt: nextUpdatedAt }\n if (parsed.data.isActive !== undefined) updates.isActive = parsed.data.isActive\n if (parsed.data.lockedUntil !== undefined) updates.lockedUntil = parsed.data.lockedUntil ? new Date(parsed.data.lockedUntil) : null\n if (parsed.data.personEntityId !== undefined) updates.personEntityId = parsed.data.personEntityId\n if (parsed.data.customerEntityId !== undefined) updates.customerEntityId = parsed.data.customerEntityId\n\n await em.nativeUpdate(CustomerUser, {\n id: user.id,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n }, updates)\n\n let rolesChanged = false\n if (parsed.data.roleIds !== undefined) {\n const requestedRoleIds = parsed.data.roleIds\n // Scope role resolution to the caller's organization too \u2014 CustomerRole is\n // org-scoped, so a tenant-only filter would let an admin link roles from\n // another org in the same tenant (#2693).\n const validRoles = requestedRoleIds.length > 0\n ? await findWithDecryption(\n em,\n CustomerRole,\n {\n id: { $in: requestedRoleIds } as any,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n deletedAt: null,\n } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n : []\n if (validRoles.length !== requestedRoleIds.length) {\n const foundIds = new Set(validRoles.map((role) => role.id))\n const missingId = requestedRoleIds.find((roleId) => !foundIds.has(roleId))\n return NextResponse.json({ ok: false, error: `Role ${missingId} not found` }, { status: 400 })\n }\n\n await em.nativeDelete(CustomerUserRole, { user: user.id } as Record<string, unknown>)\n\n for (const role of validRoles) {\n const userRole = em.create(CustomerUserRole, {\n user,\n role,\n createdAt: new Date(),\n } as any)\n em.persist(userRole)\n }\n await em.flush()\n rolesChanged = true\n }\n\n if (rolesChanged) {\n const customerRbacService = container.resolve('customerRbacService') as CustomerRbacService\n await customerRbacService.invalidateUserCache(user.id)\n }\n\n void emitCustomerAccountsEvent('customer_accounts.user.updated', {\n id: user.id,\n recipientUserId: user.id,\n email: user.email,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n updatedBy: auth.sub,\n }).catch(() => undefined)\n\n return NextResponse.json({ ok: true, updatedAt: nextUpdatedAt.toISOString() })\n}\n\nexport async function DELETE(req: Request, { params }: { params: { id: string } }) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbacService = container.resolve('rbacService') as RbacService\n const hasAccess = await rbacService.userHasAllFeatures(auth.sub, ['customer_accounts.manage'], { tenantId: auth.tenantId, organizationId: auth.orgId })\n if (!hasAccess) {\n return NextResponse.json({ ok: false, error: 'Insufficient permissions' }, { status: 403 })\n }\n\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n\n const user = await findOneWithDecryption(\n em,\n CustomerUser,\n { id: params.id, tenantId: auth.tenantId, organizationId: auth.orgId, deletedAt: null } as any,\n undefined,\n { tenantId: auth.tenantId, organizationId: auth.orgId },\n )\n if (!user) {\n return NextResponse.json({ ok: false, error: 'User not found' }, { status: 404 })\n }\n\n // Optimistic lock: refuse a stale delete (e.g. deleting a record another admin\n // already modified). Strictly additive \u2014 a no-op without the expected-version header.\n try {\n await enforceCommandOptimisticLockWithGuards(container, {\n resourceKind: 'customer_accounts.user',\n resourceId: user.id,\n current: user.updatedAt ?? null,\n request: req,\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n\n const customerUserService = container.resolve('customerUserService') as CustomerUserService\n const customerSessionService = container.resolve('customerSessionService') as CustomerSessionService\n\n await customerUserService.softDelete(user.id, {\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n })\n await customerSessionService.revokeAllUserSessions(user.id)\n\n void emitCustomerAccountsEvent('customer_accounts.user.deleted', {\n id: user.id,\n email: user.email,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n deletedBy: auth.sub,\n }).catch(() => undefined)\n\n return NextResponse.json({ ok: true })\n}\n\nconst roleSchema = z.object({ id: z.string().uuid(), name: z.string(), slug: z.string() })\nconst userDetailSchema = z.object({\n id: z.string().uuid(),\n email: z.string(),\n displayName: z.string(),\n emailVerified: z.boolean(),\n isActive: z.boolean(),\n lockedUntil: z.string().datetime().nullable(),\n lastLoginAt: z.string().datetime().nullable(),\n failedLoginAttempts: z.number(),\n customerEntityId: z.string().uuid().nullable(),\n personEntityId: z.string().uuid().nullable(),\n createdAt: z.string().datetime(),\n updatedAt: z.string().datetime().nullable(),\n roles: z.array(roleSchema),\n activeSessionCount: z.number(),\n})\n\nconst successSchema = z.object({ ok: z.literal(true) })\nconst errorSchema = z.object({ ok: z.literal(false), error: z.string() })\n\nconst getMethodDoc: OpenApiMethodDoc = {\n summary: 'Get customer user detail (admin)',\n description: 'Returns full customer user details including CRM links, roles, and active session count.',\n tags: ['Customer Accounts Admin'],\n responses: [{\n status: 200,\n description: 'User detail',\n schema: z.object({ ok: z.literal(true), user: userDetailSchema }),\n }],\n errors: [\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 404, description: 'User not found', schema: errorSchema },\n ],\n}\n\nconst putMethodDoc: OpenApiMethodDoc = {\n summary: 'Update customer user (admin)',\n description: 'Updates a customer user. Staff can update status, lock, CRM links, and roles. Role assignment bypasses customer_assignable check.',\n tags: ['Customer Accounts Admin'],\n requestBody: { schema: adminUpdateUserSchema },\n responses: [{ status: 200, description: 'User updated', schema: successSchema }],\n errors: [\n { status: 400, description: 'Validation failed or role not found', schema: errorSchema },\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 404, description: 'User not found', schema: errorSchema },\n ],\n}\n\nconst deleteMethodDoc: OpenApiMethodDoc = {\n summary: 'Delete customer user (admin)',\n description: 'Soft deletes a customer user and revokes all their active sessions.',\n tags: ['Customer Accounts Admin'],\n responses: [{ status: 200, description: 'User deleted', schema: successSchema }],\n errors: [\n { status: 401, description: 'Not authenticated', schema: errorSchema },\n { status: 403, description: 'Insufficient permissions', schema: errorSchema },\n { status: 404, description: 'User not found', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Customer user detail management (admin)',\n pathParams: z.object({ id: z.string().uuid() }),\n methods: {\n GET: getMethodDoc,\n PUT: putMethodDoc,\n DELETE: deleteMethodDoc,\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -90,12 +90,23 @@ class CustomerUserService {
90
90
  }, { passwordHash });
91
91
  user.passwordHash = passwordHash;
92
92
  }
93
+ // `display_name` is encrypted at rest. `nativeUpdate` issues raw SQL and fires none of the
94
+ // flush hooks the tenant-encryption subscriber depends on, so writing it that way persists
95
+ // plaintext PII into a ciphertext column (#3837). Assign it on the managed entity and flush
96
+ // so `beforeUpdate` encrypts the value on its way to the database.
93
97
  async updateProfile(user, data) {
94
- const updates = {};
95
- if (data.displayName !== void 0) updates.displayName = data.displayName;
96
- if (Object.keys(updates).length === 0) return;
97
- await this.em.nativeUpdate(CustomerUser, { id: user.id }, updates);
98
- if (data.displayName !== void 0) user.displayName = data.displayName;
98
+ if (data.displayName === void 0) return;
99
+ const managed = await findOneWithDecryption(
100
+ this.em,
101
+ CustomerUser,
102
+ { id: user.id, tenantId: user.tenantId, organizationId: user.organizationId, deletedAt: null },
103
+ void 0,
104
+ { tenantId: user.tenantId, organizationId: user.organizationId }
105
+ );
106
+ if (!managed) return;
107
+ managed.displayName = data.displayName;
108
+ await this.em.flush();
109
+ user.displayName = data.displayName;
99
110
  }
100
111
  async softDelete(userId, scope) {
101
112
  const where = { id: userId };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/customer_accounts/services/customerUserService.ts"],
4
- "sourcesContent": ["import { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport { CustomerUser } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nconst BCRYPT_COST = 10\nconst MAX_FAILED_ATTEMPTS = 5\nconst LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes\n\nexport class CustomerUserService {\n constructor(private em: EntityManager) {}\n\n async createUser(\n email: string,\n password: string,\n displayName: string,\n scope: { tenantId: string; organizationId: string },\n ): Promise<CustomerUser> {\n const passwordHash = await hash(password, BCRYPT_COST)\n const emailHash = hashForLookup(email)\n const user = this.em.create(CustomerUser, {\n email: email.toLowerCase().trim(),\n emailHash,\n passwordHash,\n displayName,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n isActive: true,\n failedLoginAttempts: 0,\n createdAt: new Date(),\n } as any)\n return user as CustomerUser\n }\n\n async findByEmail(email: string, tenantId: string): Promise<CustomerUser | null> {\n return findOneWithDecryption(\n this.em,\n CustomerUser,\n {\n emailHash: { $in: lookupHashCandidates(email) },\n tenantId,\n deletedAt: null,\n } as any,\n undefined,\n { tenantId },\n )\n }\n\n async findById(\n id: string,\n tenantId: string,\n organizationId?: string | null,\n ): Promise<CustomerUser | null> {\n const where: Record<string, unknown> = { id, tenantId, deletedAt: null }\n if (organizationId !== undefined) where.organizationId = organizationId\n return findOneWithDecryption(\n this.em,\n CustomerUser,\n where as any,\n undefined,\n { tenantId, organizationId },\n )\n }\n\n async verifyPassword(user: CustomerUser, password: string): Promise<boolean> {\n if (!user.passwordHash) return false\n return compare(password, user.passwordHash)\n }\n\n async updateLastLoginAt(user: CustomerUser): Promise<void> {\n const now = new Date()\n await this.em.nativeUpdate(CustomerUser, { id: user.id }, { lastLoginAt: now })\n user.lastLoginAt = now\n }\n\n checkLockout(user: CustomerUser): boolean {\n if (!user.lockedUntil) return false\n if (user.lockedUntil.getTime() > Date.now()) return true\n return false\n }\n\n async incrementFailedAttempts(user: CustomerUser): Promise<void> {\n const newCount = (user.failedLoginAttempts || 0) + 1\n const updates: Record<string, unknown> = { failedLoginAttempts: newCount }\n if (newCount >= MAX_FAILED_ATTEMPTS) {\n updates.lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS)\n }\n await this.em.nativeUpdate(CustomerUser, { id: user.id }, updates)\n user.failedLoginAttempts = newCount\n if (updates.lockedUntil) user.lockedUntil = updates.lockedUntil as Date\n }\n\n async resetFailedAttempts(user: CustomerUser): Promise<void> {\n await this.em.nativeUpdate(CustomerUser, { id: user.id }, {\n failedLoginAttempts: 0,\n lockedUntil: null,\n })\n user.failedLoginAttempts = 0\n user.lockedUntil = null\n }\n\n async updatePassword(user: CustomerUser, newPassword: string, em?: EntityManager): Promise<void> {\n const passwordHash = await hash(newPassword, BCRYPT_COST)\n await (em ?? this.em).nativeUpdate(CustomerUser, {\n id: user.id,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n }, { passwordHash })\n user.passwordHash = passwordHash\n }\n\n async updateProfile(user: CustomerUser, data: { displayName?: string }): Promise<void> {\n const updates: Record<string, unknown> = {}\n if (data.displayName !== undefined) updates.displayName = data.displayName\n if (Object.keys(updates).length === 0) return\n await this.em.nativeUpdate(CustomerUser, { id: user.id }, updates)\n if (data.displayName !== undefined) user.displayName = data.displayName\n }\n\n async softDelete(\n userId: string,\n scope?: { tenantId: string; organizationId: string | null },\n ): Promise<void> {\n const where: Record<string, unknown> = { id: userId }\n if (scope) {\n where.tenantId = scope.tenantId\n where.organizationId = scope.organizationId\n }\n await this.em.nativeUpdate(CustomerUser, where, {\n deletedAt: new Date(),\n isActive: false,\n })\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport { CustomerUser } from '@open-mercato/core/modules/customer_accounts/data/entities'\nimport { hashForLookup, lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nconst BCRYPT_COST = 10\nconst MAX_FAILED_ATTEMPTS = 5\nconst LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes\n\nexport class CustomerUserService {\n constructor(private em: EntityManager) {}\n\n async createUser(\n email: string,\n password: string,\n displayName: string,\n scope: { tenantId: string; organizationId: string },\n ): Promise<CustomerUser> {\n const passwordHash = await hash(password, BCRYPT_COST)\n const emailHash = hashForLookup(email)\n const user = this.em.create(CustomerUser, {\n email: email.toLowerCase().trim(),\n emailHash,\n passwordHash,\n displayName,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n isActive: true,\n failedLoginAttempts: 0,\n createdAt: new Date(),\n } as any)\n return user as CustomerUser\n }\n\n async findByEmail(email: string, tenantId: string): Promise<CustomerUser | null> {\n return findOneWithDecryption(\n this.em,\n CustomerUser,\n {\n emailHash: { $in: lookupHashCandidates(email) },\n tenantId,\n deletedAt: null,\n } as any,\n undefined,\n { tenantId },\n )\n }\n\n async findById(\n id: string,\n tenantId: string,\n organizationId?: string | null,\n ): Promise<CustomerUser | null> {\n const where: Record<string, unknown> = { id, tenantId, deletedAt: null }\n if (organizationId !== undefined) where.organizationId = organizationId\n return findOneWithDecryption(\n this.em,\n CustomerUser,\n where as any,\n undefined,\n { tenantId, organizationId },\n )\n }\n\n async verifyPassword(user: CustomerUser, password: string): Promise<boolean> {\n if (!user.passwordHash) return false\n return compare(password, user.passwordHash)\n }\n\n async updateLastLoginAt(user: CustomerUser): Promise<void> {\n const now = new Date()\n await this.em.nativeUpdate(CustomerUser, { id: user.id }, { lastLoginAt: now })\n user.lastLoginAt = now\n }\n\n checkLockout(user: CustomerUser): boolean {\n if (!user.lockedUntil) return false\n if (user.lockedUntil.getTime() > Date.now()) return true\n return false\n }\n\n async incrementFailedAttempts(user: CustomerUser): Promise<void> {\n const newCount = (user.failedLoginAttempts || 0) + 1\n const updates: Record<string, unknown> = { failedLoginAttempts: newCount }\n if (newCount >= MAX_FAILED_ATTEMPTS) {\n updates.lockedUntil = new Date(Date.now() + LOCKOUT_DURATION_MS)\n }\n await this.em.nativeUpdate(CustomerUser, { id: user.id }, updates)\n user.failedLoginAttempts = newCount\n if (updates.lockedUntil) user.lockedUntil = updates.lockedUntil as Date\n }\n\n async resetFailedAttempts(user: CustomerUser): Promise<void> {\n await this.em.nativeUpdate(CustomerUser, { id: user.id }, {\n failedLoginAttempts: 0,\n lockedUntil: null,\n })\n user.failedLoginAttempts = 0\n user.lockedUntil = null\n }\n\n async updatePassword(user: CustomerUser, newPassword: string, em?: EntityManager): Promise<void> {\n const passwordHash = await hash(newPassword, BCRYPT_COST)\n await (em ?? this.em).nativeUpdate(CustomerUser, {\n id: user.id,\n tenantId: user.tenantId,\n organizationId: user.organizationId,\n }, { passwordHash })\n user.passwordHash = passwordHash\n }\n\n // `display_name` is encrypted at rest. `nativeUpdate` issues raw SQL and fires none of the\n // flush hooks the tenant-encryption subscriber depends on, so writing it that way persists\n // plaintext PII into a ciphertext column (#3837). Assign it on the managed entity and flush\n // so `beforeUpdate` encrypts the value on its way to the database.\n async updateProfile(user: CustomerUser, data: { displayName?: string }): Promise<void> {\n if (data.displayName === undefined) return\n const managed = await findOneWithDecryption(\n this.em,\n CustomerUser,\n { id: user.id, tenantId: user.tenantId, organizationId: user.organizationId, deletedAt: null } as any,\n undefined,\n { tenantId: user.tenantId, organizationId: user.organizationId },\n )\n if (!managed) return\n managed.displayName = data.displayName\n await this.em.flush()\n user.displayName = data.displayName\n }\n\n async softDelete(\n userId: string,\n scope?: { tenantId: string; organizationId: string | null },\n ): Promise<void> {\n const where: Record<string, unknown> = { id: userId }\n if (scope) {\n where.tenantId = scope.tenantId\n where.organizationId = scope.organizationId\n }\n await this.em.nativeUpdate(CustomerUser, where, {\n deletedAt: new Date(),\n isActive: false,\n })\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -155,6 +155,7 @@ const openApi = {
155
155
  errors: [
156
156
  { status: 400, description: "Invalid query parameters", schema: widgetErrorSchema },
157
157
  { status: 401, description: "Unauthorized", schema: widgetErrorSchema },
158
+ { status: 403, description: "Requested scope is not accessible", schema: widgetErrorSchema },
158
159
  { status: 500, description: "Widget failed to load", schema: widgetErrorSchema }
159
160
  ]
160
161
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/customers/api/dashboard/widgets/customer-todos/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport { resolveCustomerInteractionFeatureFlags } from '../../../../lib/interactionFeatureFlags'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n listLegacyTodoRows,\n listCanonicalTodoRows,\n sortTodoRows,\n type CustomerTodoRow,\n} from '../../../../lib/todoCompatibility'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.todos'] },\n}\n\ntype WidgetContext = WidgetScopeContext & { limit: number }\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n rawQuery[key] = value\n }\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { container, em, tenantId, organizationIds, limit } = await resolveContext(req, translate)\n const auth = {\n tenantId,\n orgId: organizationIds?.[0] ?? null,\n sub: 'customers.dashboard.todos',\n }\n const flags = await resolveCustomerInteractionFeatureFlags(container, tenantId)\n const mergedWindow = Math.min(limit * 4, 50)\n const rows = flags.unified\n ? (await listCanonicalTodoRows(\n em,\n container,\n auth,\n organizationIds?.[0] ?? null,\n organizationIds ?? null,\n { pagination: { page: 1, pageSize: limit } },\n )).items\n : await Promise.all([\n listLegacyTodoRows(\n em,\n container.resolve('queryEngine') as QueryEngine,\n tenantId,\n organizationIds ?? null,\n undefined,\n { limit: mergedWindow },\n ),\n listCanonicalTodoRows(\n em,\n container,\n auth,\n organizationIds?.[0] ?? null,\n organizationIds ?? null,\n {\n includeDeleted: true,\n limit: mergedWindow,\n },\n ),\n ]).then(([legacyRows, canonicalRows]) =>\n sortTodoRows([\n ...legacyRows.filter((row) => !canonicalRows.bridgeIds.has(row.todoId)),\n ...canonicalRows.items,\n ]),\n )\n\n const items = rows.slice(0, limit).map((row: CustomerTodoRow) => {\n const entity = row.customer ?? null\n return {\n id: row.id,\n todoId: row.todoId,\n todoSource: row.todoSource,\n todoTitle: row.todoTitle ?? null,\n createdAt: row.createdAt,\n organizationId: row.organizationId ?? null,\n _integrations: row._integrations ?? undefined,\n entity: entity?.id\n ? {\n id: entity.id,\n displayName: entity.displayName ?? null,\n kind: entity.kind ?? null,\n ownerUserId: null,\n }\n : {\n id: null,\n displayName: null,\n kind: null,\n ownerUserId: null,\n },\n }\n })\n\n return NextResponse.json({ items })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.todos failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.todos.error', 'Failed to load customer tasks') },\n { status: 500 }\n )\n }\n}\n\nconst customerTodoWidgetItemSchema = z.object({\n id: z.string().uuid(),\n todoId: z.string().uuid(),\n todoSource: z.string(),\n todoTitle: z.string().nullable().optional(),\n createdAt: z.string(),\n _integrations: z.record(z.string(), z.unknown()).optional(),\n organizationId: z.string().uuid().nullable().optional(),\n entity: z\n .object({\n id: z.string().uuid().nullable(),\n displayName: z.string().nullable(),\n kind: z.string().nullable(),\n ownerUserId: z.string().uuid().nullable().optional(),\n })\n .passthrough(),\n})\n\nconst customerTodoWidgetResponseSchema = z.object({\n items: z.array(customerTodoWidgetItemSchema),\n})\n\nconst widgetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Customer todos widget',\n methods: {\n GET: {\n summary: 'Fetch recent customer tasks',\n description: 'Returns the most recent customer tasks for display on dashboards, including legacy compatibility rows when needed.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Widget payload', schema: customerTodoWidgetResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport { resolveCustomerInteractionFeatureFlags } from '../../../../lib/interactionFeatureFlags'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n listLegacyTodoRows,\n listCanonicalTodoRows,\n sortTodoRows,\n type CustomerTodoRow,\n} from '../../../../lib/todoCompatibility'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.todos'] },\n}\n\ntype WidgetContext = WidgetScopeContext & { limit: number }\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n rawQuery[key] = value\n }\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { container, em, tenantId, organizationIds, limit } = await resolveContext(req, translate)\n const auth = {\n tenantId,\n orgId: organizationIds?.[0] ?? null,\n sub: 'customers.dashboard.todos',\n }\n const flags = await resolveCustomerInteractionFeatureFlags(container, tenantId)\n const mergedWindow = Math.min(limit * 4, 50)\n const rows = flags.unified\n ? (await listCanonicalTodoRows(\n em,\n container,\n auth,\n organizationIds?.[0] ?? null,\n organizationIds ?? null,\n { pagination: { page: 1, pageSize: limit } },\n )).items\n : await Promise.all([\n listLegacyTodoRows(\n em,\n container.resolve('queryEngine') as QueryEngine,\n tenantId,\n organizationIds ?? null,\n undefined,\n { limit: mergedWindow },\n ),\n listCanonicalTodoRows(\n em,\n container,\n auth,\n organizationIds?.[0] ?? null,\n organizationIds ?? null,\n {\n includeDeleted: true,\n limit: mergedWindow,\n },\n ),\n ]).then(([legacyRows, canonicalRows]) =>\n sortTodoRows([\n ...legacyRows.filter((row) => !canonicalRows.bridgeIds.has(row.todoId)),\n ...canonicalRows.items,\n ]),\n )\n\n const items = rows.slice(0, limit).map((row: CustomerTodoRow) => {\n const entity = row.customer ?? null\n return {\n id: row.id,\n todoId: row.todoId,\n todoSource: row.todoSource,\n todoTitle: row.todoTitle ?? null,\n createdAt: row.createdAt,\n organizationId: row.organizationId ?? null,\n _integrations: row._integrations ?? undefined,\n entity: entity?.id\n ? {\n id: entity.id,\n displayName: entity.displayName ?? null,\n kind: entity.kind ?? null,\n ownerUserId: null,\n }\n : {\n id: null,\n displayName: null,\n kind: null,\n ownerUserId: null,\n },\n }\n })\n\n return NextResponse.json({ items })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.todos failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.todos.error', 'Failed to load customer tasks') },\n { status: 500 }\n )\n }\n}\n\nconst customerTodoWidgetItemSchema = z.object({\n id: z.string().uuid(),\n todoId: z.string().uuid(),\n todoSource: z.string(),\n todoTitle: z.string().nullable().optional(),\n createdAt: z.string(),\n _integrations: z.record(z.string(), z.unknown()).optional(),\n organizationId: z.string().uuid().nullable().optional(),\n entity: z\n .object({\n id: z.string().uuid().nullable(),\n displayName: z.string().nullable(),\n kind: z.string().nullable(),\n ownerUserId: z.string().uuid().nullable().optional(),\n })\n .passthrough(),\n})\n\nconst customerTodoWidgetResponseSchema = z.object({\n items: z.array(customerTodoWidgetItemSchema),\n})\n\nconst widgetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Customer todos widget',\n methods: {\n GET: {\n summary: 'Fetch recent customer tasks',\n description: 'Returns the most recent customer tasks for display on dashboards, including legacy compatibility rows when needed.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Widget payload', schema: customerTodoWidgetResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -102,6 +102,7 @@ const openApi = {
102
102
  errors: [
103
103
  { status: 400, description: "Invalid query parameters", schema: widgetErrorSchema },
104
104
  { status: 401, description: "Unauthorized", schema: widgetErrorSchema },
105
+ { status: 403, description: "Requested scope is not accessible", schema: widgetErrorSchema },
105
106
  { status: 500, description: "Widget failed to load", schema: widgetErrorSchema }
106
107
  ]
107
108
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/customers/api/dashboard/widgets/new-customers/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerEntity, type CustomerEntityKind } from '../../../../data/entities'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n kind: z.enum(['person', 'company']).optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.new-customers'] },\n}\n\ntype WidgetContext = WidgetScopeContext & {\n limit: number\n kind: CustomerEntityKind | null\n}\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n rawQuery[key] = value\n }\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n kind: parsed.data.kind ?? null,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { em, tenantId, organizationIds, limit, kind } = await resolveContext(req, translate)\n\n const where: FilterQuery<CustomerEntity> = {\n tenantId,\n deletedAt: null,\n }\n if (Array.isArray(organizationIds)) {\n where.organizationId =\n organizationIds.length === 1 ? organizationIds[0] : { $in: Array.from(new Set(organizationIds)) }\n }\n if (kind) where.kind = kind\n\n const entities = await em.find(CustomerEntity, where, {\n limit,\n orderBy: { createdAt: 'desc' as const },\n })\n\n const items = entities.map((entity) => ({\n id: entity.id,\n displayName: entity.displayName,\n kind: entity.kind,\n organizationId: entity.organizationId,\n createdAt: entity.createdAt.toISOString(),\n ownerUserId: entity.ownerUserId ?? null,\n }))\n\n return NextResponse.json({ items })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.newCustomers failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.newCustomers.error', 'Failed to load recently added customers') },\n { status: 500 }\n )\n }\n}\n\nconst newCustomersItemSchema = z.object({\n id: z.string().uuid(),\n displayName: z.string().nullable().optional(),\n kind: z.string().nullable().optional(),\n organizationId: z.string().uuid().nullable().optional(),\n createdAt: z.string(),\n ownerUserId: z.string().uuid().nullable().optional(),\n})\n\nconst newCustomersResponseSchema = z.object({\n items: z.array(newCustomersItemSchema),\n})\n\nconst widgetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'New customers widget',\n methods: {\n GET: {\n summary: 'Fetch recently created customers',\n description: 'Returns the latest customers created within the scoped tenant/organization for dashboard display.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Widget payload', schema: newCustomersResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerEntity, type CustomerEntityKind } from '../../../../data/entities'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n kind: z.enum(['person', 'company']).optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.new-customers'] },\n}\n\ntype WidgetContext = WidgetScopeContext & {\n limit: number\n kind: CustomerEntityKind | null\n}\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n rawQuery[key] = value\n }\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n kind: parsed.data.kind ?? null,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { em, tenantId, organizationIds, limit, kind } = await resolveContext(req, translate)\n\n const where: FilterQuery<CustomerEntity> = {\n tenantId,\n deletedAt: null,\n }\n if (Array.isArray(organizationIds)) {\n where.organizationId =\n organizationIds.length === 1 ? organizationIds[0] : { $in: Array.from(new Set(organizationIds)) }\n }\n if (kind) where.kind = kind\n\n const entities = await em.find(CustomerEntity, where, {\n limit,\n orderBy: { createdAt: 'desc' as const },\n })\n\n const items = entities.map((entity) => ({\n id: entity.id,\n displayName: entity.displayName,\n kind: entity.kind,\n organizationId: entity.organizationId,\n createdAt: entity.createdAt.toISOString(),\n ownerUserId: entity.ownerUserId ?? null,\n }))\n\n return NextResponse.json({ items })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.newCustomers failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.newCustomers.error', 'Failed to load recently added customers') },\n { status: 500 }\n )\n }\n}\n\nconst newCustomersItemSchema = z.object({\n id: z.string().uuid(),\n displayName: z.string().nullable().optional(),\n kind: z.string().nullable().optional(),\n organizationId: z.string().uuid().nullable().optional(),\n createdAt: z.string(),\n ownerUserId: z.string().uuid().nullable().optional(),\n})\n\nconst newCustomersResponseSchema = z.object({\n items: z.array(newCustomersItemSchema),\n})\n\nconst widgetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'New customers widget',\n methods: {\n GET: {\n summary: 'Fetch recently created customers',\n description: 'Returns the latest customers created within the scoped tenant/organization for dashboard display.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Widget payload', schema: newCustomersResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -97,6 +97,7 @@ const openApi = {
97
97
  errors: [
98
98
  { status: 400, description: "Invalid query parameters", schema: widgetErrorSchema },
99
99
  { status: 401, description: "Unauthorized", schema: widgetErrorSchema },
100
+ { status: 403, description: "Requested scope is not accessible", schema: widgetErrorSchema },
100
101
  { status: 500, description: "Widget failed to load", schema: widgetErrorSchema }
101
102
  ]
102
103
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/customers/api/dashboard/widgets/new-deals/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerDeal } from '../../../../data/entities'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.new-deals'] },\n}\n\ntype WidgetContext = WidgetScopeContext & {\n limit: number\n}\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) rawQuery[key] = value\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { em, tenantId, organizationIds, limit } = await resolveContext(req, translate)\n\n const where: FilterQuery<CustomerDeal> = {\n tenantId,\n deletedAt: null,\n }\n if (Array.isArray(organizationIds)) {\n where.organizationId = organizationIds.length === 1 ? organizationIds[0] : { $in: Array.from(new Set(organizationIds)) }\n }\n\n const deals = await em.find(CustomerDeal, where, {\n limit,\n orderBy: { createdAt: 'desc' as const },\n })\n\n const items = deals.map((deal) => ({\n id: deal.id,\n title: deal.title,\n status: deal.status,\n organizationId: deal.organizationId,\n createdAt: deal.createdAt.toISOString(),\n ownerUserId: deal.ownerUserId ?? null,\n valueAmount: deal.valueAmount ?? null,\n valueCurrency: deal.valueCurrency ?? null,\n }))\n\n return NextResponse.json({ items })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.newDeals failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.newDeals.error', 'Failed to load recently created deals') },\n { status: 500 },\n )\n }\n}\n\nconst newDealsItemSchema = z.object({\n id: z.string().uuid(),\n title: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n organizationId: z.string().uuid().nullable().optional(),\n createdAt: z.string(),\n ownerUserId: z.string().uuid().nullable().optional(),\n valueAmount: z.string().nullable().optional(),\n valueCurrency: z.string().nullable().optional(),\n})\n\nconst newDealsResponseSchema = z.object({\n items: z.array(newDealsItemSchema),\n})\n\nconst widgetErrorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'New deals widget',\n methods: {\n GET: {\n summary: 'Fetch recently created deals',\n description: 'Returns the latest deals created within the scoped tenant/organization for dashboard display.',\n query: querySchema,\n responses: [{ status: 200, description: 'Widget payload', schema: newDealsResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,2BAA2B;AACpC,SAAS,eAAe,uBAAuB;AAC/C,SAAS,oBAAoB;AAC7B,SAAS,0BAAmD;AAG5D,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW;AAEvC,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,OAAO,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,CAAC;AAAA,EACjD,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACrC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAC7C,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,6BAA6B,EAAE;AAChG;AAMA,eAAe,eAAe,KAAc,WAA+E;AACzH,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,WAAmC,CAAC;AAC1C,aAAW,CAAC,KAAK,KAAK,KAAK,IAAI,aAAa,QAAQ,EAAG,UAAS,GAAG,IAAI;AACvE,QAAM,SAAS,YAAY,UAAU,QAAQ;AAC7C,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,kCAAkC,0BAA0B,EAAE,CAAC;AAAA,EACjH;AAEA,QAAM,EAAE,WAAW,IAAI,UAAU,gBAAgB,IAAI,MAAM,mBAAmB,KAAK,WAAW;AAAA,IAC5F,UAAU,OAAO,KAAK,YAAY;AAAA,IAClC,gBAAgB,OAAO,KAAK,kBAAkB;AAAA,EAChD,CAAC;AAED,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,OAAO,KAAK;AAAA,EACrB;AACF;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,MAAI;AACF,UAAM,EAAE,IAAI,UAAU,iBAAiB,MAAM,IAAI,MAAM,eAAe,KAAK,SAAS;AAEpF,UAAM,QAAmC;AAAA,MACvC;AAAA,MACA,WAAW;AAAA,IACb;AACA,QAAI,MAAM,QAAQ,eAAe,GAAG;AAClC,YAAM,iBAAiB,gBAAgB,WAAW,IAAI,gBAAgB,CAAC,IAAI,EAAE,KAAK,MAAM,KAAK,IAAI,IAAI,eAAe,CAAC,EAAE;AAAA,IACzH;AAEA,UAAM,QAAQ,MAAM,GAAG,KAAK,cAAc,OAAO;AAAA,MAC/C;AAAA,MACA,SAAS,EAAE,WAAW,OAAgB;AAAA,IACxC,CAAC;AAED,UAAM,QAAQ,MAAM,IAAI,CAAC,UAAU;AAAA,MACjC,IAAI,KAAK;AAAA,MACT,OAAO,KAAK;AAAA,MACZ,QAAQ,KAAK;AAAA,MACb,gBAAgB,KAAK;AAAA,MACrB,WAAW,KAAK,UAAU,YAAY;AAAA,MACtC,aAAa,KAAK,eAAe;AAAA,MACjC,aAAa,KAAK,eAAe;AAAA,MACjC,eAAe,KAAK,iBAAiB;AAAA,IACvC,EAAE;AAEF,WAAO,aAAa,KAAK,EAAE,MAAM,CAAC;AAAA,EACpC,SAAS,KAAK;AACZ,QAAI,gBAAgB,GAAG,GAAG;AACxB,aAAO,aAAa,KAAK,IAAI,MAAM,EAAE,QAAQ,IAAI,OAAO,CAAC;AAAA,IAC3D;AACA,WAAO,MAAM,qCAAqC,EAAE,IAAI,CAAC;AACzD,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,UAAU,oCAAoC,uCAAuC,EAAE;AAAA,MAChG,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAEA,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,EACpB,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACtC,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACvC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACtD,WAAW,EAAE,OAAO;AAAA,EACpB,aAAa,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACnD,aAAa,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC5C,eAAe,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAChD,CAAC;AAED,MAAM,yBAAyB,EAAE,OAAO;AAAA,EACtC,OAAO,EAAE,MAAM,kBAAkB;AACnC,CAAC;AAED,MAAM,oBAAoB,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAEjD,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,MACP,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,kBAAkB,QAAQ,uBAAuB,CAAC;AAAA,MAC1F,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,kBAAkB;AAAA,QAClF,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,kBAAkB;AAAA,QACtE,EAAE,QAAQ,KAAK,aAAa,yBAAyB,QAAQ,kBAAkB;AAAA,MACjF;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerDeal } from '../../../../data/entities'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.new-deals'] },\n}\n\ntype WidgetContext = WidgetScopeContext & {\n limit: number\n}\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) rawQuery[key] = value\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { em, tenantId, organizationIds, limit } = await resolveContext(req, translate)\n\n const where: FilterQuery<CustomerDeal> = {\n tenantId,\n deletedAt: null,\n }\n if (Array.isArray(organizationIds)) {\n where.organizationId = organizationIds.length === 1 ? organizationIds[0] : { $in: Array.from(new Set(organizationIds)) }\n }\n\n const deals = await em.find(CustomerDeal, where, {\n limit,\n orderBy: { createdAt: 'desc' as const },\n })\n\n const items = deals.map((deal) => ({\n id: deal.id,\n title: deal.title,\n status: deal.status,\n organizationId: deal.organizationId,\n createdAt: deal.createdAt.toISOString(),\n ownerUserId: deal.ownerUserId ?? null,\n valueAmount: deal.valueAmount ?? null,\n valueCurrency: deal.valueCurrency ?? null,\n }))\n\n return NextResponse.json({ items })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.newDeals failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.newDeals.error', 'Failed to load recently created deals') },\n { status: 500 },\n )\n }\n}\n\nconst newDealsItemSchema = z.object({\n id: z.string().uuid(),\n title: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n organizationId: z.string().uuid().nullable().optional(),\n createdAt: z.string(),\n ownerUserId: z.string().uuid().nullable().optional(),\n valueAmount: z.string().nullable().optional(),\n valueCurrency: z.string().nullable().optional(),\n})\n\nconst newDealsResponseSchema = z.object({\n items: z.array(newDealsItemSchema),\n})\n\nconst widgetErrorSchema = z.object({ error: z.string() })\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'New deals widget',\n methods: {\n GET: {\n summary: 'Fetch recently created deals',\n description: 'Returns the latest deals created within the scoped tenant/organization for dashboard display.',\n query: querySchema,\n responses: [{ status: 200, description: 'Widget payload', schema: newDealsResponseSchema }],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -110,6 +110,7 @@ const openApi = {
110
110
  errors: [
111
111
  { status: 400, description: "Invalid query parameters", schema: widgetErrorSchema },
112
112
  { status: 401, description: "Unauthorized", schema: widgetErrorSchema },
113
+ { status: 403, description: "Requested scope is not accessible", schema: widgetErrorSchema },
113
114
  { status: 500, description: "Widget failed to load", schema: widgetErrorSchema }
114
115
  ]
115
116
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/customers/api/dashboard/widgets/next-interactions/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerEntity } from '../../../../data/entities'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n includePast: z.enum(['true', 'false']).optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.next-interactions'] },\n}\n\ntype WidgetContext = WidgetScopeContext & {\n limit: number\n includePast: boolean\n}\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n rawQuery[key] = value\n }\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n includePast: parseBooleanToken(parsed.data.includePast) === true,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { em, tenantId, organizationIds, limit, includePast } = await resolveContext(req, translate)\n const organizationFilter =\n Array.isArray(organizationIds)\n ? organizationIds.length === 1\n ? organizationIds[0]\n : { $in: Array.from(new Set(organizationIds)) }\n : null\n\n const now = new Date()\n\n const filters: FilterQuery<CustomerEntity> = {\n tenantId,\n deletedAt: null,\n nextInteractionAt: includePast ? { $ne: null } : { $gte: now },\n }\n if (organizationFilter) filters.organizationId = organizationFilter\n\n const entities = await em.find(CustomerEntity, filters, {\n limit,\n orderBy: { nextInteractionAt: 'asc' as const },\n })\n\n const items = entities.map((entity) => ({\n id: entity.id,\n displayName: entity.displayName,\n kind: entity.kind,\n organizationId: entity.organizationId,\n nextInteractionAt: entity.nextInteractionAt ? entity.nextInteractionAt.toISOString() : null,\n nextInteractionName: entity.nextInteractionName ?? null,\n nextInteractionIcon: entity.nextInteractionIcon ?? null,\n nextInteractionColor: entity.nextInteractionColor ?? null,\n ownerUserId: entity.ownerUserId ?? null,\n }))\n\n return NextResponse.json({ items, now: now.toISOString() })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.nextInteractions failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.nextInteractions.error', 'Failed to load upcoming interactions') },\n { status: 500 }\n )\n }\n}\n\nconst nextInteractionItemSchema = z.object({\n id: z.string().uuid(),\n displayName: z.string().nullable().optional(),\n kind: z.string().nullable().optional(),\n organizationId: z.string().uuid().nullable().optional(),\n nextInteractionAt: z.string().nullable(),\n nextInteractionName: z.string().nullable().optional(),\n nextInteractionIcon: z.string().nullable().optional(),\n nextInteractionColor: z.string().nullable().optional(),\n ownerUserId: z.string().uuid().nullable().optional(),\n})\n\nconst nextInteractionResponseSchema = z.object({\n items: z.array(nextInteractionItemSchema),\n now: z.string(),\n})\n\nconst widgetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Next interactions widget',\n methods: {\n GET: {\n summary: 'Fetch upcoming customer interactions',\n description: 'Lists upcoming (or optionally past) customer interaction reminders ordered by interaction date.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Widget payload', schema: nextInteractionResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerEntity } from '../../../../data/entities'\nimport type { FilterQuery } from '@mikro-orm/core'\nimport { resolveWidgetScope, type WidgetScopeContext } from '../utils'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst querySchema = z.object({\n limit: z.coerce.number().min(1).max(20).default(5),\n tenantId: z.string().uuid().optional(),\n organizationId: z.string().uuid().optional(),\n includePast: z.enum(['true', 'false']).optional(),\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['dashboards.view', 'customers.widgets.next-interactions'] },\n}\n\ntype WidgetContext = WidgetScopeContext & {\n limit: number\n includePast: boolean\n}\n\nasync function resolveContext(req: Request, translate: (key: string, fallback?: string) => string): Promise<WidgetContext> {\n const url = new URL(req.url)\n const rawQuery: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n rawQuery[key] = value\n }\n const parsed = querySchema.safeParse(rawQuery)\n if (!parsed.success) {\n throw new CrudHttpError(400, { error: translate('customers.errors.invalid_query', 'Invalid query parameters') })\n }\n\n const { container, em, tenantId, organizationIds } = await resolveWidgetScope(req, translate, {\n tenantId: parsed.data.tenantId ?? null,\n organizationId: parsed.data.organizationId ?? null,\n })\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n limit: parsed.data.limit,\n includePast: parseBooleanToken(parsed.data.includePast) === true,\n }\n}\n\nexport async function GET(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const { em, tenantId, organizationIds, limit, includePast } = await resolveContext(req, translate)\n const organizationFilter =\n Array.isArray(organizationIds)\n ? organizationIds.length === 1\n ? organizationIds[0]\n : { $in: Array.from(new Set(organizationIds)) }\n : null\n\n const now = new Date()\n\n const filters: FilterQuery<CustomerEntity> = {\n tenantId,\n deletedAt: null,\n nextInteractionAt: includePast ? { $ne: null } : { $gte: now },\n }\n if (organizationFilter) filters.organizationId = organizationFilter\n\n const entities = await em.find(CustomerEntity, filters, {\n limit,\n orderBy: { nextInteractionAt: 'asc' as const },\n })\n\n const items = entities.map((entity) => ({\n id: entity.id,\n displayName: entity.displayName,\n kind: entity.kind,\n organizationId: entity.organizationId,\n nextInteractionAt: entity.nextInteractionAt ? entity.nextInteractionAt.toISOString() : null,\n nextInteractionName: entity.nextInteractionName ?? null,\n nextInteractionIcon: entity.nextInteractionIcon ?? null,\n nextInteractionColor: entity.nextInteractionColor ?? null,\n ownerUserId: entity.ownerUserId ?? null,\n }))\n\n return NextResponse.json({ items, now: now.toISOString() })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('customers.widgets.nextInteractions failed', { err })\n return NextResponse.json(\n { error: translate('customers.widgets.nextInteractions.error', 'Failed to load upcoming interactions') },\n { status: 500 }\n )\n }\n}\n\nconst nextInteractionItemSchema = z.object({\n id: z.string().uuid(),\n displayName: z.string().nullable().optional(),\n kind: z.string().nullable().optional(),\n organizationId: z.string().uuid().nullable().optional(),\n nextInteractionAt: z.string().nullable(),\n nextInteractionName: z.string().nullable().optional(),\n nextInteractionIcon: z.string().nullable().optional(),\n nextInteractionColor: z.string().nullable().optional(),\n ownerUserId: z.string().uuid().nullable().optional(),\n})\n\nconst nextInteractionResponseSchema = z.object({\n items: z.array(nextInteractionItemSchema),\n now: z.string(),\n})\n\nconst widgetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Next interactions widget',\n methods: {\n GET: {\n summary: 'Fetch upcoming customer interactions',\n description: 'Lists upcoming (or optionally past) customer interaction reminders ordered by interaction date.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Widget payload', schema: nextInteractionResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },\n { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },\n { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },\n { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,2BAA2B;AACpC,SAAS,eAAe,uBAAuB;AAC/C,SAAS,sBAAsB;AAE/B,SAAS,0BAAmD;AAE5D,SAAS,yBAAyB;AAClC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW;AAEvC,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,OAAO,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,QAAQ,CAAC;AAAA,EACjD,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACrC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC3C,aAAa,EAAE,KAAK,CAAC,QAAQ,OAAO,CAAC,EAAE,SAAS;AAClD,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,qCAAqC,EAAE;AACxG;AAOA,eAAe,eAAe,KAAc,WAA+E;AACzH,QAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAM,WAAmC,CAAC;AAC1C,aAAW,CAAC,KAAK,KAAK,KAAK,IAAI,aAAa,QAAQ,GAAG;AACrD,aAAS,GAAG,IAAI;AAAA,EAClB;AACA,QAAM,SAAS,YAAY,UAAU,QAAQ;AAC7C,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,kCAAkC,0BAA0B,EAAE,CAAC;AAAA,EACjH;AAEA,QAAM,EAAE,WAAW,IAAI,UAAU,gBAAgB,IAAI,MAAM,mBAAmB,KAAK,WAAW;AAAA,IAC5F,UAAU,OAAO,KAAK,YAAY;AAAA,IAClC,gBAAgB,OAAO,KAAK,kBAAkB;AAAA,EAChD,CAAC;AAED,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO,OAAO,KAAK;AAAA,IACnB,aAAa,kBAAkB,OAAO,KAAK,WAAW,MAAM;AAAA,EAC9D;AACF;AAEA,eAAsB,IAAI,KAAc;AACtC,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,MAAI;AACF,UAAM,EAAE,IAAI,UAAU,iBAAiB,OAAO,YAAY,IAAI,MAAM,eAAe,KAAK,SAAS;AACjG,UAAM,qBACJ,MAAM,QAAQ,eAAe,IACzB,gBAAgB,WAAW,IACzB,gBAAgB,CAAC,IACjB,EAAE,KAAK,MAAM,KAAK,IAAI,IAAI,eAAe,CAAC,EAAE,IAC9C;AAEN,UAAM,MAAM,oBAAI,KAAK;AAErB,UAAM,UAAuC;AAAA,MAC3C;AAAA,MACA,WAAW;AAAA,MACX,mBAAmB,cAAc,EAAE,KAAK,KAAK,IAAI,EAAE,MAAM,IAAI;AAAA,IAC/D;AACA,QAAI,mBAAoB,SAAQ,iBAAiB;AAEjD,UAAM,WAAW,MAAM,GAAG,KAAK,gBAAgB,SAAS;AAAA,MACtD;AAAA,MACA,SAAS,EAAE,mBAAmB,MAAe;AAAA,IAC/C,CAAC;AAED,UAAM,QAAQ,SAAS,IAAI,CAAC,YAAY;AAAA,MACtC,IAAI,OAAO;AAAA,MACX,aAAa,OAAO;AAAA,MACpB,MAAM,OAAO;AAAA,MACb,gBAAgB,OAAO;AAAA,MACvB,mBAAmB,OAAO,oBAAoB,OAAO,kBAAkB,YAAY,IAAI;AAAA,MACvF,qBAAqB,OAAO,uBAAuB;AAAA,MACnD,qBAAqB,OAAO,uBAAuB;AAAA,MACnD,sBAAsB,OAAO,wBAAwB;AAAA,MACrD,aAAa,OAAO,eAAe;AAAA,IACrC,EAAE;AAEF,WAAO,aAAa,KAAK,EAAE,OAAO,KAAK,IAAI,YAAY,EAAE,CAAC;AAAA,EAC5D,SAAS,KAAK;AACZ,QAAI,gBAAgB,GAAG,GAAG;AACxB,aAAO,aAAa,KAAK,IAAI,MAAM,EAAE,QAAQ,IAAI,OAAO,CAAC;AAAA,IAC3D;AACA,WAAO,MAAM,6CAA6C,EAAE,IAAI,CAAC;AACjE,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,UAAU,4CAA4C,sCAAsC,EAAE;AAAA,MACvG,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAEA,MAAM,4BAA4B,EAAE,OAAO;AAAA,EACzC,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,EACpB,aAAa,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC5C,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACrC,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACtD,mBAAmB,EAAE,OAAO,EAAE,SAAS;AAAA,EACvC,qBAAqB,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACpD,qBAAqB,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACpD,sBAAsB,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACrD,aAAa,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AACrD,CAAC;AAED,MAAM,gCAAgC,EAAE,OAAO;AAAA,EAC7C,OAAO,EAAE,MAAM,yBAAyB;AAAA,EACxC,KAAK,EAAE,OAAO;AAChB,CAAC;AAED,MAAM,oBAAoB,EAAE,OAAO;AAAA,EACjC,OAAO,EAAE,OAAO;AAClB,CAAC;AAEM,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,OAAO;AAAA,MACP,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,kBAAkB,QAAQ,8BAA8B;AAAA,MACtF;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,kBAAkB;AAAA,QAClF,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,kBAAkB;AAAA,QACtE,EAAE,QAAQ,KAAK,aAAa,qCAAqC,QAAQ,kBAAkB;AAAA,QAC3F,EAAE,QAAQ,KAAK,aAAa,yBAAyB,QAAQ,kBAAkB;AAAA,MACjF;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -2,19 +2,45 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
2
2
  import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
3
3
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
4
4
  import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
5
+ function normalizeScopeId(value) {
6
+ if (typeof value !== "string") return null;
7
+ const trimmed = value.trim();
8
+ return trimmed.length > 0 ? trimmed : null;
9
+ }
5
10
  async function resolveWidgetScope(req, translate, overrides) {
6
11
  const auth = await getAuthFromRequest(req);
7
12
  if (!auth) {
8
13
  throw new CrudHttpError(401, { error: translate("dashboards.errors.unauthorized", "Unauthorized") });
9
14
  }
15
+ const forbiddenScope = () => new CrudHttpError(403, {
16
+ error: translate("dashboards.errors.forbidden_scope", "Requested scope is not accessible")
17
+ });
18
+ const requestedTenantId = normalizeScopeId(overrides?.tenantId);
19
+ const requestedOrganizationId = normalizeScopeId(overrides?.organizationId);
20
+ const authTenantId = normalizeScopeId(auth.tenantId);
21
+ const isSuperAdmin = auth.isSuperAdmin === true;
22
+ if (requestedTenantId && !isSuperAdmin && requestedTenantId !== authTenantId) {
23
+ throw forbiddenScope();
24
+ }
10
25
  const container = await createRequestContainer();
11
- const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
12
- const tenantId = overrides?.tenantId ?? auth.tenantId ?? null;
26
+ const scope = await resolveOrganizationScopeForRequest({
27
+ container,
28
+ auth,
29
+ request: req,
30
+ ...requestedTenantId ? { tenantId: requestedTenantId } : {},
31
+ ...requestedOrganizationId ? { selectedId: requestedOrganizationId } : {}
32
+ });
33
+ const tenantId = normalizeScopeId(scope?.tenantId) ?? authTenantId;
13
34
  if (!tenantId) {
14
35
  throw new CrudHttpError(400, { error: translate("dashboards.errors.tenant_required", "Tenant context is required") });
15
36
  }
37
+ if (requestedTenantId && requestedTenantId !== tenantId) {
38
+ throw forbiddenScope();
39
+ }
40
+ if (requestedOrganizationId && (scope?.selectionRejected || scope?.selectedId !== requestedOrganizationId)) {
41
+ throw forbiddenScope();
42
+ }
16
43
  const organizationIds = (() => {
17
- if (overrides?.organizationId) return [overrides.organizationId];
18
44
  if (scope?.selectedId) return [scope.selectedId];
19
45
  if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds;
20
46
  if (scope?.allowedIds === null) return null;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/dashboards/lib/widgetScope.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer, type AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\n\nexport type WidgetScopeContext = {\n container: AppContainer\n em: EntityManager\n tenantId: string\n organizationIds: string[] | null\n}\n\nexport async function resolveWidgetScope(\n req: Request,\n translate: (key: string, fallback?: string) => string,\n overrides?: { tenantId?: string | null; organizationId?: string | null }\n): Promise<WidgetScopeContext> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n throw new CrudHttpError(401, { error: translate('dashboards.errors.unauthorized', 'Unauthorized') })\n }\n\n const container = await createRequestContainer()\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n\n const tenantId = overrides?.tenantId ?? auth.tenantId ?? null\n if (!tenantId) {\n throw new CrudHttpError(400, { error: translate('dashboards.errors.tenant_required', 'Tenant context is required') })\n }\n\n const organizationIds = (() => {\n if (overrides?.organizationId) return [overrides.organizationId]\n if (scope?.selectedId) return [scope.selectedId]\n if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds\n if (scope?.allowedIds === null) return null\n if (auth.orgId) return [auth.orgId]\n return [] as string[]\n })()\n\n if (organizationIds !== null && organizationIds.length === 0) {\n throw new CrudHttpError(400, { error: translate('dashboards.errors.organization_required', 'Organization context is required') })\n }\n\n const em = (container.resolve('em') as EntityManager)\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n }\n}\n"],
5
- "mappings": "AACA,SAAS,8BAAiD;AAC1D,SAAS,0BAA0B;AACnC,SAAS,qBAAqB;AAC9B,SAAS,0CAA0C;AASnD,eAAsB,mBACpB,KACA,WACA,WAC6B;AAC7B,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,kCAAkC,cAAc,EAAE,CAAC;AAAA,EACrG;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,QAAQ,MAAM,mCAAmC,EAAE,WAAW,MAAM,SAAS,IAAI,CAAC;AAExF,QAAM,WAAW,WAAW,YAAY,KAAK,YAAY;AACzD,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,qCAAqC,4BAA4B,EAAE,CAAC;AAAA,EACtH;AAEA,QAAM,mBAAmB,MAAM;AAC7B,QAAI,WAAW,eAAgB,QAAO,CAAC,UAAU,cAAc;AAC/D,QAAI,OAAO,WAAY,QAAO,CAAC,MAAM,UAAU;AAC/C,QAAI,MAAM,QAAQ,OAAO,SAAS,KAAK,MAAM,UAAU,SAAS,EAAG,QAAO,MAAM;AAChF,QAAI,OAAO,eAAe,KAAM,QAAO;AACvC,QAAI,KAAK,MAAO,QAAO,CAAC,KAAK,KAAK;AAClC,WAAO,CAAC;AAAA,EACV,GAAG;AAEH,MAAI,oBAAoB,QAAQ,gBAAgB,WAAW,GAAG;AAC5D,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,2CAA2C,kCAAkC,EAAE,CAAC;AAAA,EAClI;AAEA,QAAM,KAAM,UAAU,QAAQ,IAAI;AAElC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer, type AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\n\nexport type WidgetScopeContext = {\n container: AppContainer\n em: EntityManager\n tenantId: string\n organizationIds: string[] | null\n}\n\nfunction normalizeScopeId(value: string | null | undefined): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nexport async function resolveWidgetScope(\n req: Request,\n translate: (key: string, fallback?: string) => string,\n overrides?: { tenantId?: string | null; organizationId?: string | null }\n): Promise<WidgetScopeContext> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n throw new CrudHttpError(401, { error: translate('dashboards.errors.unauthorized', 'Unauthorized') })\n }\n\n const forbiddenScope = () => new CrudHttpError(403, {\n error: translate('dashboards.errors.forbidden_scope', 'Requested scope is not accessible'),\n })\n\n const requestedTenantId = normalizeScopeId(overrides?.tenantId)\n const requestedOrganizationId = normalizeScopeId(overrides?.organizationId)\n const authTenantId = normalizeScopeId(auth.tenantId)\n const isSuperAdmin = auth.isSuperAdmin === true\n\n // Cross-tenant inspection is a superadmin-only branch. Everyone else is pinned to\n // the authenticated tenant, so a request-supplied tenant can only ever restate it.\n if (requestedTenantId && !isSuperAdmin && requestedTenantId !== authTenantId) {\n throw forbiddenScope()\n }\n\n const container = await createRequestContainer()\n // Request-supplied scope is passed to the resolver as a *request*, never trusted\n // directly: it pins a non-superadmin back to their authenticated tenant and only\n // honors an organization selection the caller's ACL actually grants. Each key is\n // omitted when no override was supplied so the caller's own scope cookies still apply.\n const scope = await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n ...(requestedTenantId ? { tenantId: requestedTenantId } : {}),\n ...(requestedOrganizationId ? { selectedId: requestedOrganizationId } : {}),\n })\n\n const tenantId = normalizeScopeId(scope?.tenantId) ?? authTenantId\n if (!tenantId) {\n throw new CrudHttpError(400, { error: translate('dashboards.errors.tenant_required', 'Tenant context is required') })\n }\n // Defense in depth: the resolver already pins the tenant, so a surviving mismatch\n // means the requested tenant was not the one authorized \u2014 fail closed rather than\n // serve another tenant's rows.\n if (requestedTenantId && requestedTenantId !== tenantId) {\n throw forbiddenScope()\n }\n\n // An organization override is only accepted when the resolver honored it against the\n // caller's allowed set; `selectionRejected` marks a selection it refused to grant.\n if (requestedOrganizationId && (scope?.selectionRejected || scope?.selectedId !== requestedOrganizationId)) {\n throw forbiddenScope()\n }\n\n const organizationIds = (() => {\n if (scope?.selectedId) return [scope.selectedId]\n if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds\n if (scope?.allowedIds === null) return null\n if (auth.orgId) return [auth.orgId]\n return [] as string[]\n })()\n\n if (organizationIds !== null && organizationIds.length === 0) {\n throw new CrudHttpError(400, { error: translate('dashboards.errors.organization_required', 'Organization context is required') })\n }\n\n const em = (container.resolve('em') as EntityManager)\n\n return {\n container,\n em,\n tenantId,\n organizationIds,\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,8BAAiD;AAC1D,SAAS,0BAA0B;AACnC,SAAS,qBAAqB;AAC9B,SAAS,0CAA0C;AASnD,SAAS,iBAAiB,OAAiD;AACzE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAEA,eAAsB,mBACpB,KACA,WACA,WAC6B;AAC7B,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,kCAAkC,cAAc,EAAE,CAAC;AAAA,EACrG;AAEA,QAAM,iBAAiB,MAAM,IAAI,cAAc,KAAK;AAAA,IAClD,OAAO,UAAU,qCAAqC,mCAAmC;AAAA,EAC3F,CAAC;AAED,QAAM,oBAAoB,iBAAiB,WAAW,QAAQ;AAC9D,QAAM,0BAA0B,iBAAiB,WAAW,cAAc;AAC1E,QAAM,eAAe,iBAAiB,KAAK,QAAQ;AACnD,QAAM,eAAe,KAAK,iBAAiB;AAI3C,MAAI,qBAAqB,CAAC,gBAAgB,sBAAsB,cAAc;AAC5E,UAAM,eAAe;AAAA,EACvB;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAK/C,QAAM,QAAQ,MAAM,mCAAmC;AAAA,IACrD;AAAA,IACA;AAAA,IACA,SAAS;AAAA,IACT,GAAI,oBAAoB,EAAE,UAAU,kBAAkB,IAAI,CAAC;AAAA,IAC3D,GAAI,0BAA0B,EAAE,YAAY,wBAAwB,IAAI,CAAC;AAAA,EAC3E,CAAC;AAED,QAAM,WAAW,iBAAiB,OAAO,QAAQ,KAAK;AACtD,MAAI,CAAC,UAAU;AACb,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,qCAAqC,4BAA4B,EAAE,CAAC;AAAA,EACtH;AAIA,MAAI,qBAAqB,sBAAsB,UAAU;AACvD,UAAM,eAAe;AAAA,EACvB;AAIA,MAAI,4BAA4B,OAAO,qBAAqB,OAAO,eAAe,0BAA0B;AAC1G,UAAM,eAAe;AAAA,EACvB;AAEA,QAAM,mBAAmB,MAAM;AAC7B,QAAI,OAAO,WAAY,QAAO,CAAC,MAAM,UAAU;AAC/C,QAAI,MAAM,QAAQ,OAAO,SAAS,KAAK,MAAM,UAAU,SAAS,EAAG,QAAO,MAAM;AAChF,QAAI,OAAO,eAAe,KAAM,QAAO;AACvC,QAAI,KAAK,MAAO,QAAO,CAAC,KAAK,KAAK;AAClC,WAAO,CAAC;AAAA,EACV,GAAG;AAEH,MAAI,oBAAoB,QAAQ,gBAAgB,WAAW,GAAG;AAC5D,UAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,2CAA2C,kCAAkC,EAAE,CAAC;AAAA,EAClI;AAEA,QAAM,KAAM,UAAU,QAAQ,IAAI;AAElC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;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.8-develop.6964.1.36b364cfd8",
3
+ "version": "0.6.8-develop.6971.1.20c09ca9ea",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.8-develop.6964.1.36b364cfd8",
258
- "@open-mercato/shared": "0.6.8-develop.6964.1.36b364cfd8",
259
- "@open-mercato/ui": "0.6.8-develop.6964.1.36b364cfd8",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.6971.1.20c09ca9ea",
258
+ "@open-mercato/shared": "0.6.8-develop.6971.1.20c09ca9ea",
259
+ "@open-mercato/ui": "0.6.8-develop.6971.1.20c09ca9ea",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.8-develop.6964.1.36b364cfd8",
265
- "@open-mercato/shared": "0.6.8-develop.6964.1.36b364cfd8",
266
- "@open-mercato/ui": "0.6.8-develop.6964.1.36b364cfd8",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.6971.1.20c09ca9ea",
265
+ "@open-mercato/shared": "0.6.8-develop.6971.1.20c09ca9ea",
266
+ "@open-mercato/ui": "0.6.8-develop.6971.1.20c09ca9ea",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^7.0.0",
269
269
  "@testing-library/react": "^16.3.1",
@@ -1,5 +1,7 @@
1
+ import { headers } from 'next/headers'
1
2
  import ApiDocsExplorer from './Explorer'
2
3
  import { resolveApiDocsBaseUrl } from '@open-mercato/core/modules/api_docs/lib/resources'
4
+ import { resolveForwardableCookieHeader } from '@open-mercato/core/modules/api_docs/lib/document'
3
5
  import { APP_VERSION } from '@open-mercato/shared/lib/version'
4
6
  import type { OpenApiDocument } from '@open-mercato/shared/lib/openapi'
5
7
 
@@ -51,7 +53,12 @@ function buildTagOrder(doc: any, operations: ExplorerOperation[]): string[] {
51
53
 
52
54
  export default async function ApiDocsViewerPage() {
53
55
  const baseUrl = resolveApiDocsBaseUrl()
54
- const response = await fetch(`${baseUrl}/docs/openapi`, { cache: 'no-store' })
56
+ const requestHeaders = await headers()
57
+ const forwardedCookie = resolveForwardableCookieHeader(baseUrl, requestHeaders)
58
+ const response = await fetch(`${baseUrl}/docs/openapi`, {
59
+ cache: 'no-store',
60
+ headers: forwardedCookie ? { cookie: forwardedCookie } : undefined,
61
+ })
55
62
  const doc = response.ok
56
63
  ? await response.json() as OpenApiDocument
57
64
  : {
@@ -0,0 +1,84 @@
1
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
2
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
3
+ import {
4
+ attachOpenApiDocsToModules,
5
+ buildOpenApiDocument,
6
+ sanitizeOpenApiDocument,
7
+ } from '@open-mercato/shared/lib/openapi'
8
+ import type { OpenApiDocument } from '@open-mercato/shared/lib/openapi'
9
+ import type { ApiRouteManifestEntry, Module } from '@open-mercato/shared/modules/registry'
10
+ import { APP_VERSION } from '@open-mercato/shared/lib/version'
11
+ import { resolveApiDocsBaseUrl } from './resources'
12
+
13
+ /**
14
+ * The exports render differently for anonymous and authenticated callers, so
15
+ * they must never be served from a shared cache keyed on the URL alone.
16
+ */
17
+ export const API_DOCS_CALLER_SCOPED_HEADERS = {
18
+ 'cache-control': 'no-store',
19
+ vary: 'Cookie, Authorization',
20
+ } as const
21
+
22
+ /**
23
+ * The Explorer renders server-side and needs the visitor's session to receive
24
+ * the full document, but `resolveApiDocsBaseUrl()` is operator-configurable —
25
+ * so the session cookie only travels when the export route lives on the very
26
+ * origin that served the page.
27
+ */
28
+ export function resolveForwardableCookieHeader(
29
+ targetUrl: string,
30
+ requestHeaders: Pick<Headers, 'get'>,
31
+ ): string | null {
32
+ const cookieHeader = requestHeaders.get('cookie')
33
+ if (!cookieHeader) return null
34
+ const host = requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host')
35
+ if (!host) return null
36
+ const protocol = requestHeaders.get('x-forwarded-proto') ?? 'https'
37
+ try {
38
+ const target = new URL(targetUrl)
39
+ const origin = new URL(`${protocol}://${host}`)
40
+ return target.origin === origin.origin ? cookieHeader : null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ export type ApiDocsDocumentInput = {
47
+ modules: Module[]
48
+ apiRoutes: ApiRouteManifestEntry[]
49
+ includeAccessControlMetadata: boolean
50
+ }
51
+
52
+ /**
53
+ * The docs export routes stay publicly reachable, so the ACL metadata they
54
+ * carry (`Requires features/roles`, `x-require-features`, `x-require-roles`)
55
+ * is only rendered for authenticated staff callers. Anonymous callers get the
56
+ * same document with those identifiers stripped.
57
+ */
58
+ export async function shouldExposeAccessControlMetadata(req: Request): Promise<boolean> {
59
+ try {
60
+ return Boolean(await getAuthFromRequest(req))
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ export async function buildApiDocsOpenApiDocument({
67
+ modules,
68
+ apiRoutes,
69
+ includeAccessControlMetadata,
70
+ }: ApiDocsDocumentInput): Promise<OpenApiDocument> {
71
+ const { t } = await resolveTranslations()
72
+ const baseUrl = resolveApiDocsBaseUrl()
73
+ const docModules = await attachOpenApiDocsToModules(modules, apiRoutes)
74
+ const rawDoc = buildOpenApiDocument(docModules, {
75
+ title: t('api.docs.title', 'Open Mercato API'),
76
+ version: APP_VERSION,
77
+ description: t('api.docs.description', 'Auto-generated OpenAPI definition for all enabled modules.'),
78
+ servers: [{ url: baseUrl, description: t('api.docs.serverDescription', 'Default environment') }],
79
+ baseUrlForExamples: baseUrl,
80
+ defaultSecurity: ['bearerAuth'],
81
+ includeAccessControlMetadata,
82
+ })
83
+ return sanitizeOpenApiDocument(rawDoc)
84
+ }
@@ -183,12 +183,21 @@ export async function PUT(req: Request, { params }: { params: { id: string } })
183
183
  }
184
184
  }
185
185
 
186
+ // `display_name` is encrypted at rest, and `nativeUpdate` skips the flush hooks the
187
+ // tenant-encryption subscriber relies on, so persisting it below would write plaintext PII
188
+ // into a ciphertext column (#3837). Route it through the service, which writes it via the
189
+ // managed entity. Runs before the `nativeUpdate` so the explicit `updated_at` below stays
190
+ // the value this response reports back as the optimistic-lock version.
191
+ if (parsed.data.displayName !== undefined) {
192
+ const customerUserService = container.resolve('customerUserService') as CustomerUserService
193
+ await customerUserService.updateProfile(user, { displayName: parsed.data.displayName })
194
+ }
195
+
186
196
  // Always bump updated_at so the optimistic-lock version advances on every save.
187
197
  // `nativeUpdate` bypasses MikroORM's `onUpdate` hook, so set it explicitly — without
188
198
  // this the version never changes and concurrent edits cannot be detected (#2055).
189
199
  const nextUpdatedAt = new Date()
190
200
  const updates: Record<string, unknown> = { updatedAt: nextUpdatedAt }
191
- if (parsed.data.displayName !== undefined) updates.displayName = parsed.data.displayName
192
201
  if (parsed.data.isActive !== undefined) updates.isActive = parsed.data.isActive
193
202
  if (parsed.data.lockedUntil !== undefined) updates.lockedUntil = parsed.data.lockedUntil ? new Date(parsed.data.lockedUntil) : null
194
203
  if (parsed.data.personEntityId !== undefined) updates.personEntityId = parsed.data.personEntityId
@@ -110,12 +110,23 @@ export class CustomerUserService {
110
110
  user.passwordHash = passwordHash
111
111
  }
112
112
 
113
+ // `display_name` is encrypted at rest. `nativeUpdate` issues raw SQL and fires none of the
114
+ // flush hooks the tenant-encryption subscriber depends on, so writing it that way persists
115
+ // plaintext PII into a ciphertext column (#3837). Assign it on the managed entity and flush
116
+ // so `beforeUpdate` encrypts the value on its way to the database.
113
117
  async updateProfile(user: CustomerUser, data: { displayName?: string }): Promise<void> {
114
- const updates: Record<string, unknown> = {}
115
- if (data.displayName !== undefined) updates.displayName = data.displayName
116
- if (Object.keys(updates).length === 0) return
117
- await this.em.nativeUpdate(CustomerUser, { id: user.id }, updates)
118
- if (data.displayName !== undefined) user.displayName = data.displayName
118
+ if (data.displayName === undefined) return
119
+ const managed = await findOneWithDecryption(
120
+ this.em,
121
+ CustomerUser,
122
+ { id: user.id, tenantId: user.tenantId, organizationId: user.organizationId, deletedAt: null } as any,
123
+ undefined,
124
+ { tenantId: user.tenantId, organizationId: user.organizationId },
125
+ )
126
+ if (!managed) return
127
+ managed.displayName = data.displayName
128
+ await this.em.flush()
129
+ user.displayName = data.displayName
119
130
  }
120
131
 
121
132
  async softDelete(
@@ -179,6 +179,7 @@ export const openApi: OpenApiRouteDoc = {
179
179
  errors: [
180
180
  { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },
181
181
  { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },
182
+ { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },
182
183
  { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },
183
184
  ],
184
185
  },
@@ -125,6 +125,7 @@ export const openApi: OpenApiRouteDoc = {
125
125
  errors: [
126
126
  { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },
127
127
  { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },
128
+ { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },
128
129
  { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },
129
130
  ],
130
131
  },
@@ -118,6 +118,7 @@ export const openApi: OpenApiRouteDoc = {
118
118
  errors: [
119
119
  { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },
120
120
  { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },
121
+ { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },
121
122
  { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },
122
123
  ],
123
124
  },
@@ -138,6 +138,7 @@ export const openApi: OpenApiRouteDoc = {
138
138
  errors: [
139
139
  { status: 400, description: 'Invalid query parameters', schema: widgetErrorSchema },
140
140
  { status: 401, description: 'Unauthorized', schema: widgetErrorSchema },
141
+ { status: 403, description: 'Requested scope is not accessible', schema: widgetErrorSchema },
141
142
  { status: 500, description: 'Widget failed to load', schema: widgetErrorSchema },
142
143
  ],
143
144
  },
@@ -101,6 +101,7 @@
101
101
  "dashboards.analytics.widgets.topProducts.empty": "Keine Produktverkaufsdaten für diesen Zeitraum",
102
102
  "dashboards.analytics.widgets.topProducts.error": "Produktdaten konnten nicht geladen werden",
103
103
  "dashboards.analytics.widgets.topProducts.title": "Top-Produkte nach Umsatz",
104
+ "dashboards.errors.forbidden_scope": "Angeforderter Bereich ist nicht zugänglich",
104
105
  "dashboards.errors.organization_required": "Organisationskontext ist erforderlich",
105
106
  "dashboards.errors.tenant_required": "Mandantenkontext ist erforderlich",
106
107
  "dashboards.errors.unauthorized": "Nicht autorisiert",
@@ -101,6 +101,7 @@
101
101
  "dashboards.analytics.widgets.topProducts.empty": "No product sales data for this period",
102
102
  "dashboards.analytics.widgets.topProducts.error": "Failed to load top products data",
103
103
  "dashboards.analytics.widgets.topProducts.title": "Top Products by Revenue",
104
+ "dashboards.errors.forbidden_scope": "Requested scope is not accessible",
104
105
  "dashboards.errors.organization_required": "Organization context is required",
105
106
  "dashboards.errors.tenant_required": "Tenant context is required",
106
107
  "dashboards.errors.unauthorized": "Unauthorized",
@@ -101,6 +101,7 @@
101
101
  "dashboards.analytics.widgets.topProducts.empty": "No hay datos de ventas de productos para este período",
102
102
  "dashboards.analytics.widgets.topProducts.error": "No se pudieron cargar los datos de productos",
103
103
  "dashboards.analytics.widgets.topProducts.title": "Productos principales por ingresos",
104
+ "dashboards.errors.forbidden_scope": "El ámbito solicitado no es accesible",
104
105
  "dashboards.errors.organization_required": "Se requiere contexto de organización",
105
106
  "dashboards.errors.tenant_required": "Se requiere contexto de inquilino",
106
107
  "dashboards.errors.unauthorized": "No autorizado",
@@ -101,6 +101,7 @@
101
101
  "dashboards.analytics.widgets.topProducts.empty": "이 기간에 상품 판매 데이터가 없습니다",
102
102
  "dashboards.analytics.widgets.topProducts.error": "상위 상품 데이터를 불러오지 못했습니다",
103
103
  "dashboards.analytics.widgets.topProducts.title": "매출 기준 상위 상품",
104
+ "dashboards.errors.forbidden_scope": "요청한 범위에 접근할 수 없습니다",
104
105
  "dashboards.errors.organization_required": "조직 컨텍스트가 필요합니다",
105
106
  "dashboards.errors.tenant_required": "테넌트 컨텍스트가 필요합니다",
106
107
  "dashboards.errors.unauthorized": "권한이 없습니다",
@@ -101,6 +101,7 @@
101
101
  "dashboards.analytics.widgets.topProducts.empty": "Brak danych o sprzedaży produktów dla tego okresu",
102
102
  "dashboards.analytics.widgets.topProducts.error": "Nie udało się załadować danych o produktach",
103
103
  "dashboards.analytics.widgets.topProducts.title": "Najlepsze produkty wg przychodu",
104
+ "dashboards.errors.forbidden_scope": "Żądany zakres jest niedostępny",
104
105
  "dashboards.errors.organization_required": "Wymagany kontekst organizacji",
105
106
  "dashboards.errors.tenant_required": "Wymagany kontekst najemcy",
106
107
  "dashboards.errors.unauthorized": "Brak autoryzacji",
@@ -11,6 +11,12 @@ export type WidgetScopeContext = {
11
11
  organizationIds: string[] | null
12
12
  }
13
13
 
14
+ function normalizeScopeId(value: string | null | undefined): string | null {
15
+ if (typeof value !== 'string') return null
16
+ const trimmed = value.trim()
17
+ return trimmed.length > 0 ? trimmed : null
18
+ }
19
+
14
20
  export async function resolveWidgetScope(
15
21
  req: Request,
16
22
  translate: (key: string, fallback?: string) => string,
@@ -21,16 +27,52 @@ export async function resolveWidgetScope(
21
27
  throw new CrudHttpError(401, { error: translate('dashboards.errors.unauthorized', 'Unauthorized') })
22
28
  }
23
29
 
30
+ const forbiddenScope = () => new CrudHttpError(403, {
31
+ error: translate('dashboards.errors.forbidden_scope', 'Requested scope is not accessible'),
32
+ })
33
+
34
+ const requestedTenantId = normalizeScopeId(overrides?.tenantId)
35
+ const requestedOrganizationId = normalizeScopeId(overrides?.organizationId)
36
+ const authTenantId = normalizeScopeId(auth.tenantId)
37
+ const isSuperAdmin = auth.isSuperAdmin === true
38
+
39
+ // Cross-tenant inspection is a superadmin-only branch. Everyone else is pinned to
40
+ // the authenticated tenant, so a request-supplied tenant can only ever restate it.
41
+ if (requestedTenantId && !isSuperAdmin && requestedTenantId !== authTenantId) {
42
+ throw forbiddenScope()
43
+ }
44
+
24
45
  const container = await createRequestContainer()
25
- const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
46
+ // Request-supplied scope is passed to the resolver as a *request*, never trusted
47
+ // directly: it pins a non-superadmin back to their authenticated tenant and only
48
+ // honors an organization selection the caller's ACL actually grants. Each key is
49
+ // omitted when no override was supplied so the caller's own scope cookies still apply.
50
+ const scope = await resolveOrganizationScopeForRequest({
51
+ container,
52
+ auth,
53
+ request: req,
54
+ ...(requestedTenantId ? { tenantId: requestedTenantId } : {}),
55
+ ...(requestedOrganizationId ? { selectedId: requestedOrganizationId } : {}),
56
+ })
26
57
 
27
- const tenantId = overrides?.tenantId ?? auth.tenantId ?? null
58
+ const tenantId = normalizeScopeId(scope?.tenantId) ?? authTenantId
28
59
  if (!tenantId) {
29
60
  throw new CrudHttpError(400, { error: translate('dashboards.errors.tenant_required', 'Tenant context is required') })
30
61
  }
62
+ // Defense in depth: the resolver already pins the tenant, so a surviving mismatch
63
+ // means the requested tenant was not the one authorized — fail closed rather than
64
+ // serve another tenant's rows.
65
+ if (requestedTenantId && requestedTenantId !== tenantId) {
66
+ throw forbiddenScope()
67
+ }
68
+
69
+ // An organization override is only accepted when the resolver honored it against the
70
+ // caller's allowed set; `selectionRejected` marks a selection it refused to grant.
71
+ if (requestedOrganizationId && (scope?.selectionRejected || scope?.selectedId !== requestedOrganizationId)) {
72
+ throw forbiddenScope()
73
+ }
31
74
 
32
75
  const organizationIds = (() => {
33
- if (overrides?.organizationId) return [overrides.organizationId]
34
76
  if (scope?.selectedId) return [scope.selectedId]
35
77
  if (Array.isArray(scope?.filterIds) && scope.filterIds.length > 0) return scope.filterIds
36
78
  if (scope?.allowedIds === null) return null