@peopl-health/nexus 5.13.0-dev.1151 → 5.13.0-dev.1154

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.
@@ -11,7 +11,7 @@ function baseResource(resourceType, id, slug, patientId) {
11
11
  };
12
12
  }
13
13
 
14
- function provenance({ provenanceId, targetRefs, recordedAt, activityText, agentDevice = null, onBehalfOf = null }) {
14
+ function provenance({ provenanceId, targetRefs, recordedAt, activity, agentDevice = null, onBehalfOf = null }) {
15
15
  return {
16
16
  resourceType: 'Provenance',
17
17
  id: fhirId(provenanceId),
@@ -21,7 +21,7 @@ function provenance({ provenanceId, targetRefs, recordedAt, activityText, agentD
21
21
  who: reference('Device', agentDevice || getDeviceId()),
22
22
  onBehalfOf: reference('Organization', onBehalfOf || getOrganizationId()),
23
23
  }],
24
- activity: codeableConcept(activityText),
24
+ activity,
25
25
  };
26
26
  }
27
27
 
@@ -1,24 +1,123 @@
1
- const { ClinicalImpression } = require('../resources/ClinicalImpression');
2
- const { Provenance } = require('../resources/Provenance');
1
+ const { ZodError } = require('zod');
3
2
 
4
- const PROJECTOR_NAME = 'cluster';
3
+ const { codeSystemUrl } = require('../helpers/fhirHelper');
4
+ const { identifier, reference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
5
+ const { baseResource, provenance } = require('../helpers/resourceHelper');
6
+ const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
7
+ const { CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG } = require('../constants/projectionSlugs');
8
+ const { CLUSTER_EXT } = require('../constants/extensionSlugs');
9
+ const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
10
+ const { logger } = require('../../utils/logger');
5
11
 
6
- function projectCluster({ cluster, history = [] }) {
7
- const impression = ClinicalImpression.fromClusterImpression({ cluster });
12
+ const LIST_SEPARATOR = '; ';
13
+ const STATUS_TO_FHIR = { active: 'in-progress', dissolved: 'completed' };
14
+ const ACTIVITY_CODESYSTEM = 'clinical-activity';
15
+ const ACTIVITY_CODES = {
16
+ opened: 'cluster-hypothesis-opened',
17
+ updated: 'cluster-hypothesis-updated',
18
+ dissolved: 'cluster-hypothesis-dissolved',
19
+ };
20
+ const DEFAULT_ACTIVITY_CODE = 'cluster-hypothesis';
21
+
22
+ function toFhir({ cluster, history = [] }) {
8
23
  const records = history.length
9
24
  ? [...history].sort((a, b) => Date.parse(a.recordedAt) - Date.parse(b.recordedAt))
10
25
  : [{ turnId: 'opened', action: 'opened', recordedAt: cluster.openedAt, transitionReason: null }];
11
- const provenances = records.map((record) => Provenance.fromClusterImpression({
12
- clusterId: cluster.clusterId,
13
- turnId: record.turnId,
14
- action: record.action,
26
+ return [clusterResource(cluster), ...records.map((record) => historyProvenance(cluster.clusterId, record))];
27
+ }
28
+
29
+ function clusterResource(cluster) {
30
+ const payload = {
31
+ ...baseResource('ClinicalImpression', cluster.clusterId, CLUSTER_IMPRESSION_SLUG, cluster.patientId),
32
+ status: STATUS_TO_FHIR[cluster.status],
33
+ problem: cluster.memberCaseIds.map((caseId) => reference('Condition', caseId)),
34
+ extension: clusterExtensions(cluster),
35
+ };
36
+ if (cluster.label) payload.identifier.push(identifier(CLUSTER_LABEL_SLUG, cluster.label));
37
+ if (cluster.relationshipModel) payload.summary = cluster.relationshipModel;
38
+ if (cluster.openingReasoning) payload.description = cluster.openingReasoning;
39
+ if (cluster.dissolvedAt) payload.effectivePeriod = { start: toIso(cluster.openedAt), end: toIso(cluster.dissolvedAt) };
40
+ else payload.effectiveDateTime = toIso(cluster.openedAt);
41
+ if (cluster.dissolveCategory) payload.statusReason = codeableConcept(cluster.dissolveCategory);
42
+ return payload;
43
+ }
44
+
45
+ function clusterExtensions(cluster) {
46
+ const out = [
47
+ extension(CLUSTER_EXT.HYPOTHESIS_TYPE, { valueString: cluster.hypothesisType }),
48
+ extension(CLUSTER_EXT.CONFIDENCE, { valueString: cluster.confidence }),
49
+ extension(CLUSTER_EXT.CLINICAL_SIGNIFICANCE, { valueString: cluster.clinicalSignificance }),
50
+ ];
51
+ if (cluster.strengthensIf.length) {
52
+ out.push(extension(CLUSTER_EXT.STRENGTHENS_IF, { valueString: cluster.strengthensIf.join(LIST_SEPARATOR) }));
53
+ }
54
+ if (cluster.weakensIf.length) {
55
+ out.push(extension(CLUSTER_EXT.WEAKENS_IF, { valueString: cluster.weakensIf.join(LIST_SEPARATOR) }));
56
+ }
57
+ out.push(extension(CLUSTER_EXT.STATUS, { valueString: cluster.status }));
58
+ if (cluster.consultationId) out.push(extension(CLUSTER_EXT.CONSULTATION_ID, { valueString: cluster.consultationId }));
59
+ if (cluster.dissolveReason) out.push(extension(CLUSTER_EXT.DISSOLVE_REASON, { valueString: cluster.dissolveReason }));
60
+ return out;
61
+ }
62
+
63
+ function historyProvenance(clusterId, record) {
64
+ const code = ACTIVITY_CODES[record.action] || DEFAULT_ACTIVITY_CODE;
65
+ const payload = provenance({
66
+ provenanceId: `${clusterId}-prov-${record.turnId}-${record.action}`,
67
+ targetRefs: [reference('ClinicalImpression', clusterId)],
15
68
  recordedAt: record.recordedAt,
16
- transitionReason: record.transitionReason,
17
- }));
18
- return [impression, ...provenances];
69
+ activity: codeableConcept(code, { code, system: codeSystemUrl(ACTIVITY_CODESYSTEM) }),
70
+ });
71
+ if (record.transitionReason) {
72
+ payload.extension = [extension('transition-reason', { valueString: record.transitionReason })];
73
+ }
74
+ return payload;
75
+ }
76
+
77
+ function fromFhir(resources, { patientId }) {
78
+ const out = [];
79
+ for (const resource of resources) {
80
+ if (resource.resourceType !== 'ClinicalImpression') continue;
81
+ if (!identifierValue(resource, CLUSTER_IMPRESSION_SLUG)) continue;
82
+ try {
83
+ out.push(new ClusterImpression(reconstruct(resource, patientId)));
84
+ } catch (error) {
85
+ if (error instanceof ZodError) {
86
+ logger.warn({ clusterId: resource.id }, 'skipping malformed cluster ClinicalImpression');
87
+ continue;
88
+ }
89
+ throw error;
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function reconstruct(resource, patientId) {
96
+ const ext = extMap(resource);
97
+ const splitList = (value) => (value ? value.split(LIST_SEPARATOR) : []);
98
+ return {
99
+ schemaVersion: '1',
100
+ clusterId: identifierValue(resource, CLUSTER_IMPRESSION_SLUG),
101
+ patientId,
102
+ label: identifierValue(resource, CLUSTER_LABEL_SLUG) || '',
103
+ hypothesisType: ext[CLUSTER_EXT.HYPOTHESIS_TYPE],
104
+ status: ext[CLUSTER_EXT.STATUS],
105
+ confidence: ext[CLUSTER_EXT.CONFIDENCE],
106
+ clinicalSignificance: ext[CLUSTER_EXT.CLINICAL_SIGNIFICANCE],
107
+ memberCaseIds: (resource.problem || []).map((problemRef) => refToId(problemRef.reference)).filter(Boolean),
108
+ consultationId: ext[CLUSTER_EXT.CONSULTATION_ID] || null,
109
+ relationshipModel: resource.summary || '',
110
+ strengthensIf: splitList(ext[CLUSTER_EXT.STRENGTHENS_IF]),
111
+ weakensIf: splitList(ext[CLUSTER_EXT.WEAKENS_IF]),
112
+ openingReasoning: resource.description || '',
113
+ openedAt: resource.effectiveDateTime || resource.effectivePeriod?.start,
114
+ dissolvedAt: resource.effectivePeriod?.end || null,
115
+ dissolveReason: ext[CLUSTER_EXT.DISSOLVE_REASON] || null,
116
+ dissolveCategory: resource.statusReason?.text || null,
117
+ };
19
118
  }
20
119
 
21
120
  module.exports = {
22
- projectCluster,
23
- PROJECTOR_NAME,
121
+ toFhir,
122
+ fromFhir,
24
123
  };
@@ -60,7 +60,7 @@ function toFhir(contingency) {
60
60
  provenanceId: `${carePlan.planId}-prov`,
61
61
  targetRefs,
62
62
  recordedAt: carePlan.createdAt,
63
- activityText: ACTIVITY_TEXT,
63
+ activity: codeableConcept(ACTIVITY_TEXT),
64
64
  }));
65
65
  return resources;
66
66
  }
@@ -34,7 +34,7 @@ function toFhir(intervention) {
34
34
  provenanceId: `${intervention.interventionId}-prov`,
35
35
  targetRefs: [{ reference: `${primary.resourceType}/${fhirId(intervention.interventionId)}` }],
36
36
  recordedAt: intervention.deliveredAt,
37
- activityText: ACTIVITY_TEXT,
37
+ activity: codeableConcept(ACTIVITY_TEXT),
38
38
  })];
39
39
  }
40
40
 
@@ -5,7 +5,6 @@ const { projectReminder, PROJECTOR_NAME: REMINDER_PROJECTOR_NAME } = require('./
5
5
  const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./riskProjection');
6
6
  const { projectSafetyGate, PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('./safetyGateProjection');
7
7
  const { projectRecommendationPlan, PROJECTOR_NAME: RECOMMENDATION_PLAN_PROJECTOR_NAME } = require('./recommendationPlanProjection');
8
- const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
9
8
  const { projectPalliativeAssessment, PROJECTOR_NAME: PALLIATIVE_PROJECTOR_NAME } = require('./palliativeProjection');
10
9
  const { projectPalliativeCarePlan, PROJECTOR_NAME: PALLIATIVE_CAREPLAN_PROJECTOR_NAME } = require('./palliativeCarePlanProjection');
11
10
 
@@ -16,7 +15,6 @@ const PROJECTORS = [
16
15
  { name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
17
16
  { name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
18
17
  { name: RECOMMENDATION_PLAN_PROJECTOR_NAME, project: projectRecommendationPlan },
19
- { name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
20
18
  { name: PALLIATIVE_PROJECTOR_NAME, project: projectPalliativeAssessment },
21
19
  { name: PALLIATIVE_CAREPLAN_PROJECTOR_NAME, project: projectPalliativeCarePlan },
22
20
  ];
@@ -128,7 +128,7 @@ function toFhir(routingDecision) {
128
128
  reference(dispositionResource.resourceType, disposition.dispositionId),
129
129
  ],
130
130
  recordedAt: prov.recordedAt,
131
- activityText: ACTIVITY_TEXT,
131
+ activity: codeableConcept(ACTIVITY_TEXT),
132
132
  agentDevice: prov.agentDevice,
133
133
  onBehalfOf: prov.onBehalfOf,
134
134
  }),
@@ -41,7 +41,7 @@ function toFhir(managedSymptom) {
41
41
  }
42
42
 
43
43
  function caseProvenance(anchorId, recordedAt, targetRefs) {
44
- return provenance({ provenanceId: `${anchorId}-prov`, targetRefs, recordedAt, activityText: ACTIVITY_TEXT });
44
+ return provenance({ provenanceId: `${anchorId}-prov`, targetRefs, recordedAt, activity: codeableConcept(ACTIVITY_TEXT) });
45
45
  }
46
46
 
47
47
  function assessmentResources(assessment, placeholderIds) {
@@ -1,56 +1,13 @@
1
1
  const { fhirId } = require('../helpers/fhirHelper');
2
- const { identifier, reference, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
3
- const { CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG, RECOMMENDATION_ELEMENTS_SLUG, PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
4
- const { CLUSTER_EXT, PALLIATIVE_EXT } = require('../constants/extensionSlugs');
5
-
6
- const CLUSTER_STATUS_TO_FHIR = { active: 'in-progress', dissolved: 'completed' };
7
- const CLUSTER_LIST_SEPARATOR = '; ';
2
+ const { identifier, patientReference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
3
+ const { RECOMMENDATION_ELEMENTS_SLUG, PALLIATIVE_ASSESSMENT_SLUG } = require('../constants/projectionSlugs');
4
+ const { PALLIATIVE_EXT } = require('../constants/extensionSlugs');
8
5
 
9
6
  class ClinicalImpression {
10
7
  constructor(payload) {
11
8
  Object.assign(this, payload);
12
9
  }
13
10
 
14
- static fromClusterImpression({ cluster }) {
15
- const epistemicExts = [
16
- extension(CLUSTER_EXT.HYPOTHESIS_TYPE, { valueString: cluster.hypothesisType }),
17
- extension(CLUSTER_EXT.CONFIDENCE, { valueString: cluster.confidence }),
18
- extension(CLUSTER_EXT.CLINICAL_SIGNIFICANCE, { valueString: cluster.clinicalSignificance }),
19
- ];
20
- if (cluster.strengthensIf.length) {
21
- epistemicExts.push(extension(CLUSTER_EXT.STRENGTHENS_IF, { valueString: cluster.strengthensIf.join(CLUSTER_LIST_SEPARATOR) }));
22
- }
23
- if (cluster.weakensIf.length) {
24
- epistemicExts.push(extension(CLUSTER_EXT.WEAKENS_IF, { valueString: cluster.weakensIf.join(CLUSTER_LIST_SEPARATOR) }));
25
- }
26
- const lifecycleExts = [extension(CLUSTER_EXT.STATUS, { valueString: cluster.status })];
27
- if (cluster.consultationId) lifecycleExts.push(extension(CLUSTER_EXT.CONSULTATION_ID, { valueString: cluster.consultationId }));
28
- if (cluster.dissolveReason) lifecycleExts.push(extension(CLUSTER_EXT.DISSOLVE_REASON, { valueString: cluster.dissolveReason }));
29
-
30
- const identifiers = [identifier(CLUSTER_IMPRESSION_SLUG, cluster.clusterId)];
31
- if (cluster.label) identifiers.push(identifier(CLUSTER_LABEL_SLUG, cluster.label));
32
-
33
- const payload = {
34
- resourceType: 'ClinicalImpression',
35
- id: fhirId(cluster.clusterId),
36
- identifier: identifiers,
37
- status: CLUSTER_STATUS_TO_FHIR[cluster.status],
38
- subject: patientReference(cluster.patientId),
39
- problem: cluster.memberCaseIds.map((caseId) => reference('Condition', caseId)),
40
- extension: [...epistemicExts, ...lifecycleExts],
41
- };
42
- if (cluster.relationshipModel) payload.summary = cluster.relationshipModel;
43
- if (cluster.openingReasoning) payload.description = cluster.openingReasoning;
44
- if (cluster.dissolvedAt) {
45
- // toIso normalizes to Z; a non-Z openedAt/dissolvedAt does not byte-for-byte round-trip (known loss).
46
- payload.effectivePeriod = { start: toIso(cluster.openedAt), end: toIso(cluster.dissolvedAt) };
47
- } else {
48
- payload.effectiveDateTime = toIso(cluster.openedAt);
49
- }
50
- if (cluster.dissolveCategory) payload.statusReason = codeableConcept(cluster.dissolveCategory);
51
- return new ClinicalImpression(payload);
52
- }
53
-
54
11
  static fromRecommendationPlan({ plan }) {
55
12
  const finding = [...plan.elements]
56
13
  .sort((a, b) => a.rank - b.rank)
@@ -117,6 +74,4 @@ function palliativeFinding(finding) {
117
74
 
118
75
  module.exports = {
119
76
  ClinicalImpression,
120
- CLUSTER_STATUS_TO_FHIR,
121
- CLUSTER_LIST_SEPARATOR,
122
77
  };
@@ -1,33 +1,12 @@
1
1
  const { getDeviceId, getOrganizationId } = require('../config/fhirConfig');
2
- const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
2
+ const { fhirId } = require('../helpers/fhirHelper');
3
3
  const { extension, reference, codeableConcept, toIso } = require('../helpers/elementHelper');
4
4
 
5
- const CLUSTER_ACTIVITY_CODESYSTEM = 'clinical-activity';
6
- const CLUSTER_ACTIVITY_CODES = {
7
- opened: 'cluster-hypothesis-opened',
8
- updated: 'cluster-hypothesis-updated',
9
- dissolved: 'cluster-hypothesis-dissolved',
10
- };
11
-
12
5
  class Provenance {
13
6
  constructor(payload) {
14
7
  Object.assign(this, payload);
15
8
  }
16
9
 
17
- static fromClusterImpression({ clusterId, turnId, action, recordedAt, transitionReason }) {
18
- const activityCode = CLUSTER_ACTIVITY_CODES[action] || 'cluster-hypothesis';
19
- const payload = {
20
- resourceType: 'Provenance',
21
- id: fhirId(`${clusterId}-prov-${turnId}-${action}`),
22
- target: [{ reference: `ClinicalImpression/${fhirId(clusterId)}` }],
23
- recorded: toIso(recordedAt),
24
- agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
25
- activity: codeableConcept(activityCode, { code: activityCode, system: codeSystemUrl(CLUSTER_ACTIVITY_CODESYSTEM) }),
26
- };
27
- if (transitionReason) payload.extension = [extension('transition-reason', { valueString: transitionReason })];
28
- return new Provenance(payload);
29
- }
30
-
31
10
  static fromMentionProjection({ target, provenance, activityText }) {
32
11
  const payload = {
33
12
  resourceType: 'Provenance',
@@ -1,75 +1,27 @@
1
- const { ZodError } = require('zod');
2
-
3
- const { fhirId, identifierSystem } = require('../helpers/fhirHelper');
4
- const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
5
- const { CLUSTER_IMPRESSION_SLUG, CLUSTER_LABEL_SLUG } = require('../constants/projectionSlugs');
6
- const { CLUSTER_LIST_SEPARATOR } = require('../resources/ClinicalImpression');
7
- const { CLUSTER_EXT } = require('../constants/extensionSlugs');
8
- const { PROJECTOR_NAME } = require('../projections/clusterProjection');
1
+ const { fhirId } = require('../helpers/fhirHelper');
2
+ const { CLUSTER_IMPRESSION_SLUG } = require('../constants/projectionSlugs');
9
3
  const { getFhirStore } = require('../stores/fhirStore');
10
- const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
11
- const { logger } = require('../../utils/logger');
12
- const { project, storeResources } = require('./fhirService');
4
+ const { toFhir, fromFhir } = require('../projections/clusterProjection');
5
+ const { storeResources } = require('./fhirService');
13
6
 
14
7
  async function storeCluster({ patientId, cluster, history = [] }) {
15
8
  if (cluster.patientId !== patientId) {
16
9
  throw new Error(`storeCluster patientId mismatch: expected ${patientId}`);
17
10
  }
18
- const bundle = project([{ name: PROJECTOR_NAME, aggregate: { cluster, history } }]);
19
- const resources = bundle.entry.map((entry) => entry.resource);
20
- const { stored } = await storeResources(resources);
11
+ const { stored } = await storeResources(toFhir({ cluster, history }));
21
12
  return { count: stored.length, ids: stored };
22
13
  }
23
14
 
24
15
  async function readClusters({ patientId, label = null, status = null }) {
25
16
  const store = getFhirStore();
26
17
  const patient = `Patient/${fhirId(patientId)}`;
27
- const clusterSystem = identifierSystem(CLUSTER_IMPRESSION_SLUG);
28
- const impressions = (await store.find({ patient, resourceType: 'ClinicalImpression' }))
29
- .filter((resource) => (resource.identifier || []).some((id) => id.system === clusterSystem))
30
- .filter((resource) => identifierValue(resource, CLUSTER_IMPRESSION_SLUG));
31
- const clusters = [];
32
- for (const impression of impressions) {
33
- let cluster;
34
- try {
35
- cluster = new ClusterImpression(reconstructCluster(impression, patientId));
36
- } catch (error) {
37
- if (error instanceof ZodError) {
38
- logger.warn({ clusterId: impression.id }, 'skipping malformed cluster ClinicalImpression');
39
- continue;
40
- }
41
- throw error;
42
- }
43
- if (label && cluster.label !== label) continue;
44
- if (status && cluster.status !== status) continue;
45
- clusters.push(cluster);
46
- }
47
- return clusters;
48
- }
49
-
50
- function reconstructCluster(resource, patientId) {
51
- const ext = extMap(resource);
52
- const period = resource.effectivePeriod || {};
53
- return {
54
- schemaVersion: '1',
55
- clusterId: identifierValue(resource, CLUSTER_IMPRESSION_SLUG),
56
- patientId,
57
- label: identifierValue(resource, CLUSTER_LABEL_SLUG) || '',
58
- hypothesisType: ext[CLUSTER_EXT.HYPOTHESIS_TYPE],
59
- status: ext[CLUSTER_EXT.STATUS],
60
- confidence: ext[CLUSTER_EXT.CONFIDENCE],
61
- clinicalSignificance: ext[CLUSTER_EXT.CLINICAL_SIGNIFICANCE],
62
- memberCaseIds: (resource.problem || []).map((problemRef) => refToId(problemRef.reference)).filter(Boolean),
63
- consultationId: ext[CLUSTER_EXT.CONSULTATION_ID] || null,
64
- relationshipModel: resource.summary || '',
65
- strengthensIf: ext[CLUSTER_EXT.STRENGTHENS_IF] ? ext[CLUSTER_EXT.STRENGTHENS_IF].split(CLUSTER_LIST_SEPARATOR) : [],
66
- weakensIf: ext[CLUSTER_EXT.WEAKENS_IF] ? ext[CLUSTER_EXT.WEAKENS_IF].split(CLUSTER_LIST_SEPARATOR) : [],
67
- openingReasoning: resource.description || '',
68
- openedAt: resource.effectiveDateTime || period.start,
69
- dissolvedAt: period.end || null,
70
- dissolveReason: ext[CLUSTER_EXT.DISSOLVE_REASON] || null,
71
- dissolveCategory: (resource.statusReason && resource.statusReason.text) || null,
72
- };
18
+ const resources = await store.find({
19
+ patient,
20
+ resourceType: 'ClinicalImpression',
21
+ identifierSlug: CLUSTER_IMPRESSION_SLUG,
22
+ });
23
+ return fromFhir(resources, { patientId })
24
+ .filter((cluster) => (!label || cluster.label === label) && (!status || cluster.status === status));
73
25
  }
74
26
 
75
27
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.13.0-dev.1151",
3
+ "version": "5.13.0-dev.1154",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",