tango-app-api-audio-analytics 1.0.0
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.
- package/.eslintrc.cjs +41 -0
- package/API_EXAMPLES.md +310 -0
- package/COHORT_API.md +513 -0
- package/COHORT_API_EXAMPLES.sh +235 -0
- package/COHORT_API_IMPLEMENTATION.md +296 -0
- package/COHORT_API_QUICKSTART.md +387 -0
- package/index.js +6 -0
- package/package.json +36 -0
- package/src/controllers/audioAnalytics.controller.js +116 -0
- package/src/controllers/cohort.controller.js +357 -0
- package/src/controllers/cohortAnalytics.controller.js +46 -0
- package/src/controllers/conversationAnalytics.controller.js +92 -0
- package/src/dtos/audioAnalytics.dtos.js +537 -0
- package/src/middlewares/validation.middleware.js +624 -0
- package/src/routes/audioAnalytics.routes.js +18 -0
- package/src/services/cohort.service.js +332 -0
- package/src/validations/cohort.validation.js +113 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cohort Service
|
|
3
|
+
* Handles all OpenSearch operations for cohort data
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { insertWithId, searchOpenSearchData, updateOpenSearchData } from 'tango-app-api-middleware';
|
|
7
|
+
import { logger } from 'tango-app-api-middleware';
|
|
8
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
9
|
+
|
|
10
|
+
const OPENSEARCH_INDEX = 'tango-audio-cohort';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a new cohort in OpenSearch
|
|
14
|
+
* @param {Object} cohortData - Cohort data to insert
|
|
15
|
+
* @return {Promise<Object>} - Created cohort with ID
|
|
16
|
+
*/
|
|
17
|
+
export const createCohort = async ( cohortData ) => {
|
|
18
|
+
try {
|
|
19
|
+
logger.info( `Creating cohort: ${cohortData.cohortId}` );
|
|
20
|
+
|
|
21
|
+
// Generate unique document ID
|
|
22
|
+
const documentId = uuidv4();
|
|
23
|
+
|
|
24
|
+
// Prepare cohort document
|
|
25
|
+
const cohortDocument = {
|
|
26
|
+
...cohortData,
|
|
27
|
+
documentId,
|
|
28
|
+
createdAt: new Date().toISOString(),
|
|
29
|
+
updatedAt: new Date().toISOString(),
|
|
30
|
+
isActive: true,
|
|
31
|
+
version: '1.0',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Insert into OpenSearch using the insert function with id
|
|
35
|
+
const result = await insertWithId(
|
|
36
|
+
OPENSEARCH_INDEX,
|
|
37
|
+
documentId,
|
|
38
|
+
cohortDocument,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
logger.info( `Cohort created successfully: ${cohortData.cohortId}`, {
|
|
42
|
+
documentId,
|
|
43
|
+
cohortId: cohortData.cohortId,
|
|
44
|
+
result,
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
data: {
|
|
50
|
+
documentId,
|
|
51
|
+
...cohortDocument,
|
|
52
|
+
},
|
|
53
|
+
message: 'Cohort created successfully',
|
|
54
|
+
};
|
|
55
|
+
} catch ( error ) {
|
|
56
|
+
logger.error( `Error creating cohort: ${error.message}`, error );
|
|
57
|
+
return {
|
|
58
|
+
code: 'COHORT_CREATE_ERROR',
|
|
59
|
+
message: 'Failed to create cohort',
|
|
60
|
+
details: error.message,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get cohort by ID
|
|
67
|
+
* @param {string} cohortId - Cohort ID to retrieve
|
|
68
|
+
* @return {Promise<Object>} - Cohort data
|
|
69
|
+
*/
|
|
70
|
+
export const getCohortById = async ( cohortId ) => {
|
|
71
|
+
try {
|
|
72
|
+
logger.info( `Fetching cohort: ${cohortId}` );
|
|
73
|
+
|
|
74
|
+
const query = {
|
|
75
|
+
query: {
|
|
76
|
+
match: {
|
|
77
|
+
cohortId: cohortId,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = await searchOpenSearchData( OPENSEARCH_INDEX, query );
|
|
83
|
+
|
|
84
|
+
if ( !result || result.hits.hits.length === 0 ) {
|
|
85
|
+
return new Error( `Cohort not found: ${cohortId}` );
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cohort = result.hits.hits[0]._source;
|
|
89
|
+
cohort.documentId = result.hits.hits[0]._id;
|
|
90
|
+
|
|
91
|
+
return cohort;
|
|
92
|
+
} catch ( error ) {
|
|
93
|
+
logger.error( `Error fetching cohort: ${error.message}`, error );
|
|
94
|
+
return {
|
|
95
|
+
code: 'COHORT_FETCH_ERROR',
|
|
96
|
+
message: 'Failed to fetch cohort',
|
|
97
|
+
details: error.message,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get cohorts by client ID
|
|
104
|
+
* @param {string} clientId - Client ID to filter by
|
|
105
|
+
* @param {number} limit - Results limit
|
|
106
|
+
* @param {number} offset - Pagination offset
|
|
107
|
+
* @return {Promise<Object>} - Cohorts and pagination
|
|
108
|
+
*/
|
|
109
|
+
export const getCohortsByClientId = async ( clientId, limit = 10, offset = 0 ) => {
|
|
110
|
+
try {
|
|
111
|
+
logger.info( `Fetching cohorts for client: ${clientId}` );
|
|
112
|
+
|
|
113
|
+
const query = {
|
|
114
|
+
query: {
|
|
115
|
+
match: {
|
|
116
|
+
clientId: clientId,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
size: limit,
|
|
120
|
+
from: offset,
|
|
121
|
+
sort: [ { createdAt: { order: 'desc' } } ],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const result = await search( OPENSEARCH_INDEX, query );
|
|
125
|
+
|
|
126
|
+
const cohorts = result.hits.hits.map( ( hit ) => ( {
|
|
127
|
+
documentId: hit._id,
|
|
128
|
+
...hit._source,
|
|
129
|
+
} ) );
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
cohorts,
|
|
133
|
+
pagination: {
|
|
134
|
+
total: result.hits.total.value,
|
|
135
|
+
limit,
|
|
136
|
+
offset,
|
|
137
|
+
hasMore: offset + limit < result.hits.total.value,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
} catch ( error ) {
|
|
141
|
+
logger.error( `Error fetching cohorts by client: ${error.message}`, error );
|
|
142
|
+
return {
|
|
143
|
+
code: 'COHORT_SEARCH_ERROR',
|
|
144
|
+
message: 'Failed to fetch cohorts',
|
|
145
|
+
details: error.message,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Update cohort
|
|
152
|
+
* @param {string} documentId - Document ID in OpenSearch
|
|
153
|
+
* @param {Object} updateData - Data to update
|
|
154
|
+
* @return {Promise<Object>} - Updated cohort
|
|
155
|
+
*/
|
|
156
|
+
export const updateCohort = async ( documentId, updateData ) => {
|
|
157
|
+
try {
|
|
158
|
+
logger.info( `Updating cohort document: ${documentId}` );
|
|
159
|
+
|
|
160
|
+
const updateDocument = {
|
|
161
|
+
...updateData,
|
|
162
|
+
updatedAt: new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const result = await updateOpenSearchData( OPENSEARCH_INDEX, documentId, updateDocument );
|
|
166
|
+
|
|
167
|
+
logger.info( `Cohort updated successfully: ${documentId}`, result );
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
success: true,
|
|
171
|
+
data: {
|
|
172
|
+
documentId,
|
|
173
|
+
...updateDocument,
|
|
174
|
+
},
|
|
175
|
+
message: 'Cohort updated successfully',
|
|
176
|
+
};
|
|
177
|
+
} catch ( error ) {
|
|
178
|
+
logger.error( `Error updating cohort: ${error.message}`, error );
|
|
179
|
+
return {
|
|
180
|
+
code: 'COHORT_UPDATE_ERROR',
|
|
181
|
+
message: 'Failed to update cohort',
|
|
182
|
+
details: error.message,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Delete cohort
|
|
189
|
+
* @param {string} documentId - Document ID in OpenSearch
|
|
190
|
+
* @return {Promise<Object>} - Deletion result
|
|
191
|
+
*/
|
|
192
|
+
export const deleteCohort = async ( documentId ) => {
|
|
193
|
+
try {
|
|
194
|
+
logger.info( `Deleting cohort document: ${documentId}` );
|
|
195
|
+
|
|
196
|
+
// const result = await deleteDoc( OPENSEARCH_INDEX, documentId );
|
|
197
|
+
|
|
198
|
+
logger.info( `Cohort deleted successfully: ${documentId}` );
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
success: true,
|
|
202
|
+
message: 'Cohort deleted successfully',
|
|
203
|
+
};
|
|
204
|
+
} catch ( error ) {
|
|
205
|
+
logger.error( `Error deleting cohort: ${error.message}`, error );
|
|
206
|
+
return {
|
|
207
|
+
code: 'COHORT_DELETE_ERROR',
|
|
208
|
+
message: 'Failed to delete cohort',
|
|
209
|
+
details: error.message,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Search cohorts by criteria
|
|
216
|
+
* @param {Object} searchCriteria - Search criteria (clientId, cohortName, etc.)
|
|
217
|
+
* @param {number} limit - Results limit
|
|
218
|
+
* @param {number} offset - Pagination offset
|
|
219
|
+
* @return {Promise<Object>} - Search results
|
|
220
|
+
*/
|
|
221
|
+
export const searchCohorts = async ( searchCriteria, limit = 10, offset = 0 ) => {
|
|
222
|
+
try {
|
|
223
|
+
logger.info( `Searching cohorts with criteria:`, searchCriteria );
|
|
224
|
+
|
|
225
|
+
const mustClauses = [];
|
|
226
|
+
|
|
227
|
+
if ( searchCriteria.clientId ) {
|
|
228
|
+
mustClauses.push( { match: { clientId: searchCriteria.clientId } } );
|
|
229
|
+
}
|
|
230
|
+
if ( searchCriteria.cohortId ) {
|
|
231
|
+
mustClauses.push( { match: { cohortId: searchCriteria.cohortId } } );
|
|
232
|
+
}
|
|
233
|
+
if ( searchCriteria.cohortName ) {
|
|
234
|
+
mustClauses.push( {
|
|
235
|
+
match: {
|
|
236
|
+
cohortName: {
|
|
237
|
+
query: searchCriteria.cohortName,
|
|
238
|
+
fuzziness: 'AUTO',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
} );
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const query = {
|
|
245
|
+
query: {
|
|
246
|
+
bool: {
|
|
247
|
+
must: mustClauses.length > 0 ? mustClauses : [ { match_all: {} } ],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
size: limit,
|
|
251
|
+
from: offset,
|
|
252
|
+
sort: [ { createdAt: { order: 'desc' } } ],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const result = await search( OPENSEARCH_INDEX, query );
|
|
256
|
+
|
|
257
|
+
const cohorts = result.hits.hits.map( ( hit ) => ( {
|
|
258
|
+
documentId: hit._id,
|
|
259
|
+
...hit._source,
|
|
260
|
+
} ) );
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
cohorts,
|
|
264
|
+
pagination: {
|
|
265
|
+
total: result.hits.total.value,
|
|
266
|
+
limit,
|
|
267
|
+
offset,
|
|
268
|
+
hasMore: offset + limit < result.hits.total.value,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
} catch ( error ) {
|
|
272
|
+
logger.error( `Error searching cohorts: ${error.message}`, error );
|
|
273
|
+
return {
|
|
274
|
+
code: 'COHORT_SEARCH_ERROR',
|
|
275
|
+
message: 'Failed to search cohorts',
|
|
276
|
+
details: error.message,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get cohort analytics/metrics summary
|
|
283
|
+
* @param {string} cohortId - Cohort ID
|
|
284
|
+
* @return {Promise<Object>} - Cohort with metrics summary
|
|
285
|
+
*/
|
|
286
|
+
export const getCohortAnalytics = async ( cohortId ) => {
|
|
287
|
+
try {
|
|
288
|
+
logger.info( `Fetching analytics for cohort: ${cohortId}` );
|
|
289
|
+
|
|
290
|
+
const cohort = await getCohortById( cohortId );
|
|
291
|
+
|
|
292
|
+
// Build metrics summary
|
|
293
|
+
const metricsSummary = cohort.metrics.map( ( metric ) => ( {
|
|
294
|
+
metricId: metric.metricId,
|
|
295
|
+
metricName: metric.metricName,
|
|
296
|
+
isNumneric: metric.isNumneric,
|
|
297
|
+
contextCount: metric.contexts ? metric.contexts.length : 0,
|
|
298
|
+
contexts: metric.contexts || [],
|
|
299
|
+
} ) );
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
cohortId: cohort.cohortId,
|
|
303
|
+
cohortName: cohort.cohortName,
|
|
304
|
+
clientId: cohort.clientId,
|
|
305
|
+
totalMetrics: cohort.metrics.length,
|
|
306
|
+
metrics: metricsSummary,
|
|
307
|
+
createdAt: cohort.createdAt,
|
|
308
|
+
updatedAt: cohort.updatedAt,
|
|
309
|
+
};
|
|
310
|
+
} catch ( error ) {
|
|
311
|
+
logger.error( `Error fetching cohort analytics: ${error.message}`, error );
|
|
312
|
+
return {
|
|
313
|
+
code: 'COHORT_ANALYTICS_ERROR',
|
|
314
|
+
message: 'Failed to fetch cohort analytics',
|
|
315
|
+
details: error.message,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Check if cohort exists
|
|
322
|
+
* @param {string} cohortId - Cohort ID to check
|
|
323
|
+
* @return {Promise<boolean>} - True if exists, false otherwise
|
|
324
|
+
*/
|
|
325
|
+
export const cohortExists = async ( cohortId ) => {
|
|
326
|
+
try {
|
|
327
|
+
await getCohortById( cohortId );
|
|
328
|
+
return true;
|
|
329
|
+
} catch ( error ) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cohort Validation Schema using Joi
|
|
3
|
+
* Validates cohort creation requests according to the schema
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Joi from 'joi';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Context validation schema (for metrics)
|
|
10
|
+
*/
|
|
11
|
+
const contextSchema = Joi.object( {
|
|
12
|
+
contextName: Joi.string().required().description( 'Name of the context' ),
|
|
13
|
+
priority: Joi.number().min( 0 ).max( 2 ).required().description( 'Priority level 0-2' ),
|
|
14
|
+
description: Joi.string().required().description( 'Description of the context' ),
|
|
15
|
+
minValue: Joi.number().optional().description( 'Minimum value for numeric contexts' ),
|
|
16
|
+
maxValue: Joi.number().optional().description( 'Maximum value for numeric contexts' ),
|
|
17
|
+
} );
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Metric validation schema
|
|
21
|
+
*/
|
|
22
|
+
const metricSchema = Joi.object( {
|
|
23
|
+
metricId: Joi.string().required().description( 'Unique metric identifier' ),
|
|
24
|
+
metricName: Joi.string().required().description( 'Human-readable metric name' ),
|
|
25
|
+
metricDescription: Joi.string().required().description( 'Detailed metric description' ),
|
|
26
|
+
hasContext: Joi.boolean().required().description( 'Whether metric has context values' ),
|
|
27
|
+
isNumneric: Joi.boolean().required().description( 'Whether metric is numeric' ),
|
|
28
|
+
contexts: Joi.when( 'hasContext', {
|
|
29
|
+
is: true,
|
|
30
|
+
then: Joi.array().items( contextSchema ).required().min( 1 ),
|
|
31
|
+
otherwise: Joi.forbidden(),
|
|
32
|
+
} ).description( 'Array of context options for this metric' ),
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Main Cohort Creation Schema
|
|
37
|
+
*/
|
|
38
|
+
export const cohortCreationSchema = Joi.object( {
|
|
39
|
+
clientId: Joi.string().required().description( 'Client identifier' ),
|
|
40
|
+
cohortId: Joi.string().required().pattern( /^[a-z0-9_-]+$/ ).description( 'Unique cohort identifier' ),
|
|
41
|
+
cohortName: Joi.string().required().min( 3 ).max( 100 ).description( 'Name of the cohort' ),
|
|
42
|
+
cohortDescription: Joi.string().required().min( 10 ).max( 500 ).description( 'Detailed cohort description' ),
|
|
43
|
+
metrics: Joi.array()
|
|
44
|
+
.items( metricSchema )
|
|
45
|
+
.required()
|
|
46
|
+
.min( 1 )
|
|
47
|
+
.max( 50 )
|
|
48
|
+
.description( 'Array of metrics defining the cohort' ),
|
|
49
|
+
} ).strict();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cohort Update Schema (optional fields)
|
|
53
|
+
*/
|
|
54
|
+
export const cohortUpdateSchema = Joi.object( {
|
|
55
|
+
cohortName: Joi.string().min( 3 ).max( 100 ).optional(),
|
|
56
|
+
cohortDescription: Joi.string().min( 10 ).max( 500 ).optional(),
|
|
57
|
+
metrics: Joi.array().items( metricSchema ).min( 1 ).max( 50 ).optional(),
|
|
58
|
+
} ).strict();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cohort Query Schema
|
|
62
|
+
*/
|
|
63
|
+
export const cohortQuerySchema = Joi.object( {
|
|
64
|
+
cohortId: Joi.string().optional(),
|
|
65
|
+
clientId: Joi.string().optional(),
|
|
66
|
+
cohortName: Joi.string().optional(),
|
|
67
|
+
limit: Joi.number().integer().min( 1 ).max( 100 ).default( 10 ),
|
|
68
|
+
offset: Joi.number().integer().min( 0 ).default( 0 ),
|
|
69
|
+
} ).strict();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate cohort data using Joi
|
|
73
|
+
* @param {Object} data - Data to validate
|
|
74
|
+
* @param {Joi.Schema} schema - Joi schema to validate against
|
|
75
|
+
* @return {Promise<{error: null, value: Object} | {error: Error, value: null}>}
|
|
76
|
+
*/
|
|
77
|
+
export const validateCohortData = async ( data, schema ) => {
|
|
78
|
+
try {
|
|
79
|
+
const { error, value } = schema.validate( data, {
|
|
80
|
+
abortEarly: false,
|
|
81
|
+
stripUnknown: true,
|
|
82
|
+
convert: true,
|
|
83
|
+
} );
|
|
84
|
+
|
|
85
|
+
if ( error ) {
|
|
86
|
+
const details = error.details.map( ( detail ) => ( {
|
|
87
|
+
field: detail.path.join( '.' ),
|
|
88
|
+
message: detail.message,
|
|
89
|
+
type: detail.type,
|
|
90
|
+
} ) );
|
|
91
|
+
return {
|
|
92
|
+
error: {
|
|
93
|
+
message: 'Validation failed',
|
|
94
|
+
details,
|
|
95
|
+
},
|
|
96
|
+
value: null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
error: null,
|
|
102
|
+
value,
|
|
103
|
+
};
|
|
104
|
+
} catch ( err ) {
|
|
105
|
+
return {
|
|
106
|
+
error: {
|
|
107
|
+
message: 'Validation error',
|
|
108
|
+
details: [ { message: err.message } ],
|
|
109
|
+
},
|
|
110
|
+
value: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
};
|