@open-mercato/core 0.6.2-develop.3439.1.94b9d9733c → 0.6.2-develop.3446.1.bd060c6017

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/entity_index_row/index.js +2 -0
  3. package/dist/generated/entities/entity_index_row/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/queue-runner.js +58 -0
  7. package/dist/helpers/integration/queue-runner.js.map +7 -0
  8. package/dist/helpers/integration/queue.js +67 -0
  9. package/dist/helpers/integration/queue.js.map +7 -0
  10. package/dist/helpers/integration/salesUi.js +33 -15
  11. package/dist/helpers/integration/salesUi.js.map +2 -2
  12. package/dist/modules/customers/ai-agents.js +5 -2
  13. package/dist/modules/customers/ai-agents.js.map +2 -2
  14. package/dist/modules/customers/ai-tools/deals-pack.js +52 -8
  15. package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
  16. package/dist/modules/customers/ai-tools/types.js.map +2 -2
  17. package/dist/modules/customers/api/people/[id]/route.js +10 -1
  18. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  19. package/dist/modules/query_index/data/entities.js +13 -1
  20. package/dist/modules/query_index/data/entities.js.map +2 -2
  21. package/dist/modules/query_index/migrations/Migration20260518162054_query_index.js +43 -0
  22. package/dist/modules/query_index/migrations/Migration20260518162054_query_index.js.map +7 -0
  23. package/generated/entities/entity_index_row/index.ts +1 -0
  24. package/generated/entity-fields-registry.ts +1 -0
  25. package/package.json +8 -8
  26. package/src/helpers/integration/queue-runner.ts +72 -0
  27. package/src/helpers/integration/queue.ts +70 -0
  28. package/src/helpers/integration/salesUi.ts +38 -23
  29. package/src/modules/attachments/i18n/de.json +13 -0
  30. package/src/modules/attachments/i18n/en.json +13 -0
  31. package/src/modules/attachments/i18n/es.json +13 -0
  32. package/src/modules/attachments/i18n/pl.json +13 -0
  33. package/src/modules/customers/ai-agents.ts +4 -1
  34. package/src/modules/customers/ai-tools/deals-pack.ts +68 -10
  35. package/src/modules/customers/ai-tools/types.ts +6 -0
  36. package/src/modules/customers/api/people/[id]/route.ts +10 -1
  37. package/src/modules/query_index/data/entities.ts +12 -0
  38. package/src/modules/query_index/migrations/.snapshot-open-mercato.json +792 -343
  39. package/src/modules/query_index/migrations/Migration20260518162054_query_index.ts +43 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.2-develop.3439.1.94b9d9733c",
3
+ "version": "0.6.2-develop.3446.1.bd060c6017",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -232,7 +232,7 @@
232
232
  "@mikro-orm/postgresql": "^7.0.15",
233
233
  "@xyflow/react": "^12.10.2",
234
234
  "ai": "^6.0.177",
235
- "date-fns": "^4.1.0",
235
+ "date-fns": "4.1.0",
236
236
  "date-fns-tz": "^3.2.0",
237
237
  "html-to-text": "^9.0.5",
238
238
  "mammoth": "^1.9.0",
@@ -242,16 +242,16 @@
242
242
  "zod": "^4.3.6"
243
243
  },
244
244
  "peerDependencies": {
245
- "@open-mercato/ai-assistant": "0.6.2-develop.3439.1.94b9d9733c",
246
- "@open-mercato/shared": "0.6.2-develop.3439.1.94b9d9733c",
247
- "@open-mercato/ui": "0.6.2-develop.3439.1.94b9d9733c",
245
+ "@open-mercato/ai-assistant": "0.6.2-develop.3446.1.bd060c6017",
246
+ "@open-mercato/shared": "0.6.2-develop.3446.1.bd060c6017",
247
+ "@open-mercato/ui": "0.6.2-develop.3446.1.bd060c6017",
248
248
  "react": "^19.0.0",
249
249
  "react-dom": "^19.0.0"
250
250
  },
251
251
  "devDependencies": {
252
- "@open-mercato/ai-assistant": "0.6.2-develop.3439.1.94b9d9733c",
253
- "@open-mercato/shared": "0.6.2-develop.3439.1.94b9d9733c",
254
- "@open-mercato/ui": "0.6.2-develop.3439.1.94b9d9733c",
252
+ "@open-mercato/ai-assistant": "0.6.2-develop.3446.1.bd060c6017",
253
+ "@open-mercato/shared": "0.6.2-develop.3446.1.bd060c6017",
254
+ "@open-mercato/ui": "0.6.2-develop.3446.1.bd060c6017",
255
255
  "@testing-library/dom": "^10.4.1",
256
256
  "@testing-library/jest-dom": "^6.9.1",
257
257
  "@testing-library/react": "^16.3.1",
@@ -0,0 +1,72 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { bootstrapFromAppRoot } from '@open-mercato/shared/lib/bootstrap/dynamicLoader'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { createQueue } from '@open-mercato/queue'
6
+
7
+ export type QueueDrainRunnerOptions = {
8
+ appRoot: string
9
+ jobLimit: number
10
+ }
11
+
12
+ export async function drainQueueFromAppRoot(
13
+ queueName: string,
14
+ options: QueueDrainRunnerOptions,
15
+ ): Promise<number> {
16
+ const data = await bootstrapFromAppRoot(options.appRoot)
17
+ const worker = data.modules
18
+ .flatMap((module) => module.workers ?? [])
19
+ .find((entry) => entry.queue === queueName)
20
+ if (!worker) return 0
21
+
22
+ const container = await createRequestContainer()
23
+ const queue = createQueue(queueName, 'local', {
24
+ baseDir: path.resolve(options.appRoot, '.mercato/queue'),
25
+ concurrency: 1,
26
+ })
27
+ const resolve = <T = unknown>(name: string): T => container.resolve(name) as T
28
+
29
+ try {
30
+ let processedJobs = 0
31
+ while (true) {
32
+ const result = await queue.process(
33
+ async (job, ctx) => {
34
+ await Promise.resolve(worker.handler(job, { ...ctx, resolve }))
35
+ },
36
+ { limit: options.jobLimit },
37
+ )
38
+ const handled = result.processed + result.failed
39
+ processedJobs += handled
40
+ if (handled === 0) return processedJobs
41
+ }
42
+ } finally {
43
+ await queue.close()
44
+ }
45
+ }
46
+
47
+ export async function runQueueDrainFromEnv(): Promise<void> {
48
+ const queueName = process.env.OM_INTEGRATION_QUEUE_NAME?.trim()
49
+ const appRoot = process.env.OM_INTEGRATION_APP_ROOT?.trim()
50
+ const jobLimit = Number.parseInt(process.env.OM_INTEGRATION_QUEUE_JOB_LIMIT || '100', 10) || 100
51
+ if (!queueName || !appRoot) throw new Error('Missing queue drain environment')
52
+
53
+ const processed = await drainQueueFromAppRoot(queueName, {
54
+ appRoot: path.resolve(appRoot),
55
+ jobLimit,
56
+ })
57
+ console.log(JSON.stringify({ processed }))
58
+ }
59
+
60
+ const currentFile = fileURLToPath(import.meta.url)
61
+ const invokedFile = process.argv[1] ? path.resolve(process.argv[1]) : null
62
+
63
+ if (invokedFile === currentFile) {
64
+ runQueueDrainFromEnv()
65
+ .then(() => {
66
+ process.exit(0)
67
+ })
68
+ .catch((error) => {
69
+ console.error(error)
70
+ process.exit(1)
71
+ })
72
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+ import { drainQueueFromAppRoot } from './queue-runner'
5
+
6
+ type DrainQueueOptions = {
7
+ appRoot?: string
8
+ jobLimit?: number
9
+ }
10
+
11
+ const DEFAULT_JOB_LIMIT = 100
12
+
13
+ function resolveAppRoot(input?: string): string {
14
+ return path.resolve(input?.trim() || process.env.OM_TEST_APP_ROOT?.trim() || path.resolve(process.cwd(), 'apps/mercato'))
15
+ }
16
+
17
+ async function drainQueueInCurrentProcess(queueName: string, options: Required<DrainQueueOptions>): Promise<number> {
18
+ return drainQueueFromAppRoot(queueName, options)
19
+ }
20
+
21
+ function resolveAppRunnerPath(appRoot: string): string {
22
+ const requireFromApp = createRequire(path.join(appRoot, 'package.json'))
23
+ return requireFromApp.resolve('@open-mercato/core/helpers/integration/queue-runner')
24
+ }
25
+
26
+ function drainQueueInAppProcess(queueName: string, options: Required<DrainQueueOptions>): Promise<number> {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(process.execPath, [resolveAppRunnerPath(options.appRoot)], {
29
+ cwd: options.appRoot,
30
+ env: {
31
+ ...process.env,
32
+ OM_INTEGRATION_APP_ROOT: options.appRoot,
33
+ OM_INTEGRATION_QUEUE_NAME: queueName,
34
+ OM_INTEGRATION_QUEUE_JOB_LIMIT: String(options.jobLimit),
35
+ QUEUE_BASE_DIR: path.resolve(options.appRoot, '.mercato/queue'),
36
+ },
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ })
39
+
40
+ let stdout = ''
41
+ let stderr = ''
42
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString() })
43
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString() })
44
+ child.on('error', reject)
45
+ child.on('close', (code) => {
46
+ if (code !== 0) {
47
+ reject(new Error(`Queue drain failed for "${queueName}" in ${options.appRoot} (exit ${code}).\n${stderr || stdout}`))
48
+ return
49
+ }
50
+ const lastLine = stdout.trim().split(/\r?\n/).reverse().find((line) => line.trim().startsWith('{')) ?? '{}'
51
+ try {
52
+ const parsed = JSON.parse(lastLine) as { processed?: unknown }
53
+ resolve(Number(parsed.processed ?? 0))
54
+ } catch {
55
+ resolve(0)
56
+ }
57
+ })
58
+ })
59
+ }
60
+
61
+ export async function drainIntegrationQueue(queueName: string, options: DrainQueueOptions = {}): Promise<number> {
62
+ const resolved = {
63
+ appRoot: resolveAppRoot(options.appRoot),
64
+ jobLimit: options.jobLimit ?? DEFAULT_JOB_LIMIT,
65
+ }
66
+ if (process.env.OM_TEST_APP_ROOT?.trim()) {
67
+ return drainQueueInAppProcess(queueName, resolved)
68
+ }
69
+ return drainQueueInCurrentProcess(queueName, resolved)
70
+ }
@@ -1011,16 +1011,22 @@ export async function addCustomLine(page: Page, options: AddLineOptions): Promis
1011
1011
  await dialog.getByRole('textbox', { name: '1' }).fill(String(options.quantity));
1012
1012
 
1013
1013
  if (options.taxClassName) {
1014
- // Radix Select inside Dialog — force click on option to bypass overlay
1015
- const taxClassTrigger = dialog
1016
- .locator('[role="combobox"]')
1017
- .filter({ hasText: /No tax class selected/i })
1018
- .first();
1014
+ const taxClassTrigger = dialog.locator('[data-crud-field-id="taxRateId"] [role="combobox"]').first();
1019
1015
  if ((await taxClassTrigger.count()) > 0) {
1020
- await taxClassTrigger.click();
1021
- const opt = page.getByRole('option', { name: options.taxClassName, exact: true });
1022
- await opt.first().waitFor({ state: 'visible', timeout: 3_000 });
1023
- await opt.first().click({ force: true });
1016
+ await expect(taxClassTrigger).toBeEnabled({ timeout: TEST_WAIT_TIMEOUT_MS });
1017
+ const selectedLabel = options.taxClassName.split('•')[0].trim();
1018
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1019
+ await taxClassTrigger.click();
1020
+ const opt = page.getByRole('option', { name: options.taxClassName, exact: true }).first();
1021
+ await opt.waitFor({ state: 'visible', timeout: 3_000 });
1022
+ await opt.click();
1023
+ const selected = await taxClassTrigger
1024
+ .filter({ hasText: new RegExp(escapeRegExp(selectedLabel), 'i') })
1025
+ .waitFor({ state: 'visible', timeout: 1_500 })
1026
+ .then(() => true, () => false);
1027
+ if (selected) break;
1028
+ }
1029
+ await expect(taxClassTrigger).toContainText(selectedLabel, { timeout: TEST_WAIT_TIMEOUT_MS });
1024
1030
  }
1025
1031
  }
1026
1032
 
@@ -1104,21 +1110,30 @@ export async function addAdjustment(page: Page, options: AddAdjustmentOptions):
1104
1110
  TEST_WAIT_TIMEOUT_MS,
1105
1111
  );
1106
1112
  await expect(kindTrigger).toBeEnabled({ timeout: TEST_WAIT_TIMEOUT_MS });
1107
- await kindTrigger.click();
1108
- const kindOption = page.getByRole('option', { name: new RegExp(`^${escapeRegExp(kindLabel)}$`, 'i') }).first();
1109
- const optionVisible = await kindOption
1110
- .waitFor({ state: 'visible', timeout: TEST_WAIT_TIMEOUT_MS })
1111
- .then(() => true, () => false);
1112
- if (optionVisible) {
1113
- await kindOption.click({ force: true });
1114
- } else {
1115
- // Fallback: drive selection via keyboard when modal-backdrop click interception
1116
- // hides the rendered option. Brought forward from develop.
1117
- await page.keyboard.type(kindLabel.charAt(0));
1118
- await page.waitForTimeout(150);
1119
- await page.keyboard.press('Enter');
1113
+ for (let attempt = 0; attempt < 3; attempt += 1) {
1114
+ const currentLabel = await kindTrigger.textContent().catch(() => '');
1115
+ if (currentLabel?.includes(kindLabel)) return;
1116
+ const expanded = await kindTrigger.getAttribute('aria-expanded').catch(() => null);
1117
+ if (expanded === 'true') {
1118
+ await page.keyboard.press('Escape').catch(() => {});
1119
+ }
1120
+ await kindTrigger.click();
1121
+ const kindOption = page.getByRole('option', { name: new RegExp(`^${escapeRegExp(kindLabel)}$`, 'i') }).first();
1122
+ const optionVisible = await kindOption
1123
+ .waitFor({ state: 'visible', timeout: TEST_WAIT_TIMEOUT_MS })
1124
+ .then(() => true, () => false);
1125
+ if (optionVisible) {
1126
+ await kindOption.click({ force: true });
1127
+ } else {
1128
+ // Fallback: drive selection via keyboard when modal-backdrop click interception
1129
+ // hides the rendered option. Brought forward from develop.
1130
+ await page.keyboard.type(kindLabel.charAt(0));
1131
+ await page.waitForTimeout(150);
1132
+ await page.keyboard.press('Enter');
1133
+ }
1134
+ await expect(kindTrigger).toContainText(kindLabel, { timeout: 2_000 }).catch(() => {});
1120
1135
  }
1121
- await expect(kindTrigger).toContainText(kindLabel, { timeout: 2_000 });
1136
+ await expect(kindTrigger).toContainText(kindLabel, { timeout: 5_000 });
1122
1137
  };
1123
1138
 
1124
1139
  // Select kind first — the Radix Select interaction can trigger CrudForm re-renders that
@@ -122,6 +122,18 @@
122
122
  "attachments.partitions.form.ocrModelOptions.gpt-4o": "GPT-4o (Empfohlen)",
123
123
  "attachments.partitions.form.ocrModelOptions.gpt-4o-mini": "GPT-4o Mini (Schneller, günstiger)",
124
124
  "attachments.partitions.form.publicLabel": "Öffentlich zugänglich",
125
+ "attachments.partitions.form.s3BucketLabel": "Bucket",
126
+ "attachments.partitions.form.s3BucketOverrideLabel": "Bucket überschreiben (optional)",
127
+ "attachments.partitions.form.s3ConfigTitle": "S3-Konfiguration",
128
+ "attachments.partitions.form.s3CredsPrefixLabel": "Präfix für Zugangsdaten-Umgebungsvariablen",
129
+ "attachments.partitions.form.s3CredsSourceLabel": "Quelle der Zugangsdaten",
130
+ "attachments.partitions.form.s3EndpointHelp": "Für AWS S3 leer lassen. Erforderlich für MinIO, DigitalOcean Spaces usw.",
131
+ "attachments.partitions.form.s3EndpointLabel": "Benutzerdefinierter Endpoint",
132
+ "attachments.partitions.form.s3EndpointOverrideLabel": "Benutzerdefinierten Endpoint überschreiben (optional)",
133
+ "attachments.partitions.form.s3ForcePathStyleLabel": "Path-Style erzwingen (für MinIO erforderlich)",
134
+ "attachments.partitions.form.s3RegionLabel": "Region",
135
+ "attachments.partitions.form.s3RegionOverrideLabel": "Region überschreiben (optional)",
136
+ "attachments.partitions.form.storageDriverLabel": "Speichertreiber",
125
137
  "attachments.partitions.form.titleLabel": "Titel",
126
138
  "attachments.partitions.form.titlePlaceholder": "z. B. Marketing-Assets",
127
139
  "attachments.partitions.locked.description": "Anhang-Partitionen werden durch Umgebungsstandardwerte verwaltet, solange der Demo- oder Onboarding-Modus aktiv ist.",
@@ -139,6 +151,7 @@
139
151
  "attachments.partitions.table.private": "Privat",
140
152
  "attachments.partitions.table.public": "Öffentlich",
141
153
  "attachments.partitions.table.search": "Partitionen suchen…",
154
+ "attachments.partitions.table.storageDriver": "Speicher",
142
155
  "attachments.partitions.table.title": "Titel",
143
156
  "attachments.partitions.table.visibility": "Sichtbarkeit",
144
157
  "attachments.partitions.title": "Anhang-Partitionen",
@@ -122,6 +122,18 @@
122
122
  "attachments.partitions.form.ocrModelOptions.gpt-4o": "GPT-4o (Recommended)",
123
123
  "attachments.partitions.form.ocrModelOptions.gpt-4o-mini": "GPT-4o Mini (Faster, Lower Cost)",
124
124
  "attachments.partitions.form.publicLabel": "Publicly accessible",
125
+ "attachments.partitions.form.s3BucketLabel": "Bucket",
126
+ "attachments.partitions.form.s3BucketOverrideLabel": "Bucket override (optional)",
127
+ "attachments.partitions.form.s3ConfigTitle": "S3 Configuration",
128
+ "attachments.partitions.form.s3CredsPrefixLabel": "Credentials Env Prefix",
129
+ "attachments.partitions.form.s3CredsSourceLabel": "Credentials source",
130
+ "attachments.partitions.form.s3EndpointHelp": "Leave empty for AWS S3. Required for MinIO, DigitalOcean Spaces, etc.",
131
+ "attachments.partitions.form.s3EndpointLabel": "Custom Endpoint",
132
+ "attachments.partitions.form.s3EndpointOverrideLabel": "Custom endpoint override (optional)",
133
+ "attachments.partitions.form.s3ForcePathStyleLabel": "Force Path Style (required for MinIO)",
134
+ "attachments.partitions.form.s3RegionLabel": "Region",
135
+ "attachments.partitions.form.s3RegionOverrideLabel": "Region override (optional)",
136
+ "attachments.partitions.form.storageDriverLabel": "Storage Driver",
125
137
  "attachments.partitions.form.titleLabel": "Title",
126
138
  "attachments.partitions.form.titlePlaceholder": "e.g. Marketing assets",
127
139
  "attachments.partitions.locked.description": "Attachment partitions are managed by environment defaults while demo or onboarding mode is enabled.",
@@ -139,6 +151,7 @@
139
151
  "attachments.partitions.table.private": "Private",
140
152
  "attachments.partitions.table.public": "Public",
141
153
  "attachments.partitions.table.search": "Search partitions…",
154
+ "attachments.partitions.table.storageDriver": "Storage",
142
155
  "attachments.partitions.table.title": "Title",
143
156
  "attachments.partitions.table.visibility": "Visibility",
144
157
  "attachments.partitions.title": "Attachment partitions",
@@ -122,6 +122,18 @@
122
122
  "attachments.partitions.form.ocrModelOptions.gpt-4o": "GPT-4o (Recomendado)",
123
123
  "attachments.partitions.form.ocrModelOptions.gpt-4o-mini": "GPT-4o Mini (Más rápido, menor costo)",
124
124
  "attachments.partitions.form.publicLabel": "Acceso público",
125
+ "attachments.partitions.form.s3BucketLabel": "Bucket",
126
+ "attachments.partitions.form.s3BucketOverrideLabel": "Sobrescribir bucket (opcional)",
127
+ "attachments.partitions.form.s3ConfigTitle": "Configuración S3",
128
+ "attachments.partitions.form.s3CredsPrefixLabel": "Prefijo de variables de entorno de credenciales",
129
+ "attachments.partitions.form.s3CredsSourceLabel": "Origen de credenciales",
130
+ "attachments.partitions.form.s3EndpointHelp": "Déjelo vacío para AWS S3. Es obligatorio para MinIO, DigitalOcean Spaces, etc.",
131
+ "attachments.partitions.form.s3EndpointLabel": "Endpoint personalizado",
132
+ "attachments.partitions.form.s3EndpointOverrideLabel": "Sobrescribir endpoint personalizado (opcional)",
133
+ "attachments.partitions.form.s3ForcePathStyleLabel": "Forzar Path Style (obligatorio para MinIO)",
134
+ "attachments.partitions.form.s3RegionLabel": "Región",
135
+ "attachments.partitions.form.s3RegionOverrideLabel": "Sobrescribir región (opcional)",
136
+ "attachments.partitions.form.storageDriverLabel": "Controlador de almacenamiento",
125
137
  "attachments.partitions.form.titleLabel": "Título",
126
138
  "attachments.partitions.form.titlePlaceholder": "ej. Recursos de marketing",
127
139
  "attachments.partitions.locked.description": "Las particiones de archivos adjuntos están administradas por valores predeterminados del entorno mientras el modo demo o de incorporación está activo.",
@@ -139,6 +151,7 @@
139
151
  "attachments.partitions.table.private": "Privado",
140
152
  "attachments.partitions.table.public": "Público",
141
153
  "attachments.partitions.table.search": "Buscar particiones…",
154
+ "attachments.partitions.table.storageDriver": "Almacenamiento",
142
155
  "attachments.partitions.table.title": "Título",
143
156
  "attachments.partitions.table.visibility": "Visibilidad",
144
157
  "attachments.partitions.title": "Particiones de archivos adjuntos",
@@ -122,6 +122,18 @@
122
122
  "attachments.partitions.form.ocrModelOptions.gpt-4o": "GPT-4o (zalecany)",
123
123
  "attachments.partitions.form.ocrModelOptions.gpt-4o-mini": "GPT-4o Mini (szybszy, niższy koszt)",
124
124
  "attachments.partitions.form.publicLabel": "Publicznie dostępna",
125
+ "attachments.partitions.form.s3BucketLabel": "Bucket",
126
+ "attachments.partitions.form.s3BucketOverrideLabel": "Nadpisanie bucketu (opcjonalne)",
127
+ "attachments.partitions.form.s3ConfigTitle": "Konfiguracja S3",
128
+ "attachments.partitions.form.s3CredsPrefixLabel": "Prefiks zmiennych środowiskowych poświadczeń",
129
+ "attachments.partitions.form.s3CredsSourceLabel": "Źródło poświadczeń",
130
+ "attachments.partitions.form.s3EndpointHelp": "Pozostaw puste dla AWS S3. Wymagane dla MinIO, DigitalOcean Spaces itp.",
131
+ "attachments.partitions.form.s3EndpointLabel": "Niestandardowy endpoint",
132
+ "attachments.partitions.form.s3EndpointOverrideLabel": "Nadpisanie niestandardowego endpointu (opcjonalne)",
133
+ "attachments.partitions.form.s3ForcePathStyleLabel": "Wymuś Path Style (wymagane dla MinIO)",
134
+ "attachments.partitions.form.s3RegionLabel": "Region",
135
+ "attachments.partitions.form.s3RegionOverrideLabel": "Nadpisanie regionu (opcjonalne)",
136
+ "attachments.partitions.form.storageDriverLabel": "Sterownik magazynu",
125
137
  "attachments.partitions.form.titleLabel": "Tytuł",
126
138
  "attachments.partitions.form.titlePlaceholder": "np. Zasoby marketingowe",
127
139
  "attachments.partitions.locked.description": "Partycje załączników są zarządzane przez domyślne zmienne środowiskowe, gdy włączony jest tryb demonstracyjny lub onboardingowy.",
@@ -139,6 +151,7 @@
139
151
  "attachments.partitions.table.private": "Prywatna",
140
152
  "attachments.partitions.table.public": "Publiczna",
141
153
  "attachments.partitions.table.search": "Szukaj partycji…",
154
+ "attachments.partitions.table.storageDriver": "Magazyn",
142
155
  "attachments.partitions.table.title": "Tytuł",
143
156
  "attachments.partitions.table.visibility": "Widoczność",
144
157
  "attachments.partitions.title": "Partycje załączników",
@@ -281,6 +281,7 @@ const agent: AiAgentDefinition = {
281
281
  executionMode: 'chat',
282
282
  acceptedMediaTypes: ['image', 'pdf', 'file'],
283
283
  requiredFeatures: [...REQUIRED_FEATURES],
284
+ taskPlan: { enabled: true },
284
285
  readOnly: false,
285
286
  // Default for write-capable agents: every mutation must be confirmed by
286
287
  // the operator. Per-tenant override can downgrade to `read-only` to lock
@@ -339,7 +340,7 @@ const DEAL_ANALYZER_PROMPT_SECTIONS: DealAnalyzerPromptSection[] = [
339
340
  content: [
340
341
  'SCOPE',
341
342
  'Stay inside the customers module. Respect tenant and organization isolation.',
342
- 'ALWAYS call customers.analyze_deals as your FIRST tool call — do not skip this.',
343
+ 'ALWAYS call customers.analyze_deals as your FIRST domain tool — do not skip this.',
343
344
  'Reason about stalled deals: any deal with no activity for more than 14 days',
344
345
  'is considered stalled. For each stalled deal with a value greater than $5,000',
345
346
  'propose a stage move via customers.update_deal_stage.',
@@ -448,6 +449,7 @@ function buildDealAnalyzerPrepareStep() {
448
449
  'customers.list_activities',
449
450
  'search.hybrid_search',
450
451
  'meta.describe_agent',
452
+ 'meta.update_task_plan',
451
453
  ],
452
454
  }
453
455
  }
@@ -466,6 +468,7 @@ const dealAnalyzer: AiAgentDefinition = {
466
468
  executionMode: 'chat',
467
469
  executionEngine: 'stream-text',
468
470
  allowRuntimeOverride: true,
471
+ taskPlan: { enabled: true },
469
472
  readOnly: false,
470
473
  mutationPolicy: 'confirm-required',
471
474
  requiredFeatures: ['customers.deals.view'],
@@ -14,7 +14,7 @@
14
14
  * the spec's residual N+1 budget; deeper aggregation can earn a first-class
15
15
  * API later without touching the AI surface.
16
16
  */
17
- import type { EntityManager } from '@mikro-orm/postgresql'
17
+ import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
18
18
  import { z } from 'zod'
19
19
  import { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'
20
20
  import {
@@ -390,6 +390,15 @@ function recordVersionFromUpdatedAt(updatedAt: Date | null | undefined): string
390
390
  return value.toISOString()
391
391
  }
392
392
 
393
+ function titleStatus(value: string | null | undefined): string | undefined {
394
+ if (!value) return undefined
395
+ return value
396
+ .split(/[_\s-]+/)
397
+ .filter(Boolean)
398
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
399
+ .join(' ')
400
+ }
401
+
393
402
  async function loadDealWithStage(
394
403
  em: EntityManager,
395
404
  ctx: CustomersToolContext,
@@ -401,7 +410,7 @@ async function loadDealWithStage(
401
410
  const deal = await findOneWithDecryption<CustomerDeal>(
402
411
  em,
403
412
  CustomerDeal,
404
- where as any,
413
+ where as FilterQuery<CustomerDeal>,
405
414
  undefined,
406
415
  buildScope(ctx, tenantId),
407
416
  )
@@ -410,6 +419,26 @@ async function loadDealWithStage(
410
419
  return deal
411
420
  }
412
421
 
422
+ async function loadPipelineStage(
423
+ em: EntityManager,
424
+ ctx: CustomersToolContext,
425
+ tenantId: string,
426
+ stageId: string,
427
+ organizationId: string,
428
+ ): Promise<CustomerPipelineStage | null> {
429
+ return findOneWithDecryption<CustomerPipelineStage>(
430
+ em,
431
+ CustomerPipelineStage,
432
+ {
433
+ id: stageId,
434
+ tenantId,
435
+ organizationId,
436
+ },
437
+ undefined,
438
+ buildScope(ctx, tenantId),
439
+ )
440
+ }
441
+
413
442
  const updateDealStageTool: CustomersAiToolDefinition = {
414
443
  name: 'customers.update_deal_stage',
415
444
  displayName: 'Update deal stage',
@@ -425,14 +454,47 @@ const updateDealStageTool: CustomersAiToolDefinition = {
425
454
  const em = resolveEm(ctx)
426
455
  const deal = await loadDealWithStage(em, ctx, tenantId, input.dealId)
427
456
  if (!deal) return null
457
+ let afterStatus = deal.status ?? null
458
+ let afterPipelineStageId = deal.pipelineStageId ?? null
459
+ let afterPipelineStageLabel = deal.pipelineStage ?? null
460
+ if (input.toPipelineStageId) {
461
+ const organizationId = deal.organizationId ?? ctx.organizationId ?? null
462
+ const stage = organizationId
463
+ ? await loadPipelineStage(em, ctx, tenantId, input.toPipelineStageId, organizationId)
464
+ : null
465
+ afterPipelineStageId = input.toPipelineStageId
466
+ afterPipelineStageLabel = stage?.label ?? input.toPipelineStageId
467
+ } else if (input.toStage) {
468
+ afterStatus = input.toStage
469
+ }
470
+ const beforeStatus = deal.status ?? null
471
+ const beforePipelineStageId = deal.pipelineStageId ?? null
472
+ const beforePipelineStageLabel = deal.pipelineStage ?? beforePipelineStageId
428
473
  return {
429
474
  recordId: deal.id,
430
475
  entityType: 'customers.deal',
431
476
  recordVersion: recordVersionFromUpdatedAt(deal.updatedAt),
432
477
  before: {
433
- status: deal.status ?? null,
434
- pipelineStage: deal.pipelineStage ?? null,
435
- pipelineStageId: deal.pipelineStageId ?? null,
478
+ status: beforeStatus,
479
+ pipelineStageId: beforePipelineStageId,
480
+ },
481
+ after: {
482
+ status: afterStatus,
483
+ pipelineStageId: afterPipelineStageId,
484
+ },
485
+ display: {
486
+ fieldLabels: {
487
+ status: 'Status',
488
+ pipelineStageId: 'Pipeline stage',
489
+ },
490
+ before: {
491
+ ...(beforeStatus ? { status: titleStatus(beforeStatus) } : {}),
492
+ ...(beforePipelineStageLabel ? { pipelineStageId: beforePipelineStageLabel } : {}),
493
+ },
494
+ after: {
495
+ ...(afterStatus ? { status: titleStatus(afterStatus) } : {}),
496
+ ...(afterPipelineStageLabel ? { pipelineStageId: afterPipelineStageLabel } : {}),
497
+ },
436
498
  },
437
499
  }
438
500
  },
@@ -461,11 +523,7 @@ const updateDealStageTool: CustomersAiToolDefinition = {
461
523
  organizationId,
462
524
  }
463
525
  if (input.toPipelineStageId) {
464
- const stage = await em.findOne(CustomerPipelineStage, {
465
- id: input.toPipelineStageId,
466
- tenantId,
467
- organizationId,
468
- })
526
+ const stage = await loadPipelineStage(em, ctx, tenantId, input.toPipelineStageId, organizationId)
469
527
  if (!stage) {
470
528
  throw new Error('Pipeline stage not found.')
471
529
  }
@@ -34,6 +34,12 @@ export interface CustomersToolLoadBeforeSingleRecord {
34
34
  entityType: string
35
35
  recordVersion: string | null
36
36
  before: Record<string, unknown>
37
+ after?: Record<string, unknown>
38
+ display?: {
39
+ fieldLabels?: Record<string, string>
40
+ before?: Record<string, unknown>
41
+ after?: Record<string, unknown>
42
+ }
37
43
  }
38
44
 
39
45
  export interface CustomersAiToolDefinition<TInput = unknown, TOutput = unknown> {
@@ -452,7 +452,16 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
452
452
  })
453
453
  const em = (container.resolve('em') as EntityManager)
454
454
 
455
- const person = await findOneWithDecryption(em, CustomerEntity, { id: parse.data.id, kind: 'person', deletedAt: null }, {}, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null })
455
+ const person = await findOneWithDecryption(
456
+ em,
457
+ CustomerEntity,
458
+ { id: parse.data.id, kind: 'person', deletedAt: null },
459
+ {},
460
+ {
461
+ tenantId: scope?.tenantId ?? auth.tenantId ?? null,
462
+ organizationId: scope?.selectedId ?? auth.orgId ?? null,
463
+ },
464
+ )
456
465
  profiler.mark('person_loaded', { found: !!person })
457
466
  if (!person) {
458
467
  statusCode = 404
@@ -33,6 +33,10 @@ import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorato
33
33
  `create index "entity_indexes_customer_company_profile_tenant_doc_idx" on "entity_indexes" ("tenant_id", "entity_id") include ("doc") where deleted_at is null and entity_type = 'customers:customer_company_profile' and organization_id is null and tenant_id is not null`,
34
34
  })
35
35
  @Index({ name: 'entity_indexes_type_tenant_idx', properties: ['entityType', 'tenantId'] })
36
+ @Unique({
37
+ name: 'entity_indexes_type_entity_org_coalesced_unique',
38
+ properties: ['entityType', 'entityId', 'organizationIdCoalesced'],
39
+ })
36
40
  export class EntityIndexRow {
37
41
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
38
42
  id!: string
@@ -51,6 +55,14 @@ export class EntityIndexRow {
51
55
  @Index({ name: 'entity_indexes_org_idx' })
52
56
  organizationId?: string | null
53
57
 
58
+ @Property({
59
+ name: 'organization_id_coalesced',
60
+ type: 'uuid',
61
+ generated: (cols) => `(coalesce(${cols.organizationId}, '00000000-0000-0000-0000-000000000000'::uuid)) stored`,
62
+ hidden: true,
63
+ })
64
+ organizationIdCoalesced!: string
65
+
54
66
  @Property({ name: 'tenant_id', type: 'uuid', nullable: true })
55
67
  tenantId?: string | null
56
68