@open-mercato/core 0.7.1-develop.7175.1.d49ab48ee2 → 0.7.1-develop.7176.1.d229567a50

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 (79) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/phone_call/index.js +53 -0
  3. package/dist/generated/entities/phone_call/index.js.map +7 -0
  4. package/dist/generated/entities/phone_call_participant/index.js +27 -0
  5. package/dist/generated/entities/phone_call_participant/index.js.map +7 -0
  6. package/dist/generated/entities.ids.generated.js +5 -0
  7. package/dist/generated/entities.ids.generated.js.map +2 -2
  8. package/dist/generated/entity-fields-registry.js +41 -0
  9. package/dist/generated/entity-fields-registry.js.map +2 -2
  10. package/dist/modules/configs/lib/upgrade-actions.js +20 -0
  11. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  12. package/dist/modules/entities/migrations/Migration20260822120000.js +44 -0
  13. package/dist/modules/entities/migrations/Migration20260822120000.js.map +7 -0
  14. package/dist/modules/phone_calls/acl.js +15 -0
  15. package/dist/modules/phone_calls/acl.js.map +7 -0
  16. package/dist/modules/phone_calls/api/calls/route.js +132 -0
  17. package/dist/modules/phone_calls/api/calls/route.js.map +7 -0
  18. package/dist/modules/phone_calls/api/openapi.js +23 -0
  19. package/dist/modules/phone_calls/api/openapi.js.map +7 -0
  20. package/dist/modules/phone_calls/backend/page.js +261 -0
  21. package/dist/modules/phone_calls/backend/page.js.map +7 -0
  22. package/dist/modules/phone_calls/backend/page.meta.js +16 -0
  23. package/dist/modules/phone_calls/backend/page.meta.js.map +7 -0
  24. package/dist/modules/phone_calls/commands/calls.js +125 -0
  25. package/dist/modules/phone_calls/commands/calls.js.map +7 -0
  26. package/dist/modules/phone_calls/commands/index.js +2 -0
  27. package/dist/modules/phone_calls/commands/index.js.map +7 -0
  28. package/dist/modules/phone_calls/data/entities.js +162 -0
  29. package/dist/modules/phone_calls/data/entities.js.map +7 -0
  30. package/dist/modules/phone_calls/data/validators.js +68 -0
  31. package/dist/modules/phone_calls/data/validators.js.map +7 -0
  32. package/dist/modules/phone_calls/di.js +12 -0
  33. package/dist/modules/phone_calls/di.js.map +7 -0
  34. package/dist/modules/phone_calls/encryption.js +28 -0
  35. package/dist/modules/phone_calls/encryption.js.map +7 -0
  36. package/dist/modules/phone_calls/events.js +18 -0
  37. package/dist/modules/phone_calls/events.js.map +7 -0
  38. package/dist/modules/phone_calls/index.js +15 -0
  39. package/dist/modules/phone_calls/index.js.map +7 -0
  40. package/dist/modules/phone_calls/migrations/Migration20260706124120_phone_calls.js +22 -0
  41. package/dist/modules/phone_calls/migrations/Migration20260706124120_phone_calls.js.map +7 -0
  42. package/dist/modules/phone_calls/migrations/Migration20260814124333_phone_calls.js +13 -0
  43. package/dist/modules/phone_calls/migrations/Migration20260814124333_phone_calls.js.map +7 -0
  44. package/dist/modules/phone_calls/setup.js +16 -0
  45. package/dist/modules/phone_calls/setup.js.map +7 -0
  46. package/generated/entities/phone_call/index.ts +25 -0
  47. package/generated/entities/phone_call_participant/index.ts +12 -0
  48. package/generated/entities.ids.generated.ts +5 -0
  49. package/generated/entity-fields-registry.ts +41 -0
  50. package/package.json +7 -7
  51. package/src/modules/auth/i18n/de.json +3 -0
  52. package/src/modules/auth/i18n/en.json +3 -0
  53. package/src/modules/auth/i18n/es.json +3 -0
  54. package/src/modules/auth/i18n/ko.json +3 -0
  55. package/src/modules/auth/i18n/pl.json +3 -0
  56. package/src/modules/configs/lib/upgrade-actions.ts +20 -0
  57. package/src/modules/entities/migrations/Migration20260822120000.ts +56 -0
  58. package/src/modules/phone_calls/acl.ts +11 -0
  59. package/src/modules/phone_calls/api/calls/route.ts +150 -0
  60. package/src/modules/phone_calls/api/openapi.ts +25 -0
  61. package/src/modules/phone_calls/backend/page.meta.ts +12 -0
  62. package/src/modules/phone_calls/backend/page.tsx +303 -0
  63. package/src/modules/phone_calls/commands/calls.ts +156 -0
  64. package/src/modules/phone_calls/commands/index.ts +1 -0
  65. package/src/modules/phone_calls/data/entities.ts +144 -0
  66. package/src/modules/phone_calls/data/validators.ts +69 -0
  67. package/src/modules/phone_calls/di.ts +10 -0
  68. package/src/modules/phone_calls/encryption.ts +26 -0
  69. package/src/modules/phone_calls/events.ts +25 -0
  70. package/src/modules/phone_calls/i18n/de.json +42 -0
  71. package/src/modules/phone_calls/i18n/en.json +42 -0
  72. package/src/modules/phone_calls/i18n/es.json +42 -0
  73. package/src/modules/phone_calls/i18n/ko.json +42 -0
  74. package/src/modules/phone_calls/i18n/pl.json +42 -0
  75. package/src/modules/phone_calls/index.ts +13 -0
  76. package/src/modules/phone_calls/migrations/.snapshot-open-mercato.json +772 -0
  77. package/src/modules/phone_calls/migrations/Migration20260706124120_phone_calls.ts +23 -0
  78. package/src/modules/phone_calls/migrations/Migration20260814124333_phone_calls.ts +13 -0
  79. package/src/modules/phone_calls/setup.ts +14 -0
@@ -94,6 +94,26 @@ const upgradeActions = [
94
94
  const { registerSessionInitializationPruneSchedule } = await import("@open-mercato/core/modules/payment_gateways/setup");
95
95
  await registerSessionInitializationPruneSchedule(container, { tenantId, organizationId });
96
96
  }
97
+ },
98
+ {
99
+ id: "phone_calls.seed-call-encryption-maps",
100
+ version: "0.7.1",
101
+ messageKey: "phone_calls.config.upgradeActions.encryptionMaps.message",
102
+ ctaKey: "phone_calls.config.upgradeActions.encryptionMaps.cta",
103
+ successKey: "phone_calls.config.upgradeActions.encryptionMaps.success",
104
+ loadingKey: "phone_calls.config.upgradeActions.encryptionMaps.loading",
105
+ // `Migration20260822120000` backfills these maps, but only for scopes that had active maps when
106
+ // it ran. A tenant that upgraded with encryption disabled and turned it on afterwards is left
107
+ // without a map, so ingest writes call PII as plaintext with nothing to signal it. This is the
108
+ // heal for that case, and the same re-assert path devices exposes for its own map.
109
+ // Lazy-imported so configs stays decoupled from phone_calls/entities (mirrors the devices action).
110
+ run: async ({ em, tenantId, organizationId }) => {
111
+ const [{ default: phoneCallsEncryptionMaps }, { upsertEncryptionMapSpecs }] = await Promise.all([
112
+ import("@open-mercato/core/modules/phone_calls/encryption"),
113
+ import("@open-mercato/core/modules/entities/cli")
114
+ ]);
115
+ await upsertEncryptionMapSpecs(em, tenantId, organizationId ?? null, phoneCallsEncryptionMaps);
116
+ }
97
117
  }
98
118
  ];
99
119
  function actionsUpToVersion(version) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/configs/lib/upgrade-actions.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport * as semver from 'semver'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport { reconcileAttachmentOrganizations } from '@open-mercato/core/modules/attachments/lib/reconcileOrganization'\n\nconst logger = createLogger('configs').child({ component: 'upgrade-actions' })\n\nexport type UpgradeActionContext = {\n tenantId: string\n organizationId: string\n container: AppContainer\n em: EntityManager\n}\n\nexport type UpgradeActionDefinition = {\n id: string\n version: string\n messageKey: string\n ctaKey: string\n successKey: string\n loadingKey?: string\n run: (ctx: UpgradeActionContext) => Promise<void>\n}\n\n/**\n * Compare two semantic version strings.\n * Uses the semver library for robust version comparison.\n * Returns negative if a < b, positive if a > b, 0 if equal.\n * Throws an error if either version string is invalid.\n */\nexport function compareVersions(a: string, b: string): number {\n const cleanA = semver.valid(semver.coerce(a))\n const cleanB = semver.valid(semver.coerce(b))\n if (!cleanA) {\n throw new Error(`Invalid version string: \"${a}\". Expected a valid semver format (e.g., \"1.2.3\").`)\n }\n if (!cleanB) {\n throw new Error(`Invalid version string: \"${b}\". Expected a valid semver format (e.g., \"1.2.3\").`)\n }\n return semver.compare(cleanA, cleanB)\n}\n\nexport const upgradeActions: UpgradeActionDefinition[] = [\n {\n id: 'attachments.reconcile-organization',\n version: '0.6.6',\n messageKey: 'configs.upgrades.attachmentsOrgReconcile.message',\n ctaKey: 'configs.upgrades.attachmentsOrgReconcile.cta',\n successKey: 'configs.upgrades.attachmentsOrgReconcile.success',\n loadingKey: 'configs.upgrades.attachmentsOrgReconcile.loading',\n async run({ container, em, tenantId }) {\n const queryEngine = container.resolve('queryEngine') as QueryEngine\n const report = await reconcileAttachmentOrganizations({ em, queryEngine, tenantId })\n logger.info('attachments organization reconcile completed', {\n tenantId,\n scanned: report.scanned,\n updated: report.updated,\n unresolved: report.unresolved,\n skippedVirtual: report.skippedVirtual,\n })\n },\n },\n {\n id: 'devices.seed-push-token-encryption-map',\n version: '0.6.6',\n messageKey: 'configs.upgrades.devicesPushTokenEncryption.message',\n ctaKey: 'configs.upgrades.devicesPushTokenEncryption.cta',\n successKey: 'configs.upgrades.devicesPushTokenEncryption.success',\n loadingKey: 'configs.upgrades.devicesPushTokenEncryption.loading',\n // Encryption maps are seeded once at tenant creation (`entities seed-encryption`), so a tenant\n // that predates the devices push-token feature has no `devices:user_device` map row. Without it\n // `encryptEntityPayload` no-ops and `push_token` is written as PLAINTEXT (the map is a declaration\n // of which fields to encrypt; the tenant DEK still drives the actual crypto, so seeding it is safe\n // even when encryption is currently disabled \u2014 it only takes effect once encryption is on).\n // Lazy-imported so configs stays decoupled from devices/entities (mirrors the customers action).\n run: async ({ em, tenantId, organizationId }) => {\n const [{ default: devicesEncryptionMaps }, { upsertEncryptionMapSpecs }] = await Promise.all([\n import('@open-mercato/core/modules/devices/encryption'),\n import('@open-mercato/core/modules/entities/cli'),\n ])\n await upsertEncryptionMapSpecs(em, tenantId, organizationId ?? null, devicesEncryptionMaps)\n },\n },\n {\n id: 'customers.seed-interaction-statuses',\n version: '0.6.5',\n messageKey: 'customers.config.upgradeActions.interactionStatuses.message',\n ctaKey: 'customers.config.upgradeActions.interactionStatuses.cta',\n successKey: 'customers.config.upgradeActions.interactionStatuses.success',\n loadingKey: 'customers.config.upgradeActions.interactionStatuses.loading',\n // Existing tenants predate the `interaction_status` dictionary, so their status\n // dropdown is empty until seeded. New tenants get it via customers `seedDefaults`.\n // Lazy-imported so configs stays decoupled from customers (the catalog of actions\n // lives here, but customers code only loads when the action actually runs).\n run: async ({ em, tenantId, organizationId }) => {\n const [{ INTERACTION_STATUS_DEFAULTS }, { ensureDictionaryEntry }] = await Promise.all([\n import('@open-mercato/core/modules/customers/cli'),\n import('@open-mercato/core/modules/customers/commands/shared'),\n ])\n for (const entry of INTERACTION_STATUS_DEFAULTS) {\n await ensureDictionaryEntry(em, {\n tenantId,\n organizationId,\n kind: 'interaction_status',\n value: entry.value,\n label: entry.label,\n color: entry.color,\n icon: entry.icon,\n })\n }\n },\n },\n {\n id: 'payment_gateways.register-session-initialization-prune',\n version: '0.6.6',\n messageKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.message',\n ctaKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.cta',\n successKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.success',\n loadingKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.loading',\n run: async ({ container, tenantId, organizationId }) => {\n const { registerSessionInitializationPruneSchedule } = await import(\n '@open-mercato/core/modules/payment_gateways/setup'\n )\n await registerSessionInitializationPruneSchedule(container, { tenantId, organizationId })\n },\n },\n]\n\nexport function actionsUpToVersion(version: string): UpgradeActionDefinition[] {\n return upgradeActions // NOSONAR \u2014 upgradeActions is populated at boot time by modules\n .filter((action) => compareVersions(action.version, version) <= 0)\n .sort((a, b) => compareVersions(a.version, b.version) || a.id.localeCompare(b.id))\n}\n\nexport function findUpgradeAction(actionId: string, maxVersion: string): UpgradeActionDefinition | undefined {\n const matches = actionsUpToVersion(maxVersion).filter((action) => action.id === actionId)\n if (!matches.length) return undefined\n return matches[matches.length - 1]\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport * as semver from 'semver'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport { reconcileAttachmentOrganizations } from '@open-mercato/core/modules/attachments/lib/reconcileOrganization'\n\nconst logger = createLogger('configs').child({ component: 'upgrade-actions' })\n\nexport type UpgradeActionContext = {\n tenantId: string\n organizationId: string\n container: AppContainer\n em: EntityManager\n}\n\nexport type UpgradeActionDefinition = {\n id: string\n version: string\n messageKey: string\n ctaKey: string\n successKey: string\n loadingKey?: string\n run: (ctx: UpgradeActionContext) => Promise<void>\n}\n\n/**\n * Compare two semantic version strings.\n * Uses the semver library for robust version comparison.\n * Returns negative if a < b, positive if a > b, 0 if equal.\n * Throws an error if either version string is invalid.\n */\nexport function compareVersions(a: string, b: string): number {\n const cleanA = semver.valid(semver.coerce(a))\n const cleanB = semver.valid(semver.coerce(b))\n if (!cleanA) {\n throw new Error(`Invalid version string: \"${a}\". Expected a valid semver format (e.g., \"1.2.3\").`)\n }\n if (!cleanB) {\n throw new Error(`Invalid version string: \"${b}\". Expected a valid semver format (e.g., \"1.2.3\").`)\n }\n return semver.compare(cleanA, cleanB)\n}\n\nexport const upgradeActions: UpgradeActionDefinition[] = [\n {\n id: 'attachments.reconcile-organization',\n version: '0.6.6',\n messageKey: 'configs.upgrades.attachmentsOrgReconcile.message',\n ctaKey: 'configs.upgrades.attachmentsOrgReconcile.cta',\n successKey: 'configs.upgrades.attachmentsOrgReconcile.success',\n loadingKey: 'configs.upgrades.attachmentsOrgReconcile.loading',\n async run({ container, em, tenantId }) {\n const queryEngine = container.resolve('queryEngine') as QueryEngine\n const report = await reconcileAttachmentOrganizations({ em, queryEngine, tenantId })\n logger.info('attachments organization reconcile completed', {\n tenantId,\n scanned: report.scanned,\n updated: report.updated,\n unresolved: report.unresolved,\n skippedVirtual: report.skippedVirtual,\n })\n },\n },\n {\n id: 'devices.seed-push-token-encryption-map',\n version: '0.6.6',\n messageKey: 'configs.upgrades.devicesPushTokenEncryption.message',\n ctaKey: 'configs.upgrades.devicesPushTokenEncryption.cta',\n successKey: 'configs.upgrades.devicesPushTokenEncryption.success',\n loadingKey: 'configs.upgrades.devicesPushTokenEncryption.loading',\n // Encryption maps are seeded once at tenant creation (`entities seed-encryption`), so a tenant\n // that predates the devices push-token feature has no `devices:user_device` map row. Without it\n // `encryptEntityPayload` no-ops and `push_token` is written as PLAINTEXT (the map is a declaration\n // of which fields to encrypt; the tenant DEK still drives the actual crypto, so seeding it is safe\n // even when encryption is currently disabled \u2014 it only takes effect once encryption is on).\n // Lazy-imported so configs stays decoupled from devices/entities (mirrors the customers action).\n run: async ({ em, tenantId, organizationId }) => {\n const [{ default: devicesEncryptionMaps }, { upsertEncryptionMapSpecs }] = await Promise.all([\n import('@open-mercato/core/modules/devices/encryption'),\n import('@open-mercato/core/modules/entities/cli'),\n ])\n await upsertEncryptionMapSpecs(em, tenantId, organizationId ?? null, devicesEncryptionMaps)\n },\n },\n {\n id: 'customers.seed-interaction-statuses',\n version: '0.6.5',\n messageKey: 'customers.config.upgradeActions.interactionStatuses.message',\n ctaKey: 'customers.config.upgradeActions.interactionStatuses.cta',\n successKey: 'customers.config.upgradeActions.interactionStatuses.success',\n loadingKey: 'customers.config.upgradeActions.interactionStatuses.loading',\n // Existing tenants predate the `interaction_status` dictionary, so their status\n // dropdown is empty until seeded. New tenants get it via customers `seedDefaults`.\n // Lazy-imported so configs stays decoupled from customers (the catalog of actions\n // lives here, but customers code only loads when the action actually runs).\n run: async ({ em, tenantId, organizationId }) => {\n const [{ INTERACTION_STATUS_DEFAULTS }, { ensureDictionaryEntry }] = await Promise.all([\n import('@open-mercato/core/modules/customers/cli'),\n import('@open-mercato/core/modules/customers/commands/shared'),\n ])\n for (const entry of INTERACTION_STATUS_DEFAULTS) {\n await ensureDictionaryEntry(em, {\n tenantId,\n organizationId,\n kind: 'interaction_status',\n value: entry.value,\n label: entry.label,\n color: entry.color,\n icon: entry.icon,\n })\n }\n },\n },\n {\n id: 'payment_gateways.register-session-initialization-prune',\n version: '0.6.6',\n messageKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.message',\n ctaKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.cta',\n successKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.success',\n loadingKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.loading',\n run: async ({ container, tenantId, organizationId }) => {\n const { registerSessionInitializationPruneSchedule } = await import(\n '@open-mercato/core/modules/payment_gateways/setup'\n )\n await registerSessionInitializationPruneSchedule(container, { tenantId, organizationId })\n },\n },\n {\n id: 'phone_calls.seed-call-encryption-maps',\n version: '0.7.1',\n messageKey: 'phone_calls.config.upgradeActions.encryptionMaps.message',\n ctaKey: 'phone_calls.config.upgradeActions.encryptionMaps.cta',\n successKey: 'phone_calls.config.upgradeActions.encryptionMaps.success',\n loadingKey: 'phone_calls.config.upgradeActions.encryptionMaps.loading',\n // `Migration20260822120000` backfills these maps, but only for scopes that had active maps when\n // it ran. A tenant that upgraded with encryption disabled and turned it on afterwards is left\n // without a map, so ingest writes call PII as plaintext with nothing to signal it. This is the\n // heal for that case, and the same re-assert path devices exposes for its own map.\n // Lazy-imported so configs stays decoupled from phone_calls/entities (mirrors the devices action).\n run: async ({ em, tenantId, organizationId }) => {\n const [{ default: phoneCallsEncryptionMaps }, { upsertEncryptionMapSpecs }] = await Promise.all([\n import('@open-mercato/core/modules/phone_calls/encryption'),\n import('@open-mercato/core/modules/entities/cli'),\n ])\n await upsertEncryptionMapSpecs(em, tenantId, organizationId ?? null, phoneCallsEncryptionMaps)\n },\n },\n]\n\nexport function actionsUpToVersion(version: string): UpgradeActionDefinition[] {\n return upgradeActions // NOSONAR \u2014 upgradeActions is populated at boot time by modules\n .filter((action) => compareVersions(action.version, version) <= 0)\n .sort((a, b) => compareVersions(a.version, b.version) || a.id.localeCompare(b.id))\n}\n\nexport function findUpgradeAction(actionId: string, maxVersion: string): UpgradeActionDefinition | undefined {\n const matches = actionsUpToVersion(maxVersion).filter((action) => action.id === actionId)\n if (!matches.length) return undefined\n return matches[matches.length - 1]\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,44 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20260822120000 extends Migration {
3
+ async up() {
4
+ this.addSql(`
5
+ insert into "encryption_maps" ("id", "entity_id", "tenant_id", "organization_id", "fields_json", "is_active", "created_at", "updated_at")
6
+ select gen_random_uuid(), 'phone_calls:phone_call', src."tenant_id", src."organization_id", '[{"field":"raw_snapshot"},{"field":"provider_facts"},{"field":"recording_url"}]'::jsonb, true, now(), now()
7
+ from (
8
+ select distinct "tenant_id", "organization_id"
9
+ from "encryption_maps"
10
+ where "is_active" = true and "deleted_at" is null
11
+ ) src
12
+ where not exists (
13
+ select 1 from "encryption_maps" existing
14
+ where existing."entity_id" = 'phone_calls:phone_call'
15
+ and existing."tenant_id" is not distinct from src."tenant_id"
16
+ and existing."organization_id" is not distinct from src."organization_id"
17
+ and existing."deleted_at" is null
18
+ );
19
+ `);
20
+ this.addSql(`
21
+ insert into "encryption_maps" ("id", "entity_id", "tenant_id", "organization_id", "fields_json", "is_active", "created_at", "updated_at")
22
+ select gen_random_uuid(), 'phone_calls:phone_call_participant', src."tenant_id", src."organization_id", '[{"field":"phone_number"},{"field":"display_name"},{"field":"email"}]'::jsonb, true, now(), now()
23
+ from (
24
+ select distinct "tenant_id", "organization_id"
25
+ from "encryption_maps"
26
+ where "is_active" = true and "deleted_at" is null
27
+ ) src
28
+ where not exists (
29
+ select 1 from "encryption_maps" existing
30
+ where existing."entity_id" = 'phone_calls:phone_call_participant'
31
+ and existing."tenant_id" is not distinct from src."tenant_id"
32
+ and existing."organization_id" is not distinct from src."organization_id"
33
+ and existing."deleted_at" is null
34
+ );
35
+ `);
36
+ }
37
+ async down() {
38
+ this.addSql(`delete from "encryption_maps" where "entity_id" in ('phone_calls:phone_call', 'phone_calls:phone_call_participant');`);
39
+ }
40
+ }
41
+ export {
42
+ Migration20260822120000
43
+ };
44
+ //# sourceMappingURL=Migration20260822120000.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/entities/migrations/Migration20260822120000.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\n// Backfill the phone_calls encryption maps for every pre-existing (tenant, org) scope that already\n// has active encryption maps. Encryption maps are seeded only at tenant creation (`entities\n// seed-encryption`), so a tenant that predates the phone_calls module has no map for either of its\n// entities, `encryptEntityPayload` no-ops, and caller/destination numbers plus the untouched Tillio\n// payload are written as PLAINTEXT, both in the base tables and in the query index doc. Restricting\n// the insert to scopes that ALREADY have maps mirrors what seed-encryption does and correctly skips\n// tenants with encryption disabled (which have no maps at all). Idempotent via the NOT EXISTS guard,\n// so re-runs are a no-op and it stays consistent with the runtime `upsertEncryptionMapSpecs` helper.\n// Same shape as the `devices:user_device` backfill in Migration20260722120000.\nexport class Migration20260822120000 extends Migration {\n\n override async up(): Promise<void> {\n this.addSql(`\n insert into \"encryption_maps\" (\"id\", \"entity_id\", \"tenant_id\", \"organization_id\", \"fields_json\", \"is_active\", \"created_at\", \"updated_at\")\n select gen_random_uuid(), 'phone_calls:phone_call', src.\"tenant_id\", src.\"organization_id\", '[{\"field\":\"raw_snapshot\"},{\"field\":\"provider_facts\"},{\"field\":\"recording_url\"}]'::jsonb, true, now(), now()\n from (\n select distinct \"tenant_id\", \"organization_id\"\n from \"encryption_maps\"\n where \"is_active\" = true and \"deleted_at\" is null\n ) src\n where not exists (\n select 1 from \"encryption_maps\" existing\n where existing.\"entity_id\" = 'phone_calls:phone_call'\n and existing.\"tenant_id\" is not distinct from src.\"tenant_id\"\n and existing.\"organization_id\" is not distinct from src.\"organization_id\"\n and existing.\"deleted_at\" is null\n );\n `);\n\n this.addSql(`\n insert into \"encryption_maps\" (\"id\", \"entity_id\", \"tenant_id\", \"organization_id\", \"fields_json\", \"is_active\", \"created_at\", \"updated_at\")\n select gen_random_uuid(), 'phone_calls:phone_call_participant', src.\"tenant_id\", src.\"organization_id\", '[{\"field\":\"phone_number\"},{\"field\":\"display_name\"},{\"field\":\"email\"}]'::jsonb, true, now(), now()\n from (\n select distinct \"tenant_id\", \"organization_id\"\n from \"encryption_maps\"\n where \"is_active\" = true and \"deleted_at\" is null\n ) src\n where not exists (\n select 1 from \"encryption_maps\" existing\n where existing.\"entity_id\" = 'phone_calls:phone_call_participant'\n and existing.\"tenant_id\" is not distinct from src.\"tenant_id\"\n and existing.\"organization_id\" is not distinct from src.\"organization_id\"\n and existing.\"deleted_at\" is null\n );\n `);\n }\n\n override async down(): Promise<void> {\n // Reverts the feature's encryption maps. phone_calls maps have no source other than this module,\n // so deleting them all returns the schema to its pre-feature state.\n this.addSql(`delete from \"encryption_maps\" where \"entity_id\" in ('phone_calls:phone_call', 'phone_calls:phone_call_participant');`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAWnB,MAAM,gCAAgC,UAAU;AAAA,EAErD,MAAe,KAAoB;AACjC,SAAK,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAeX;AAED,SAAK,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,KAeX;AAAA,EACH;AAAA,EAEA,MAAe,OAAsB;AAGnC,SAAK,OAAO,sHAAsH;AAAA,EACpI;AAEF;",
6
+ "names": []
7
+ }
@@ -0,0 +1,15 @@
1
+ const features = [
2
+ { id: "phone_calls.view", title: "View phone calls", module: "phone_calls" },
3
+ {
4
+ id: "phone_calls.manage",
5
+ title: "Manage phone calls (pull, raw payload)",
6
+ module: "phone_calls",
7
+ dependsOn: ["phone_calls.view"]
8
+ }
9
+ ];
10
+ var acl_default = features;
11
+ export {
12
+ acl_default as default,
13
+ features
14
+ };
15
+ //# sourceMappingURL=acl.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/phone_calls/acl.ts"],
4
+ "sourcesContent": ["export const features = [\n { id: 'phone_calls.view', title: 'View phone calls', module: 'phone_calls' },\n {\n id: 'phone_calls.manage',\n title: 'Manage phone calls (pull, raw payload)',\n module: 'phone_calls',\n dependsOn: ['phone_calls.view'],\n },\n]\n\nexport default features\n"],
5
+ "mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,EAAE,IAAI,oBAAoB,OAAO,oBAAoB,QAAQ,cAAc;AAAA,EAC3E;AAAA,IACE,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,WAAW,CAAC,kBAAkB;AAAA,EAChC;AACF;AAEA,IAAO,cAAQ;",
6
+ "names": []
7
+ }
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import { makeCrudRoute } from "@open-mercato/shared/lib/crud/factory";
3
+ import { buildIlikeTerm } from "@open-mercato/shared/lib/db/buildIlikeTerm";
4
+ import { E } from "../../../../generated/entities.ids.generated.js";
5
+ import { PhoneCall } from "../../data/entities.js";
6
+ import { createPhoneCallsCrudOpenApi, createPagedListResponseSchema } from "../openapi.js";
7
+ const filterDateSchema = z.string().trim().min(1).refine((value) => !Number.isNaN(new Date(value).getTime()), { message: "Expected a parsable date" });
8
+ const listSchema = z.object({
9
+ page: z.coerce.number().min(1).default(1),
10
+ pageSize: z.coerce.number().min(1).max(100).default(50),
11
+ q: z.string().optional(),
12
+ providerKey: z.string().optional(),
13
+ status: z.string().optional(),
14
+ direction: z.string().optional(),
15
+ // Validated rather than parsed later: an unparsable value used to be dropped, so a typo in a
16
+ // filter silently returned the unfiltered page.
17
+ startedFrom: filterDateSchema.optional(),
18
+ startedTo: filterDateSchema.optional(),
19
+ sortField: z.string().optional(),
20
+ sortDir: z.enum(["asc", "desc"]).optional(),
21
+ id: z.string().uuid().optional()
22
+ }).passthrough();
23
+ const dayOnlySchema = z.iso.date();
24
+ function rangeBoundary(value, edge) {
25
+ if (!dayOnlySchema.safeParse(value).success) return new Date(value);
26
+ return /* @__PURE__ */ new Date(`${value}T${edge === "start" ? "00:00:00.000" : "23:59:59.999"}Z`);
27
+ }
28
+ const routeMetadata = {
29
+ GET: { requireAuth: true, requireFeatures: ["phone_calls.view"] }
30
+ };
31
+ const metadata = routeMetadata;
32
+ const listFields = [
33
+ "id",
34
+ "organization_id",
35
+ "tenant_id",
36
+ "provider_key",
37
+ "integration_id",
38
+ "external_call_id",
39
+ "external_conversation_id",
40
+ "direction",
41
+ "status",
42
+ "started_at",
43
+ "answered_at",
44
+ "ended_at",
45
+ "duration_seconds",
46
+ "ingest_status",
47
+ "last_ingested_at",
48
+ "created_at",
49
+ "updated_at"
50
+ ];
51
+ const crud = makeCrudRoute({
52
+ metadata: routeMetadata,
53
+ orm: {
54
+ entity: PhoneCall,
55
+ idField: "id",
56
+ orgField: "organizationId",
57
+ tenantField: "tenantId",
58
+ softDeleteField: "deletedAt"
59
+ },
60
+ indexer: { entityType: E.phone_calls.phone_call },
61
+ list: {
62
+ schema: listSchema,
63
+ entityId: E.phone_calls.phone_call,
64
+ fields: listFields,
65
+ transformItem: (item) => ({ ...item, recording_url: null }),
66
+ sortFieldMap: {
67
+ externalCallId: "external_call_id",
68
+ startedAt: "started_at",
69
+ endedAt: "ended_at",
70
+ durationSeconds: "duration_seconds",
71
+ status: "status",
72
+ direction: "direction",
73
+ providerKey: "provider_key",
74
+ ingestStatus: "ingest_status",
75
+ createdAt: "created_at",
76
+ updatedAt: "updated_at"
77
+ },
78
+ buildFilters: (query) => {
79
+ const filters = {};
80
+ if (query.id) filters.id = { $eq: query.id };
81
+ if (query.providerKey) filters.provider_key = { $eq: query.providerKey };
82
+ if (query.status) filters.status = { $eq: query.status };
83
+ if (query.direction) filters.direction = { $eq: query.direction };
84
+ if (query.q) {
85
+ const term = buildIlikeTerm(query.q);
86
+ filters.$or = [
87
+ { external_call_id: { $ilike: term } },
88
+ { external_conversation_id: { $ilike: term } },
89
+ { provider_key: { $ilike: term } }
90
+ ];
91
+ }
92
+ const startedRange = {};
93
+ if (query.startedFrom) startedRange.$gte = rangeBoundary(query.startedFrom, "start");
94
+ if (query.startedTo) startedRange.$lte = rangeBoundary(query.startedTo, "end");
95
+ if (Object.keys(startedRange).length) filters.started_at = startedRange;
96
+ return filters;
97
+ }
98
+ }
99
+ });
100
+ const GET = crud.GET;
101
+ const phoneCallListItemSchema = z.object({
102
+ id: z.string().uuid(),
103
+ organization_id: z.string().uuid().nullable().optional(),
104
+ tenant_id: z.string().uuid().nullable().optional(),
105
+ provider_key: z.string().nullable().optional(),
106
+ integration_id: z.string().nullable().optional(),
107
+ external_call_id: z.string().nullable().optional(),
108
+ external_conversation_id: z.string().nullable().optional(),
109
+ direction: z.string().nullable().optional(),
110
+ status: z.string().nullable().optional(),
111
+ started_at: z.string().nullable().optional(),
112
+ answered_at: z.string().nullable().optional(),
113
+ ended_at: z.string().nullable().optional(),
114
+ duration_seconds: z.number().nullable().optional(),
115
+ recording_url: z.string().nullable().optional(),
116
+ ingest_status: z.string().nullable().optional(),
117
+ last_ingested_at: z.string().nullable().optional(),
118
+ created_at: z.string().nullable().optional(),
119
+ updated_at: z.string().nullable().optional()
120
+ });
121
+ const openApi = createPhoneCallsCrudOpenApi({
122
+ resourceName: "PhoneCall",
123
+ pluralName: "PhoneCalls",
124
+ querySchema: listSchema,
125
+ listResponseSchema: createPagedListResponseSchema(phoneCallListItemSchema)
126
+ });
127
+ export {
128
+ GET,
129
+ metadata,
130
+ openApi
131
+ };
132
+ //# sourceMappingURL=route.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/phone_calls/api/calls/route.ts"],
4
+ "sourcesContent": ["import { z } from 'zod'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport type { Where } from '@open-mercato/shared/lib/query/types'\nimport { buildIlikeTerm } from '@open-mercato/shared/lib/db/buildIlikeTerm'\nimport { E } from '#generated/entities.ids.generated'\nimport { PhoneCall } from '../../data/entities'\nimport { createPhoneCallsCrudOpenApi, createPagedListResponseSchema } from '../openapi'\n\nconst filterDateSchema = z\n .string()\n .trim()\n .min(1)\n .refine((value) => !Number.isNaN(new Date(value).getTime()), { message: 'Expected a parsable date' })\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n q: z.string().optional(),\n providerKey: z.string().optional(),\n status: z.string().optional(),\n direction: z.string().optional(),\n // Validated rather than parsed later: an unparsable value used to be dropped, so a typo in a\n // filter silently returned the unfiltered page.\n startedFrom: filterDateSchema.optional(),\n startedTo: filterDateSchema.optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n id: z.string().uuid().optional(),\n })\n .passthrough()\n\nconst dayOnlySchema = z.iso.date()\n\n// A day is a range, not an instant. `new Date('2026-08-20')` is midnight UTC, so using it as\n// the upper bound drops every call on the day the user picked. The filter bar sends bare\n// `yyyy-MM-dd` for both ends, so each is widened to the day it names; a caller that sends a\n// full timestamp keeps the instant it asked for.\nfunction rangeBoundary(value: string, edge: 'start' | 'end'): Date {\n if (!dayOnlySchema.safeParse(value).success) return new Date(value)\n return new Date(`${value}T${edge === 'start' ? '00:00:00.000' : '23:59:59.999'}Z`)\n}\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['phone_calls.view'] },\n}\n\nexport const metadata = routeMetadata\n\n// No `recording_url`: provider recording links carry their own access token, and this route\n// is gated on `phone_calls.view`. The key still ships as null so the response shape holds.\nconst listFields = [\n 'id',\n 'organization_id',\n 'tenant_id',\n 'provider_key',\n 'integration_id',\n 'external_call_id',\n 'external_conversation_id',\n 'direction',\n 'status',\n 'started_at',\n 'answered_at',\n 'ended_at',\n 'duration_seconds',\n 'ingest_status',\n 'last_ingested_at',\n 'created_at',\n 'updated_at',\n]\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: PhoneCall,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n indexer: { entityType: E.phone_calls.phone_call },\n list: {\n schema: listSchema,\n entityId: E.phone_calls.phone_call,\n fields: listFields,\n transformItem: (item: Record<string, unknown>) => ({ ...item, recording_url: null }),\n sortFieldMap: {\n externalCallId: 'external_call_id',\n startedAt: 'started_at',\n endedAt: 'ended_at',\n durationSeconds: 'duration_seconds',\n status: 'status',\n direction: 'direction',\n providerKey: 'provider_key',\n ingestStatus: 'ingest_status',\n createdAt: 'created_at',\n updatedAt: 'updated_at',\n },\n buildFilters: (query): Where<Record<string, unknown>> => {\n const filters: Record<string, unknown> = {}\n if (query.id) filters.id = { $eq: query.id }\n if (query.providerKey) filters.provider_key = { $eq: query.providerKey }\n if (query.status) filters.status = { $eq: query.status }\n if (query.direction) filters.direction = { $eq: query.direction }\n if (query.q) {\n const term = buildIlikeTerm(query.q)\n filters.$or = [\n { external_call_id: { $ilike: term } },\n { external_conversation_id: { $ilike: term } },\n { provider_key: { $ilike: term } },\n ]\n }\n const startedRange: Record<string, Date> = {}\n if (query.startedFrom) startedRange.$gte = rangeBoundary(query.startedFrom, 'start')\n if (query.startedTo) startedRange.$lte = rangeBoundary(query.startedTo, 'end')\n if (Object.keys(startedRange).length) filters.started_at = startedRange\n return filters\n },\n },\n})\n\nexport const GET = crud.GET\n\nconst phoneCallListItemSchema = z.object({\n id: z.string().uuid(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n provider_key: z.string().nullable().optional(),\n integration_id: z.string().nullable().optional(),\n external_call_id: z.string().nullable().optional(),\n external_conversation_id: z.string().nullable().optional(),\n direction: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n started_at: z.string().nullable().optional(),\n answered_at: z.string().nullable().optional(),\n ended_at: z.string().nullable().optional(),\n duration_seconds: z.number().nullable().optional(),\n recording_url: z.string().nullable().optional(),\n ingest_status: z.string().nullable().optional(),\n last_ingested_at: z.string().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n})\n\nexport const openApi = createPhoneCallsCrudOpenApi({\n resourceName: 'PhoneCall',\n pluralName: 'PhoneCalls',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(phoneCallListItemSchema),\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,23 @@
1
+ import {
2
+ createCrudOpenApiFactory,
3
+ createPagedListResponseSchema as createSharedPagedListResponseSchema,
4
+ defaultOkResponseSchema as sharedDefaultOkResponseSchema
5
+ } from "@open-mercato/shared/lib/openapi/crud";
6
+ const defaultOkResponseSchema = sharedDefaultOkResponseSchema;
7
+ function createPagedListResponseSchema(itemSchema) {
8
+ return createSharedPagedListResponseSchema(itemSchema, { paginationMetaOptional: true });
9
+ }
10
+ const buildPhoneCallsCrudOpenApi = createCrudOpenApiFactory({
11
+ defaultTag: "PhoneCalls",
12
+ defaultOkResponseSchema,
13
+ makeListDescription: ({ pluralLower }) => `Returns a paginated collection of ${pluralLower} scoped to the authenticated organization.`
14
+ });
15
+ function createPhoneCallsCrudOpenApi(options) {
16
+ return buildPhoneCallsCrudOpenApi(options);
17
+ }
18
+ export {
19
+ createPagedListResponseSchema,
20
+ createPhoneCallsCrudOpenApi,
21
+ defaultOkResponseSchema
22
+ };
23
+ //# sourceMappingURL=openapi.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/phone_calls/api/openapi.ts"],
4
+ "sourcesContent": ["import { type ZodTypeAny } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n createCrudOpenApiFactory,\n createPagedListResponseSchema as createSharedPagedListResponseSchema,\n defaultOkResponseSchema as sharedDefaultOkResponseSchema,\n type CrudOpenApiOptions,\n} from '@open-mercato/shared/lib/openapi/crud'\n\nexport const defaultOkResponseSchema = sharedDefaultOkResponseSchema\n\nexport function createPagedListResponseSchema(itemSchema: ZodTypeAny) {\n return createSharedPagedListResponseSchema(itemSchema, { paginationMetaOptional: true })\n}\n\nconst buildPhoneCallsCrudOpenApi = createCrudOpenApiFactory({\n defaultTag: 'PhoneCalls',\n defaultOkResponseSchema,\n makeListDescription: ({ pluralLower }) =>\n `Returns a paginated collection of ${pluralLower} scoped to the authenticated organization.`,\n})\n\nexport function createPhoneCallsCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {\n return buildPhoneCallsCrudOpenApi(options)\n}\n"],
5
+ "mappings": "AAEA;AAAA,EACE;AAAA,EACA,iCAAiC;AAAA,EACjC,2BAA2B;AAAA,OAEtB;AAEA,MAAM,0BAA0B;AAEhC,SAAS,8BAA8B,YAAwB;AACpE,SAAO,oCAAoC,YAAY,EAAE,wBAAwB,KAAK,CAAC;AACzF;AAEA,MAAM,6BAA6B,yBAAyB;AAAA,EAC1D,YAAY;AAAA,EACZ;AAAA,EACA,qBAAqB,CAAC,EAAE,YAAY,MAClC,qCAAqC,WAAW;AACpD,CAAC;AAEM,SAAS,4BAA4B,SAA8C;AACxF,SAAO,2BAA2B,OAAO;AAC3C;",
6
+ "names": []
7
+ }
@@ -0,0 +1,261 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Page, PageBody } from "@open-mercato/ui/backend/Page";
5
+ import { DataTable } from "@open-mercato/ui/backend/DataTable";
6
+ import { StatusBadge } from "@open-mercato/ui/primitives/status-badge";
7
+ import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
8
+ import { useAppEvent } from "@open-mercato/ui/backend/injection/useAppEvent";
9
+ import { useProgressPoll } from "@open-mercato/ui/backend/progress/useProgressPoll";
10
+ import { formatDateTime } from "@open-mercato/shared/lib/time";
11
+ import { useT } from "@open-mercato/shared/lib/i18n/context";
12
+ import { PHONE_CALL_RESOURCE_KIND } from "@open-mercato/shared/modules/phone_calls/types";
13
+ const DIRECTION_VALUES = ["inbound", "outbound", "internal", "unknown"];
14
+ const STATUS_VALUES = ["new", "ringing", "answered", "missed", "failed", "completed", "unknown"];
15
+ function isPhoneCallStatus(value) {
16
+ return STATUS_VALUES.includes(value);
17
+ }
18
+ const SORT_FIELDS = {
19
+ external_call_id: "externalCallId",
20
+ direction: "direction",
21
+ status: "status",
22
+ provider_key: "providerKey",
23
+ started_at: "startedAt",
24
+ duration_seconds: "durationSeconds",
25
+ ingest_status: "ingestStatus"
26
+ };
27
+ const STATUS_TONE = {
28
+ completed: "success",
29
+ answered: "success",
30
+ ringing: "warning",
31
+ new: "neutral",
32
+ missed: "error",
33
+ failed: "error",
34
+ unknown: "neutral"
35
+ };
36
+ function formatDuration(seconds) {
37
+ if (seconds == null || Number.isNaN(seconds)) return "\u2014";
38
+ const total = Math.max(0, Math.floor(seconds));
39
+ const mins = Math.floor(total / 60);
40
+ const secs = total % 60;
41
+ return `${mins}:${String(secs).padStart(2, "0")}`;
42
+ }
43
+ function PhoneCallsPage() {
44
+ const t = useT();
45
+ const [rows, setRows] = React.useState([]);
46
+ const [total, setTotal] = React.useState(0);
47
+ const [totalPages, setTotalPages] = React.useState(1);
48
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false);
49
+ const [page, setPage] = React.useState(1);
50
+ const [pageSize, setPageSize] = React.useState(50);
51
+ const [search, setSearch] = React.useState("");
52
+ const [sorting, setSorting] = React.useState([{ id: "started_at", desc: true }]);
53
+ const [filterValues, setFilterValues] = React.useState({});
54
+ const handleSortingChange = React.useCallback((next) => {
55
+ setSorting(next);
56
+ setPage(1);
57
+ }, []);
58
+ const [isLoading, setIsLoading] = React.useState(true);
59
+ const [error, setError] = React.useState(null);
60
+ const [reloadToken, setReloadToken] = React.useState(0);
61
+ useAppEvent("progress.job.completed", (event) => {
62
+ const meta = event.payload?.meta;
63
+ if (meta?.resourceKind !== PHONE_CALL_RESOURCE_KIND) return;
64
+ setReloadToken((token) => token + 1);
65
+ }, []);
66
+ const { recentlyCompleted } = useProgressPoll();
67
+ const seenJobIds = React.useRef(null);
68
+ React.useEffect(() => {
69
+ const ingestJobs = recentlyCompleted.filter(
70
+ (job) => job.meta?.resourceKind === PHONE_CALL_RESOURCE_KIND
71
+ );
72
+ if (seenJobIds.current === null) {
73
+ seenJobIds.current = new Set(ingestJobs.map((job) => job.id));
74
+ return;
75
+ }
76
+ const unseen = ingestJobs.filter((job) => !seenJobIds.current.has(job.id));
77
+ if (!unseen.length) return;
78
+ for (const job of unseen) seenJobIds.current.add(job.id);
79
+ setReloadToken((token) => token + 1);
80
+ }, [recentlyCompleted]);
81
+ const queryString = React.useMemo(() => {
82
+ const params = new URLSearchParams();
83
+ params.set("page", String(page));
84
+ params.set("pageSize", String(pageSize));
85
+ if (search.trim()) params.set("q", search.trim());
86
+ if (typeof filterValues.direction === "string" && filterValues.direction) params.set("direction", filterValues.direction);
87
+ if (typeof filterValues.status === "string" && filterValues.status) params.set("status", filterValues.status);
88
+ if (typeof filterValues.providerKey === "string" && filterValues.providerKey.trim()) params.set("providerKey", filterValues.providerKey.trim());
89
+ const started = filterValues.started;
90
+ if (started?.from) params.set("startedFrom", started.from);
91
+ if (started?.to) params.set("startedTo", started.to);
92
+ const activeSort = sorting[0];
93
+ if (activeSort) {
94
+ const sortField = SORT_FIELDS[activeSort.id];
95
+ if (sortField) {
96
+ params.set("sortField", sortField);
97
+ params.set("sortDir", activeSort.desc ? "desc" : "asc");
98
+ }
99
+ }
100
+ return params.toString();
101
+ }, [page, pageSize, search, filterValues, sorting]);
102
+ React.useEffect(() => {
103
+ let cancelled = false;
104
+ async function load() {
105
+ setIsLoading(true);
106
+ setError(null);
107
+ const response = await apiCall(`/api/phone_calls/calls?${queryString}`).catch((err) => ({
108
+ ok: false,
109
+ result: { error: err instanceof Error ? err.message : void 0 }
110
+ }));
111
+ if (cancelled) return;
112
+ if (!response.ok) {
113
+ const errBody = response.result;
114
+ setError(errBody?.error ?? t("phone_calls.calls.list.error", "Failed to load phone calls"));
115
+ setRows([]);
116
+ setTotal(0);
117
+ setTotalPages(1);
118
+ setTotalIsCapped(false);
119
+ } else {
120
+ const data = response.result ?? {};
121
+ setRows(Array.isArray(data.items) ? data.items : []);
122
+ setTotal(typeof data.total === "number" ? data.total : 0);
123
+ setTotalPages(typeof data.totalPages === "number" ? data.totalPages : 1);
124
+ setTotalIsCapped(data.totalIsCapped === true);
125
+ }
126
+ setIsLoading(false);
127
+ }
128
+ void load();
129
+ return () => {
130
+ cancelled = true;
131
+ };
132
+ }, [queryString, reloadToken, t]);
133
+ const filters = React.useMemo(() => [
134
+ {
135
+ id: "direction",
136
+ label: t("phone_calls.calls.filters.direction", "Direction"),
137
+ type: "select",
138
+ options: DIRECTION_VALUES.map((value) => ({
139
+ value,
140
+ label: t(`phone_calls.calls.direction.${value}`, value)
141
+ }))
142
+ },
143
+ {
144
+ id: "status",
145
+ label: t("phone_calls.calls.filters.status", "Status"),
146
+ type: "select",
147
+ options: STATUS_VALUES.map((value) => ({
148
+ value,
149
+ label: t(`phone_calls.calls.status.${value}`, value)
150
+ }))
151
+ },
152
+ {
153
+ id: "providerKey",
154
+ label: t("phone_calls.calls.filters.provider", "Provider"),
155
+ type: "text",
156
+ placeholder: t("phone_calls.calls.filters.providerPlaceholder", "Provider key")
157
+ },
158
+ {
159
+ id: "started",
160
+ label: t("phone_calls.calls.filters.started", "Started"),
161
+ type: "dateRange"
162
+ }
163
+ ], [t]);
164
+ const columns = React.useMemo(() => [
165
+ {
166
+ accessorKey: "external_call_id",
167
+ header: t("phone_calls.calls.columns.callId", "Call ID"),
168
+ meta: { truncate: true, maxWidth: "240px" },
169
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: row.original.external_call_id || "\u2014" })
170
+ },
171
+ {
172
+ accessorKey: "direction",
173
+ header: t("phone_calls.calls.columns.direction", "Direction"),
174
+ cell: ({ row }) => {
175
+ const value = row.original.direction;
176
+ return /* @__PURE__ */ jsx("span", { className: "text-sm", children: value ? t(`phone_calls.calls.direction.${value}`, value) : "\u2014" });
177
+ }
178
+ },
179
+ {
180
+ accessorKey: "status",
181
+ header: t("phone_calls.calls.columns.status", "Status"),
182
+ cell: ({ row }) => {
183
+ const value = row.original.status;
184
+ if (!value) return /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: "\u2014" });
185
+ return /* @__PURE__ */ jsx(StatusBadge, { variant: STATUS_TONE[isPhoneCallStatus(value) ? value : "unknown"], dot: true, children: t(`phone_calls.calls.status.${value}`, value) });
186
+ }
187
+ },
188
+ {
189
+ accessorKey: "provider_key",
190
+ header: t("phone_calls.calls.columns.provider", "Provider"),
191
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-sm", children: row.original.provider_key || "\u2014" })
192
+ },
193
+ {
194
+ accessorKey: "started_at",
195
+ header: t("phone_calls.calls.columns.started", "Started"),
196
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: formatDateTime(row.original.started_at) ?? "\u2014" })
197
+ },
198
+ {
199
+ accessorKey: "duration_seconds",
200
+ header: t("phone_calls.calls.columns.duration", "Duration"),
201
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-sm tabular-nums", children: formatDuration(row.original.duration_seconds) })
202
+ },
203
+ {
204
+ accessorKey: "ingest_status",
205
+ header: t("phone_calls.calls.columns.ingestStatus", "Ingest"),
206
+ cell: ({ row }) => {
207
+ const value = row.original.ingest_status;
208
+ return /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: value ? t(`phone_calls.calls.ingestStatus.${value}`, value) : "\u2014" });
209
+ }
210
+ }
211
+ ], [t]);
212
+ return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
213
+ DataTable,
214
+ {
215
+ title: t("phone_calls.calls.list.title", "Phone Calls"),
216
+ extensionTableId: "phone_calls.calls",
217
+ columns,
218
+ data: rows,
219
+ isLoading,
220
+ error,
221
+ sortable: true,
222
+ manualSorting: true,
223
+ sorting,
224
+ onSortingChange: handleSortingChange,
225
+ searchValue: search,
226
+ onSearchChange: (value) => {
227
+ setSearch(value);
228
+ setPage(1);
229
+ },
230
+ searchPlaceholder: t("phone_calls.calls.list.searchPlaceholder", "Search by call, conversation or provider"),
231
+ filters,
232
+ filterValues,
233
+ onFiltersApply: (values) => {
234
+ setFilterValues(values);
235
+ setPage(1);
236
+ },
237
+ onFiltersClear: () => {
238
+ setFilterValues({});
239
+ setPage(1);
240
+ },
241
+ emptyState: t("phone_calls.calls.list.empty", "No phone calls yet. Calls appear here once they are ingested from a provider."),
242
+ pagination: {
243
+ page,
244
+ pageSize,
245
+ total,
246
+ totalPages,
247
+ totalIsCapped,
248
+ onPageChange: setPage,
249
+ pageSizeOptions: [10, 25, 50, 100],
250
+ onPageSizeChange: (size) => {
251
+ setPageSize(size);
252
+ setPage(1);
253
+ }
254
+ }
255
+ }
256
+ ) }) });
257
+ }
258
+ export {
259
+ PhoneCallsPage as default
260
+ };
261
+ //# sourceMappingURL=page.js.map