@recombine-ai/platform 0.2.6-test.2 → 0.2.6-test.4
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/build/analytics.d.ts +0 -2
- package/build/analytics.d.ts.map +1 -1
- package/build/analytics.js +7 -7
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/interfaces/platform.d.ts.map +1 -1
- package/build/postgres-analytics-query.d.ts +87 -0
- package/build/postgres-analytics-query.d.ts.map +1 -0
- package/build/postgres-analytics-query.js +382 -0
- package/build/postgres-analytics-query.spec.d.ts +2 -0
- package/build/postgres-analytics-query.spec.d.ts.map +1 -0
- package/build/postgres-analytics-query.spec.js +223 -0
- package/build/postgres-analytics.d.ts +2 -0
- package/build/postgres-analytics.d.ts.map +1 -1
- package/build/postgres-analytics.js +11 -0
- package/build/timeouts-manager.d.ts.map +1 -1
- package/package.json +3 -3
package/build/analytics.d.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
export type AnalyticsValueType = 'boolean' | 'number' | 'string' | 'json';
|
|
3
2
|
export type AnalyticsJsonValue = boolean | number | string | null | AnalyticsJsonValue[] | {
|
|
4
3
|
[key: string]: AnalyticsJsonValue;
|
|
5
4
|
};
|
|
6
5
|
export interface AnalyticsKey<T> {
|
|
7
6
|
readonly key: string;
|
|
8
|
-
readonly valueType: AnalyticsValueType;
|
|
9
7
|
readonly schema: z.ZodType<T>;
|
|
10
8
|
}
|
|
11
9
|
export type AnalyticsValue<TKey extends AnalyticsKey<unknown>> = TKey extends AnalyticsKey<infer TValue> ? TValue : never;
|
package/build/analytics.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../src/analytics.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../src/analytics.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAIvB,MAAM,MAAM,kBAAkB,GACxB,OAAO,GACP,MAAM,GACN,MAAM,GACN,IAAI,GACJ,kBAAkB,EAAE,GACpB;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,CAAA;CAAE,CAAA;AAI3C,MAAM,WAAW,YAAY,CAAC,CAAC;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAA;CAChC;AAID,MAAM,MAAM,cAAc,CAAC,IAAI,SAAS,YAAY,CAAC,OAAO,CAAC,IACzD,IAAI,SAAS,YAAY,CAAC,MAAM,MAAM,CAAC,GAAG,MAAM,GAAG,KAAK,CAAA;AAI5D,MAAM,WAAW,4BAA4B;IACzC,cAAc,EAAE,MAAM,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,IAAI,CAAA;IACf,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,eAAe,CAAC,EAAE,MAAM,CAAA;CAC3B;AAID,MAAM,WAAW,qBAAqB;IAClC,GAAG,CAAC,IAAI,SAAS,YAAY,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,cAAc,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9F,KAAK,CAAC,GAAG,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAChD,GAAG,CAAC,IAAI,SAAS,YAAY,CAAC,OAAO,CAAC,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,cAAc,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,CAAA;IAC7F,UAAU,IAAI,OAAO,CAAC,4BAA4B,GAAG,SAAS,CAAC,CAAA;CAClE;AAID,MAAM,WAAW,SAAS;IACtB,wBAAwB,CAAC,cAAc,EAAE,MAAM,GAAG,qBAAqB,CAAA;CAC1E;AAgBD,eAAO,MAAM,YAAY;iBACR,MAAM,GAAG,YAAY,CAAC,OAAO,CAAC;gBAG/B,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC;gBAG7B,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC;eAG9B,OAAO,SAAS,SAAS,CAAC,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,OAChD,MAAM,UACH,OAAO,GAChB,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;SAK3B,CAAC,SAAS,kBAAkB,OAAO,MAAM,UAAU,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,YAAY,CAAC,CAAC,CAAC;CAKzF,CAAA;AAID,wBAAgB,uBAAuB,CAAC,SAAS,GAAE,4BAA4B,EAAO,GAAG,SAAS,CAkDjG;AAID,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,kBAAkB,CA0B5F"}
|
package/build/analytics.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
function defineKey(key,
|
|
2
|
+
function defineKey(key, schema) {
|
|
3
3
|
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
|
|
4
4
|
throw new Error('Analytics key contains unsupported characters');
|
|
5
5
|
}
|
|
6
|
-
return Object.freeze({ key,
|
|
6
|
+
return Object.freeze({ key, schema });
|
|
7
7
|
}
|
|
8
8
|
export const analyticsKey = {
|
|
9
9
|
boolean(key) {
|
|
10
|
-
return defineKey(key,
|
|
10
|
+
return defineKey(key, z.boolean());
|
|
11
11
|
},
|
|
12
12
|
number(key) {
|
|
13
|
-
return defineKey(key,
|
|
13
|
+
return defineKey(key, z.number().finite());
|
|
14
14
|
},
|
|
15
15
|
string(key) {
|
|
16
|
-
return defineKey(key,
|
|
16
|
+
return defineKey(key, z.string());
|
|
17
17
|
},
|
|
18
18
|
enum(key, values) {
|
|
19
|
-
return defineKey(key,
|
|
19
|
+
return defineKey(key, z.enum(values));
|
|
20
20
|
},
|
|
21
21
|
json(key, schema) {
|
|
22
|
-
return defineKey(key,
|
|
22
|
+
return defineKey(key, schema);
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
export function createInMemoryAnalytics(summaries = []) {
|
package/build/index.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ export * from './interfaces/rc-voice.js';
|
|
|
3
3
|
export * from './context-cache.js';
|
|
4
4
|
export * from './analytics.js';
|
|
5
5
|
export * from './postgres-analytics.js';
|
|
6
|
+
export * from './postgres-analytics-query.js';
|
|
6
7
|
export * from './timeouts-manager.js';
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,oBAAoB,CAAA;AAGlC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,yBAAyB,CAAA;AACvC,cAAc,+BAA+B,CAAA;AAC7C,cAAc,uBAAuB,CAAA"}
|
package/build/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/interfaces/platform.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,EACH,QAAQ,EACR,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,MAAM,EACN,OAAO,EACP,QAAQ,EACR,aAAa,EAChB,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/interfaces/platform.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,EACH,QAAQ,EACR,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,MAAM,EACN,OAAO,EACP,QAAQ,EACR,aAAa,EAChB,MAAM,sBAAsB,CAAA;AAI7B,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAExC,MAAM,WAAW,OAAO;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IAClC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CACpD;AAID,MAAM,WAAW,QAAQ,CACrB,QAAQ,SAAS,EAAE,EACnB,OAAO,SAAS,OAAO,EACvB,QAAQ,GAAG,QAAQ,EACnB,eAAe,GAAG,cAAc,EAChC,aAAa,GAAG,YAAY,CAAC,QAAQ,CAAC;IAGtC,QAAQ,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAA;IAElC,EAAE,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAA;IAEtB,EAAE,EAAE,QAAQ,CAAA;IAEZ,OAAO,EAAE,QAAQ,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,eAAe,CAAA;IAE/B,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAA;IACzB,eAAe,EAAE,OAAO,CAAC,iBAAiB,CAAC,CAAA;IAE3C,YAAY,EAAE,aAAa,CAAA;IAK3B,SAAS,EAAE,SAAS,CAAA;CACvB;AAED,MAAM,WAAW,QAAQ;IACrB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,CAAC,EAAE,MAAM,CAAA;IAEpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACnC;AAED,MAAM,WAAW,UAAU;IACvB,WAAW,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC;QACrC,cAAc,EAAE,MAAM,CAAA;QACtB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA;KACpD,CAAC,CAAA;IACF,WAAW,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9C,cAAc,EAAE,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC,cAAc,CAAC,aAAa,CAAC,CAAC,CAAA;IAC/F,SAAS,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,iBAAiB,EAAE,CAAC,QAAQ,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACzE,gBAAgB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACrC,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC5D;AAED,wBAAgB,WAAW,CAAC,QAAQ,SAAS,EAAE,EAAE,SAAS,EAAE,OAAO,SAAS,OAAO,EAC/E,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,EAAE,QAAQ,EAAE,SAAS,KAAK,UAAU,cAA/D,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,YAAY,SAAS,KAAK,UAAU,CAGtF"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { PoolConfig, QueryResult } from 'pg';
|
|
2
|
+
import { AnalyticsJsonValue } from './analytics.js';
|
|
3
|
+
export type AnalyticsFilterScalar = boolean | number | string;
|
|
4
|
+
export type AnalyticsFilterValueType = 'boolean' | 'number' | 'string';
|
|
5
|
+
export type AnalyticsFilterOperator = 'eq' | 'exists' | 'gt' | 'gte' | 'in' | 'lt' | 'lte';
|
|
6
|
+
export interface AnalyticsFilter {
|
|
7
|
+
key: string;
|
|
8
|
+
type: AnalyticsFilterValueType;
|
|
9
|
+
operator: AnalyticsFilterOperator;
|
|
10
|
+
value?: AnalyticsFilterScalar;
|
|
11
|
+
values?: AnalyticsFilterScalar[];
|
|
12
|
+
}
|
|
13
|
+
export type AnalyticsMetric = {
|
|
14
|
+
operation: 'count';
|
|
15
|
+
} | {
|
|
16
|
+
operation: 'rate';
|
|
17
|
+
filter: AnalyticsFilter;
|
|
18
|
+
} | {
|
|
19
|
+
operation: 'average' | 'sum';
|
|
20
|
+
key: string;
|
|
21
|
+
};
|
|
22
|
+
export type AnalyticsTimeGranularity = 'hour' | 'day' | 'week' | 'month';
|
|
23
|
+
export type AnalyticsQueryParameters = Readonly<Record<string, unknown>>;
|
|
24
|
+
export interface TelescopeConversation {
|
|
25
|
+
conversationId: string;
|
|
26
|
+
medium: string;
|
|
27
|
+
agentId?: string;
|
|
28
|
+
region?: string;
|
|
29
|
+
phoneNumber?: string;
|
|
30
|
+
duration?: number;
|
|
31
|
+
audioUrl?: string;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
endedAt?: number;
|
|
34
|
+
analytics: Record<string, AnalyticsJsonValue>;
|
|
35
|
+
}
|
|
36
|
+
export interface TelescopeConversationPage {
|
|
37
|
+
limit: number;
|
|
38
|
+
items: TelescopeConversation[];
|
|
39
|
+
lastConversationId?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface AnalyticsFieldValue {
|
|
42
|
+
value: AnalyticsFilterScalar;
|
|
43
|
+
count: number;
|
|
44
|
+
}
|
|
45
|
+
export interface AnalyticsField {
|
|
46
|
+
key: string;
|
|
47
|
+
type: AnalyticsFilterValueType;
|
|
48
|
+
variantCount: number;
|
|
49
|
+
values: AnalyticsFieldValue[];
|
|
50
|
+
}
|
|
51
|
+
export interface AnalyticsFields {
|
|
52
|
+
items: AnalyticsField[];
|
|
53
|
+
}
|
|
54
|
+
export interface AnalyticsTimeSeriesItem {
|
|
55
|
+
startAt: string;
|
|
56
|
+
value: number | null;
|
|
57
|
+
numerator?: number;
|
|
58
|
+
denominator?: number;
|
|
59
|
+
}
|
|
60
|
+
export interface AnalyticsTimeSeries {
|
|
61
|
+
metric: AnalyticsMetric;
|
|
62
|
+
granularity: AnalyticsTimeGranularity;
|
|
63
|
+
from: string;
|
|
64
|
+
to: string;
|
|
65
|
+
items: AnalyticsTimeSeriesItem[];
|
|
66
|
+
}
|
|
67
|
+
interface Queryable {
|
|
68
|
+
query<TResult extends Record<string, unknown> = Record<string, unknown>>(text: string, values?: unknown[]): Promise<QueryResult<TResult>>;
|
|
69
|
+
}
|
|
70
|
+
export declare class AnalyticsQueryError extends Error {
|
|
71
|
+
name: string;
|
|
72
|
+
}
|
|
73
|
+
export declare class PostgresAnalyticsQueryService {
|
|
74
|
+
private readonly database;
|
|
75
|
+
constructor(database: Queryable);
|
|
76
|
+
getConversation(conversationId: string): Promise<TelescopeConversation | undefined>;
|
|
77
|
+
getConversations(query: AnalyticsQueryParameters): Promise<TelescopeConversationPage>;
|
|
78
|
+
getAnalyticsFields(query: AnalyticsQueryParameters): Promise<AnalyticsFields>;
|
|
79
|
+
getTimeSeries(query: AnalyticsQueryParameters): Promise<AnalyticsTimeSeries>;
|
|
80
|
+
}
|
|
81
|
+
export interface PostgresAnalyticsQueryServices {
|
|
82
|
+
queries: PostgresAnalyticsQueryService;
|
|
83
|
+
close(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
export declare function createPostgresAnalyticsQueryServices(config: PoolConfig): PostgresAnalyticsQueryServices;
|
|
86
|
+
export {};
|
|
87
|
+
//# sourceMappingURL=postgres-analytics-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres-analytics-query.d.ts","sourceRoot":"","sources":["../src/postgres-analytics-query.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
export class AnalyticsQueryError extends Error {
|
|
3
|
+
name = 'AnalyticsQueryError';
|
|
4
|
+
}
|
|
5
|
+
export class PostgresAnalyticsQueryService {
|
|
6
|
+
database;
|
|
7
|
+
constructor(database) {
|
|
8
|
+
this.database = database;
|
|
9
|
+
}
|
|
10
|
+
async getConversation(conversationId) {
|
|
11
|
+
const result = await this.database.query(`SELECT c.conversation_id, c.agent_id, c.channel, c.region, c.started_at,
|
|
12
|
+
c.ended_at, c.duration_seconds, c.analytics,
|
|
13
|
+
p.phone_number, p.recording_url
|
|
14
|
+
FROM conversations c
|
|
15
|
+
LEFT JOIN phone_calls p ON p.conversation_id = c.conversation_id
|
|
16
|
+
WHERE c.conversation_id = $1`, [conversationId]);
|
|
17
|
+
const row = result.rows[0];
|
|
18
|
+
return row ? conversationRowToJson(row) : undefined;
|
|
19
|
+
}
|
|
20
|
+
async getConversations(query) {
|
|
21
|
+
const parsedLimit = Number.parseInt(optionalString(query.limit, 'limit') ?? '10', 10);
|
|
22
|
+
const requestedLimit = Number.isFinite(parsedLimit)
|
|
23
|
+
? Math.min(100, Math.max(1, parsedLimit))
|
|
24
|
+
: 10;
|
|
25
|
+
const cursor = parseConversationCursor(optionalString(query.lastConversationId, 'lastConversationId'));
|
|
26
|
+
const { bind, conditions, values } = buildConversationConditions(query);
|
|
27
|
+
if (cursor) {
|
|
28
|
+
conditions.push(`(c.started_at, c.conversation_id) < (${bind(cursor.startedAt)}, ${bind(cursor.conversationId)})`);
|
|
29
|
+
}
|
|
30
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
31
|
+
const limitParameter = bind(requestedLimit + 1);
|
|
32
|
+
const result = await this.database.query(`SELECT c.conversation_id, c.agent_id, c.channel, c.region, c.started_at,
|
|
33
|
+
c.ended_at, c.duration_seconds, c.analytics,
|
|
34
|
+
p.phone_number, p.recording_url
|
|
35
|
+
FROM conversations c
|
|
36
|
+
LEFT JOIN phone_calls p ON p.conversation_id = c.conversation_id
|
|
37
|
+
${where}
|
|
38
|
+
ORDER BY c.started_at DESC, c.conversation_id DESC
|
|
39
|
+
LIMIT ${limitParameter}`, values);
|
|
40
|
+
const hasNextPage = result.rows.length > requestedLimit;
|
|
41
|
+
const rows = result.rows.slice(0, requestedLimit);
|
|
42
|
+
return {
|
|
43
|
+
limit: requestedLimit,
|
|
44
|
+
items: rows.map(conversationRowToJson),
|
|
45
|
+
lastConversationId: hasNextPage && rows.length > 0 ? makeConversationCursor(rows.at(-1)) : undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async getAnalyticsFields(query) {
|
|
49
|
+
const values = [];
|
|
50
|
+
let where = "WHERE jsonb_typeof(entry.value) IN ('boolean', 'number', 'string')";
|
|
51
|
+
const agentId = optionalString(query.agentId, 'agentId');
|
|
52
|
+
if (agentId) {
|
|
53
|
+
values.push(agentId);
|
|
54
|
+
where += ` AND c.agent_id = $${values.length}`;
|
|
55
|
+
}
|
|
56
|
+
const result = await this.database.query(`WITH value_counts AS (
|
|
57
|
+
SELECT entry.key,
|
|
58
|
+
jsonb_typeof(entry.value) AS type,
|
|
59
|
+
entry.value AS value,
|
|
60
|
+
COUNT(*)::bigint AS occurrence_count
|
|
61
|
+
FROM conversations c
|
|
62
|
+
CROSS JOIN LATERAL jsonb_each(c.analytics) AS entry(key, value)
|
|
63
|
+
${where}
|
|
64
|
+
GROUP BY entry.key, type, entry.value
|
|
65
|
+
),
|
|
66
|
+
ranked AS (
|
|
67
|
+
SELECT key,
|
|
68
|
+
type,
|
|
69
|
+
value,
|
|
70
|
+
occurrence_count,
|
|
71
|
+
COUNT(*) OVER (PARTITION BY key, type)::integer AS variant_count,
|
|
72
|
+
ROW_NUMBER() OVER (
|
|
73
|
+
PARTITION BY key, type
|
|
74
|
+
ORDER BY occurrence_count DESC, value::text ASC
|
|
75
|
+
) AS rank
|
|
76
|
+
FROM value_counts
|
|
77
|
+
)
|
|
78
|
+
SELECT key, type, value, occurrence_count, variant_count
|
|
79
|
+
FROM ranked
|
|
80
|
+
WHERE rank <= 50
|
|
81
|
+
ORDER BY key ASC, type ASC, rank ASC`, values);
|
|
82
|
+
return analyticsFieldRowsToJson(result.rows);
|
|
83
|
+
}
|
|
84
|
+
async getTimeSeries(query) {
|
|
85
|
+
const metric = parseMetric(requiredString(query.metric, 'metric'));
|
|
86
|
+
const granularity = (optionalString(query.granularity, 'granularity') ??
|
|
87
|
+
'day');
|
|
88
|
+
if (!['hour', 'day', 'week', 'month'].includes(granularity)) {
|
|
89
|
+
throw new AnalyticsQueryError('granularity must be hour, day, week, or month');
|
|
90
|
+
}
|
|
91
|
+
const { bind, conditions, from, to, values } = buildConversationConditions(query);
|
|
92
|
+
if (!from || !to) {
|
|
93
|
+
throw new AnalyticsQueryError('from and to are required for a time series');
|
|
94
|
+
}
|
|
95
|
+
const bucketExpression = `date_trunc(${bind(granularity)}::text, c.started_at, 'UTC')`;
|
|
96
|
+
let valueExpression;
|
|
97
|
+
let numeratorExpression = 'NULL::bigint';
|
|
98
|
+
if (metric.operation === 'count') {
|
|
99
|
+
valueExpression = 'COUNT(*)::double precision';
|
|
100
|
+
}
|
|
101
|
+
else if (metric.operation === 'rate') {
|
|
102
|
+
const predicate = makeAnalyticsPredicate(metric.filter, bind);
|
|
103
|
+
numeratorExpression = `COUNT(*) FILTER (WHERE ${predicate})::bigint`;
|
|
104
|
+
valueExpression =
|
|
105
|
+
`COUNT(*) FILTER (WHERE ${predicate})::double precision / ` + 'NULLIF(COUNT(*), 0)';
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const key = bind(metric.key);
|
|
109
|
+
const numericValue = `CASE WHEN jsonb_typeof(c.analytics -> (${key}::text)) = 'number' ` +
|
|
110
|
+
`THEN (c.analytics ->> (${key}::text))::double precision END`;
|
|
111
|
+
valueExpression =
|
|
112
|
+
metric.operation === 'average' ? `AVG(${numericValue})` : `SUM(${numericValue})`;
|
|
113
|
+
}
|
|
114
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
115
|
+
const result = await this.database.query(`SELECT ${bucketExpression} AS bucket_start,
|
|
116
|
+
${valueExpression} AS metric_value,
|
|
117
|
+
${numeratorExpression} AS numerator,
|
|
118
|
+
COUNT(*)::bigint AS denominator
|
|
119
|
+
FROM conversations c
|
|
120
|
+
LEFT JOIN phone_calls p ON p.conversation_id = c.conversation_id
|
|
121
|
+
${where}
|
|
122
|
+
GROUP BY bucket_start
|
|
123
|
+
ORDER BY bucket_start ASC`, values);
|
|
124
|
+
return {
|
|
125
|
+
metric,
|
|
126
|
+
granularity,
|
|
127
|
+
from: from.toISOString(),
|
|
128
|
+
to: to.toISOString(),
|
|
129
|
+
items: result.rows.map((row) => ({
|
|
130
|
+
startAt: new Date(row.bucket_start).toISOString(),
|
|
131
|
+
value: row.metric_value === null ? null : Number(row.metric_value),
|
|
132
|
+
...(metric.operation === 'rate'
|
|
133
|
+
? {
|
|
134
|
+
numerator: Number(row.numerator),
|
|
135
|
+
denominator: Number(row.denominator),
|
|
136
|
+
}
|
|
137
|
+
: {}),
|
|
138
|
+
})),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function createPostgresAnalyticsQueryServices(config) {
|
|
143
|
+
const pool = new Pool(config);
|
|
144
|
+
return {
|
|
145
|
+
queries: new PostgresAnalyticsQueryService(pool),
|
|
146
|
+
close: () => pool.end(),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function optionalString(value, name) {
|
|
150
|
+
if (value === undefined)
|
|
151
|
+
return undefined;
|
|
152
|
+
if (typeof value !== 'string') {
|
|
153
|
+
throw new AnalyticsQueryError(`${name} must be a string`);
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
function requiredString(value, name) {
|
|
158
|
+
const result = optionalString(value, name);
|
|
159
|
+
if (!result) {
|
|
160
|
+
throw new AnalyticsQueryError(`${name} is required`);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
function parseDateFilter(value, name) {
|
|
165
|
+
const stringValue = optionalString(value, name);
|
|
166
|
+
if (stringValue === undefined)
|
|
167
|
+
return undefined;
|
|
168
|
+
const date = /^\d+$/.test(stringValue) ? new Date(Number(stringValue)) : new Date(stringValue);
|
|
169
|
+
if (Number.isNaN(date.getTime())) {
|
|
170
|
+
throw new AnalyticsQueryError(`${name} must be a valid date`);
|
|
171
|
+
}
|
|
172
|
+
return date;
|
|
173
|
+
}
|
|
174
|
+
function parseDurationFilter(value, name) {
|
|
175
|
+
const stringValue = optionalString(value, name);
|
|
176
|
+
if (stringValue === undefined)
|
|
177
|
+
return undefined;
|
|
178
|
+
const duration = Number(stringValue);
|
|
179
|
+
if (!Number.isFinite(duration) || duration < 0) {
|
|
180
|
+
throw new AnalyticsQueryError(`${name} must be a non-negative number`);
|
|
181
|
+
}
|
|
182
|
+
return duration;
|
|
183
|
+
}
|
|
184
|
+
function parseAnalyticsFilters(value) {
|
|
185
|
+
const stringValue = optionalString(value, 'filters');
|
|
186
|
+
if (!stringValue)
|
|
187
|
+
return [];
|
|
188
|
+
let filters;
|
|
189
|
+
try {
|
|
190
|
+
filters = JSON.parse(stringValue);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
throw new AnalyticsQueryError('filters must be valid JSON');
|
|
194
|
+
}
|
|
195
|
+
if (!Array.isArray(filters) || filters.length > 20) {
|
|
196
|
+
throw new AnalyticsQueryError('filters must be an array with at most 20 entries');
|
|
197
|
+
}
|
|
198
|
+
return filters.map(validateAnalyticsFilter);
|
|
199
|
+
}
|
|
200
|
+
function validateAnalyticsFilter(value) {
|
|
201
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
202
|
+
throw new AnalyticsQueryError('Each filter must be an object');
|
|
203
|
+
}
|
|
204
|
+
const filter = value;
|
|
205
|
+
if (typeof filter.key !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(filter.key)) {
|
|
206
|
+
throw new AnalyticsQueryError('Filter key is invalid');
|
|
207
|
+
}
|
|
208
|
+
if (!['boolean', 'number', 'string'].includes(String(filter.type))) {
|
|
209
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid type`);
|
|
210
|
+
}
|
|
211
|
+
const type = filter.type;
|
|
212
|
+
const allowedOperators = type === 'number'
|
|
213
|
+
? ['eq', 'in', 'gt', 'gte', 'lt', 'lte', 'exists']
|
|
214
|
+
: ['eq', 'in', 'exists'];
|
|
215
|
+
if (!allowedOperators.includes(String(filter.operator))) {
|
|
216
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid operator`);
|
|
217
|
+
}
|
|
218
|
+
const operator = filter.operator;
|
|
219
|
+
if (operator === 'in') {
|
|
220
|
+
if (!Array.isArray(filter.values) ||
|
|
221
|
+
filter.values.length === 0 ||
|
|
222
|
+
filter.values.length > 50 ||
|
|
223
|
+
filter.values.some((item) => typeof item !== type || (type === 'number' && !Number.isFinite(item)))) {
|
|
224
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has invalid values`);
|
|
225
|
+
}
|
|
226
|
+
return { key: filter.key, type, operator, values: filter.values };
|
|
227
|
+
}
|
|
228
|
+
if (operator !== 'exists' && typeof filter.value !== type) {
|
|
229
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid value`);
|
|
230
|
+
}
|
|
231
|
+
if (type === 'number' && !Number.isFinite(filter.value)) {
|
|
232
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid value`);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
key: filter.key,
|
|
236
|
+
type,
|
|
237
|
+
operator,
|
|
238
|
+
...(operator === 'exists' ? {} : { value: filter.value }),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function parseMetric(value) {
|
|
242
|
+
let metric;
|
|
243
|
+
try {
|
|
244
|
+
metric = JSON.parse(value);
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
throw new AnalyticsQueryError('metric must be valid JSON');
|
|
248
|
+
}
|
|
249
|
+
if (!metric || typeof metric !== 'object' || Array.isArray(metric)) {
|
|
250
|
+
throw new AnalyticsQueryError('metric must be an object');
|
|
251
|
+
}
|
|
252
|
+
const record = metric;
|
|
253
|
+
if (!['count', 'rate', 'average', 'sum'].includes(String(record.operation))) {
|
|
254
|
+
throw new AnalyticsQueryError('metric operation is invalid');
|
|
255
|
+
}
|
|
256
|
+
if (record.operation === 'rate') {
|
|
257
|
+
return { operation: 'rate', filter: validateAnalyticsFilter(record.filter) };
|
|
258
|
+
}
|
|
259
|
+
if (record.operation === 'average' || record.operation === 'sum') {
|
|
260
|
+
if (typeof record.key !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(record.key)) {
|
|
261
|
+
throw new AnalyticsQueryError('Metric key is invalid');
|
|
262
|
+
}
|
|
263
|
+
return { operation: record.operation, key: record.key };
|
|
264
|
+
}
|
|
265
|
+
return { operation: 'count' };
|
|
266
|
+
}
|
|
267
|
+
function parseConversationCursor(value) {
|
|
268
|
+
if (!value)
|
|
269
|
+
return undefined;
|
|
270
|
+
try {
|
|
271
|
+
const cursor = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
|
|
272
|
+
const startedAt = new Date(String(cursor.startedAt));
|
|
273
|
+
if (Number.isNaN(startedAt.getTime()) ||
|
|
274
|
+
typeof cursor.conversationId !== 'string' ||
|
|
275
|
+
cursor.conversationId === '') {
|
|
276
|
+
throw new Error('invalid cursor');
|
|
277
|
+
}
|
|
278
|
+
return { startedAt, conversationId: cursor.conversationId };
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
throw new AnalyticsQueryError('lastConversationId is invalid');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function makeConversationCursor(row) {
|
|
285
|
+
return Buffer.from(JSON.stringify({
|
|
286
|
+
startedAt: new Date(row.started_at).toISOString(),
|
|
287
|
+
conversationId: row.conversation_id,
|
|
288
|
+
})).toString('base64url');
|
|
289
|
+
}
|
|
290
|
+
function conversationRowToJson(row) {
|
|
291
|
+
return {
|
|
292
|
+
conversationId: row.conversation_id,
|
|
293
|
+
medium: row.channel,
|
|
294
|
+
agentId: row.agent_id || undefined,
|
|
295
|
+
region: row.region || undefined,
|
|
296
|
+
phoneNumber: row.phone_number || undefined,
|
|
297
|
+
duration: row.duration_seconds ?? undefined,
|
|
298
|
+
audioUrl: row.recording_url || undefined,
|
|
299
|
+
createdAt: new Date(row.started_at).getTime(),
|
|
300
|
+
endedAt: row.ended_at ? new Date(row.ended_at).getTime() : undefined,
|
|
301
|
+
analytics: row.analytics ?? {},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function analyticsFieldRowsToJson(rows) {
|
|
305
|
+
const fields = new Map();
|
|
306
|
+
for (const row of rows) {
|
|
307
|
+
const id = `${row.key}\u0000${row.type}`;
|
|
308
|
+
let field = fields.get(id);
|
|
309
|
+
if (!field) {
|
|
310
|
+
field = {
|
|
311
|
+
key: row.key,
|
|
312
|
+
type: row.type,
|
|
313
|
+
variantCount: Number(row.variant_count),
|
|
314
|
+
values: [],
|
|
315
|
+
};
|
|
316
|
+
fields.set(id, field);
|
|
317
|
+
}
|
|
318
|
+
if (field.variantCount <= 50) {
|
|
319
|
+
field.values.push({ value: row.value, count: Number(row.occurrence_count) });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { items: [...fields.values()] };
|
|
323
|
+
}
|
|
324
|
+
function buildConversationConditions(query) {
|
|
325
|
+
const values = [];
|
|
326
|
+
const bind = (value) => {
|
|
327
|
+
values.push(value);
|
|
328
|
+
return `$${values.length}`;
|
|
329
|
+
};
|
|
330
|
+
const conditions = [];
|
|
331
|
+
const from = parseDateFilter(query.from, 'from');
|
|
332
|
+
const to = parseDateFilter(query.to, 'to');
|
|
333
|
+
if (from && to && from >= to) {
|
|
334
|
+
throw new AnalyticsQueryError('from must be earlier than to');
|
|
335
|
+
}
|
|
336
|
+
const agentId = optionalString(query.agentId, 'agentId');
|
|
337
|
+
const region = optionalString(query.region, 'region');
|
|
338
|
+
const channel = optionalString(query.channel, 'channel');
|
|
339
|
+
const phoneNumber = optionalString(query.phoneNumber, 'phoneNumber');
|
|
340
|
+
if (agentId)
|
|
341
|
+
conditions.push(`c.agent_id = ${bind(agentId)}`);
|
|
342
|
+
if (region)
|
|
343
|
+
conditions.push(`c.region = ${bind(region)}`);
|
|
344
|
+
if (channel)
|
|
345
|
+
conditions.push(`c.channel = ${bind(channel)}`);
|
|
346
|
+
if (phoneNumber)
|
|
347
|
+
conditions.push(`p.phone_number = ${bind(phoneNumber)}`);
|
|
348
|
+
if (from)
|
|
349
|
+
conditions.push(`c.started_at >= ${bind(from)}`);
|
|
350
|
+
if (to)
|
|
351
|
+
conditions.push(`c.started_at < ${bind(to)}`);
|
|
352
|
+
const minDuration = parseDurationFilter(query.minDurationSeconds, 'minDurationSeconds');
|
|
353
|
+
const maxDuration = parseDurationFilter(query.maxDurationSeconds, 'maxDurationSeconds');
|
|
354
|
+
if (minDuration !== undefined)
|
|
355
|
+
conditions.push(`c.duration_seconds >= ${bind(minDuration)}`);
|
|
356
|
+
if (maxDuration !== undefined)
|
|
357
|
+
conditions.push(`c.duration_seconds <= ${bind(maxDuration)}`);
|
|
358
|
+
if (minDuration !== undefined && maxDuration !== undefined && minDuration > maxDuration) {
|
|
359
|
+
throw new AnalyticsQueryError('minDurationSeconds must not exceed maxDurationSeconds');
|
|
360
|
+
}
|
|
361
|
+
for (const filter of parseAnalyticsFilters(query.filters)) {
|
|
362
|
+
conditions.push(makeAnalyticsPredicate(filter, bind));
|
|
363
|
+
}
|
|
364
|
+
return { bind, conditions, from, to, values };
|
|
365
|
+
}
|
|
366
|
+
function makeAnalyticsPredicate(filter, bind) {
|
|
367
|
+
const key = bind(filter.key);
|
|
368
|
+
if (filter.operator === 'exists') {
|
|
369
|
+
return `c.analytics ? (${key}::text)`;
|
|
370
|
+
}
|
|
371
|
+
if (filter.operator === 'eq') {
|
|
372
|
+
return `c.analytics @> jsonb_build_object(${key}::text, ${bind(JSON.stringify(filter.value))}::jsonb)`;
|
|
373
|
+
}
|
|
374
|
+
if (filter.operator === 'in') {
|
|
375
|
+
const predicates = filter.values.map((value) => `c.analytics @> jsonb_build_object(${key}::text, ${bind(JSON.stringify(value))}::jsonb)`);
|
|
376
|
+
return `(${predicates.join(' OR ')})`;
|
|
377
|
+
}
|
|
378
|
+
const operator = { gt: '>', gte: '>=', lt: '<', lte: '<=' }[filter.operator];
|
|
379
|
+
return (`(CASE WHEN jsonb_typeof(c.analytics -> (${key}::text)) = 'number' THEN ` +
|
|
380
|
+
`(c.analytics ->> (${key}::text))::double precision ${operator} ${bind(filter.value)} ` +
|
|
381
|
+
'ELSE false END)');
|
|
382
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres-analytics-query.spec.d.ts","sourceRoot":"","sources":["../src/postgres-analytics-query.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { AnalyticsQueryError, PostgresAnalyticsQueryService } from './postgres-analytics-query.js';
|
|
3
|
+
function result(rows = []) {
|
|
4
|
+
return { rows, rowCount: rows.length };
|
|
5
|
+
}
|
|
6
|
+
describe('PostgresAnalyticsQueryService', () => {
|
|
7
|
+
it('binds each analytics filter once', async () => {
|
|
8
|
+
const query = vi.fn().mockResolvedValue(result());
|
|
9
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
10
|
+
await service.getConversations({
|
|
11
|
+
agentId: 'collection-en',
|
|
12
|
+
limit: '20',
|
|
13
|
+
filters: JSON.stringify([
|
|
14
|
+
{
|
|
15
|
+
key: 'wandoo.disposition',
|
|
16
|
+
type: 'string',
|
|
17
|
+
operator: 'eq',
|
|
18
|
+
value: 'PAID',
|
|
19
|
+
},
|
|
20
|
+
]),
|
|
21
|
+
});
|
|
22
|
+
const [sql, values] = query.mock.calls[0];
|
|
23
|
+
expect(sql.match(/jsonb_build_object/g)).toHaveLength(1);
|
|
24
|
+
expect(values).toEqual(['collection-en', 'wandoo.disposition', '"PAID"', 21]);
|
|
25
|
+
});
|
|
26
|
+
it('ANDs a multiselect filter with the other analytics filters', async () => {
|
|
27
|
+
const query = vi.fn().mockResolvedValue(result());
|
|
28
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
29
|
+
await service.getConversations({
|
|
30
|
+
filters: JSON.stringify([
|
|
31
|
+
{
|
|
32
|
+
key: 'wandoo.escalated',
|
|
33
|
+
type: 'boolean',
|
|
34
|
+
operator: 'eq',
|
|
35
|
+
value: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'wandoo.disposition',
|
|
39
|
+
type: 'string',
|
|
40
|
+
operator: 'in',
|
|
41
|
+
values: ['PAID', 'DROPPED'],
|
|
42
|
+
},
|
|
43
|
+
]),
|
|
44
|
+
});
|
|
45
|
+
const [sql, values] = query.mock.calls[0];
|
|
46
|
+
expect(sql).toContain('jsonb_build_object($1::text, $2::jsonb) AND');
|
|
47
|
+
expect(sql).toContain('(c.analytics @> jsonb_build_object($3::text, $4::jsonb) OR ' +
|
|
48
|
+
'c.analytics @> jsonb_build_object($3::text, $5::jsonb))');
|
|
49
|
+
expect(values).toEqual([
|
|
50
|
+
'wandoo.escalated',
|
|
51
|
+
'true',
|
|
52
|
+
'wandoo.disposition',
|
|
53
|
+
'"PAID"',
|
|
54
|
+
'"DROPPED"',
|
|
55
|
+
11,
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
it('uses the returned keyset cursor on the next page', async () => {
|
|
59
|
+
const firstStartedAt = new Date('2026-08-02T10:00:00Z');
|
|
60
|
+
const query = vi
|
|
61
|
+
.fn()
|
|
62
|
+
.mockResolvedValueOnce(result([
|
|
63
|
+
{
|
|
64
|
+
conversation_id: 'conversation-2',
|
|
65
|
+
channel: 'phone',
|
|
66
|
+
started_at: firstStartedAt,
|
|
67
|
+
analytics: {},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
conversation_id: 'conversation-1',
|
|
71
|
+
channel: 'phone',
|
|
72
|
+
started_at: new Date('2026-08-01T10:00:00Z'),
|
|
73
|
+
analytics: {},
|
|
74
|
+
},
|
|
75
|
+
]))
|
|
76
|
+
.mockResolvedValueOnce(result());
|
|
77
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
78
|
+
const firstPage = await service.getConversations({ limit: '1' });
|
|
79
|
+
expect(firstPage.lastConversationId).toBeTypeOf('string');
|
|
80
|
+
await service.getConversations({
|
|
81
|
+
limit: '1',
|
|
82
|
+
lastConversationId: firstPage.lastConversationId,
|
|
83
|
+
});
|
|
84
|
+
const [sql, values] = query.mock.calls[1];
|
|
85
|
+
expect(sql).toContain('(c.started_at, c.conversation_id) < ($1, $2)');
|
|
86
|
+
expect(values).toEqual([firstStartedAt, 'conversation-2', 2]);
|
|
87
|
+
});
|
|
88
|
+
it('rejects malformed filters without querying PostgreSQL', async () => {
|
|
89
|
+
const query = vi.fn();
|
|
90
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
91
|
+
await expect(service.getConversations({
|
|
92
|
+
filters: JSON.stringify([
|
|
93
|
+
{
|
|
94
|
+
key: 'wandoo.disposition',
|
|
95
|
+
type: 'string',
|
|
96
|
+
operator: 'gt',
|
|
97
|
+
value: 'PAID',
|
|
98
|
+
},
|
|
99
|
+
]),
|
|
100
|
+
})).rejects.toBeInstanceOf(AnalyticsQueryError);
|
|
101
|
+
expect(query).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
it('omits value counts for fields with more than 50 variants', async () => {
|
|
104
|
+
const query = vi.fn().mockResolvedValue(result([
|
|
105
|
+
{
|
|
106
|
+
key: 'wandoo.disposition',
|
|
107
|
+
type: 'string',
|
|
108
|
+
value: 'PAID',
|
|
109
|
+
occurrence_count: '3',
|
|
110
|
+
variant_count: 2,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: 'wandoo.reference',
|
|
114
|
+
type: 'string',
|
|
115
|
+
value: 'first',
|
|
116
|
+
occurrence_count: '1',
|
|
117
|
+
variant_count: 51,
|
|
118
|
+
},
|
|
119
|
+
]));
|
|
120
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
121
|
+
await expect(service.getAnalyticsFields({ agentId: 'collection-en' })).resolves.toEqual({
|
|
122
|
+
items: [
|
|
123
|
+
{
|
|
124
|
+
key: 'wandoo.disposition',
|
|
125
|
+
type: 'string',
|
|
126
|
+
variantCount: 2,
|
|
127
|
+
values: [{ value: 'PAID', count: 3 }],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
key: 'wandoo.reference',
|
|
131
|
+
type: 'string',
|
|
132
|
+
variantCount: 51,
|
|
133
|
+
values: [],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
it('returns a rate time series with numerator and denominator', async () => {
|
|
139
|
+
const query = vi.fn().mockResolvedValue(result([
|
|
140
|
+
{
|
|
141
|
+
bucket_start: new Date('2026-08-03T00:00:00Z'),
|
|
142
|
+
metric_value: '0.25',
|
|
143
|
+
numerator: '1',
|
|
144
|
+
denominator: '4',
|
|
145
|
+
},
|
|
146
|
+
]));
|
|
147
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
148
|
+
const metric = {
|
|
149
|
+
operation: 'rate',
|
|
150
|
+
filter: {
|
|
151
|
+
key: 'wandoo.escalated',
|
|
152
|
+
type: 'boolean',
|
|
153
|
+
operator: 'eq',
|
|
154
|
+
value: true,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
await expect(service.getTimeSeries({
|
|
158
|
+
from: '2026-08-01T00:00:00Z',
|
|
159
|
+
to: '2026-09-01T00:00:00Z',
|
|
160
|
+
granularity: 'week',
|
|
161
|
+
metric: JSON.stringify(metric),
|
|
162
|
+
})).resolves.toEqual({
|
|
163
|
+
metric,
|
|
164
|
+
granularity: 'week',
|
|
165
|
+
from: '2026-08-01T00:00:00.000Z',
|
|
166
|
+
to: '2026-09-01T00:00:00.000Z',
|
|
167
|
+
items: [
|
|
168
|
+
{
|
|
169
|
+
startAt: '2026-08-03T00:00:00.000Z',
|
|
170
|
+
value: 0.25,
|
|
171
|
+
numerator: 1,
|
|
172
|
+
denominator: 4,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
const [sql] = query.mock.calls[0];
|
|
177
|
+
expect(sql).toContain("date_trunc($3::text, c.started_at, 'UTC')");
|
|
178
|
+
});
|
|
179
|
+
it.each([
|
|
180
|
+
['average', 'AVG'],
|
|
181
|
+
['sum', 'SUM'],
|
|
182
|
+
])('builds the %s metric', async (operation, sqlFunction) => {
|
|
183
|
+
const query = vi.fn().mockResolvedValue(result());
|
|
184
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
185
|
+
await service.getTimeSeries({
|
|
186
|
+
from: '2026-08-01T00:00:00Z',
|
|
187
|
+
to: '2026-09-01T00:00:00Z',
|
|
188
|
+
metric: JSON.stringify({ operation, key: 'wandoo.score' }),
|
|
189
|
+
});
|
|
190
|
+
const [sql, values] = query.mock.calls[0];
|
|
191
|
+
expect(sql).toContain(`${sqlFunction}(CASE WHEN jsonb_typeof`);
|
|
192
|
+
expect(values).toEqual([
|
|
193
|
+
new Date('2026-08-01T00:00:00Z'),
|
|
194
|
+
new Date('2026-09-01T00:00:00Z'),
|
|
195
|
+
'day',
|
|
196
|
+
'wandoo.score',
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
it('maps one PostgreSQL conversation to the Telescope response', async () => {
|
|
200
|
+
const query = vi.fn().mockResolvedValue(result([
|
|
201
|
+
{
|
|
202
|
+
conversation_id: 'conversation-1',
|
|
203
|
+
agent_id: 'collection-en',
|
|
204
|
+
channel: 'phone',
|
|
205
|
+
region: 'GB',
|
|
206
|
+
started_at: new Date('2026-08-01T10:00:00Z'),
|
|
207
|
+
ended_at: new Date('2026-08-01T10:02:00Z'),
|
|
208
|
+
duration_seconds: 120,
|
|
209
|
+
analytics: { 'wandoo.escalated': true },
|
|
210
|
+
phone_number: '+440000000000',
|
|
211
|
+
recording_url: 'https://example.test/recording.wav',
|
|
212
|
+
},
|
|
213
|
+
]));
|
|
214
|
+
const service = new PostgresAnalyticsQueryService({ query });
|
|
215
|
+
await expect(service.getConversation('conversation-1')).resolves.toMatchObject({
|
|
216
|
+
conversationId: 'conversation-1',
|
|
217
|
+
medium: 'phone',
|
|
218
|
+
duration: 120,
|
|
219
|
+
analytics: { 'wandoo.escalated': true },
|
|
220
|
+
phoneNumber: '+440000000000',
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Pool, PoolConfig, QueryResult } from 'pg';
|
|
2
2
|
import { Analytics, ConversationAnalytics } from './analytics.js';
|
|
3
|
+
import { PostgresAnalyticsQueryService } from './postgres-analytics-query.js';
|
|
3
4
|
export interface ConversationRecord {
|
|
4
5
|
conversationId: string;
|
|
5
6
|
agentId?: string;
|
|
@@ -34,6 +35,7 @@ export declare function migrateAnalyticsDatabase(pool: Pool): Promise<void>;
|
|
|
34
35
|
export interface PostgresAnalyticsServices {
|
|
35
36
|
analytics: PostgresAnalytics;
|
|
36
37
|
conversations: PostgresConversationRepository;
|
|
38
|
+
queries: PostgresAnalyticsQueryService;
|
|
37
39
|
close(): Promise<void>;
|
|
38
40
|
}
|
|
39
41
|
export declare function createPostgresAnalyticsServices(config: PoolConfig): Promise<PostgresAnalyticsServices>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"postgres-analytics.d.ts","sourceRoot":"","sources":["../src/postgres-analytics.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAc,UAAU,EAAE,WAAW,EAAE,MAAM,IAAI,CAAA;AAE9D,OAAO,EACH,SAAS,EAGT,qBAAqB,EAGxB,MAAM,gBAAgB,CAAA;
|
|
1
|
+
{"version":3,"file":"postgres-analytics.d.ts","sourceRoot":"","sources":["../src/postgres-analytics.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAc,UAAU,EAAE,WAAW,EAAE,MAAM,IAAI,CAAA;AAE9D,OAAO,EACH,SAAS,EAGT,qBAAqB,EAGxB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,6BAA6B,EAAE,MAAM,+BAA+B,CAAA;AAI7E,MAAM,WAAW,kBAAkB;IAC/B,cAAc,EAAE,MAAM,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,IAAI,CAAA;IACf,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,eAAe,CAAC,EAAE,MAAM,CAAA;CAC3B;AAID,MAAM,WAAW,eAAe;IAC5B,cAAc,EAAE,MAAM,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAID,UAAU,SAAS;IACf,KAAK,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnE,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACnB,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAA;CACnC;AAgBD,qBAAa,iBAAkB,YAAW,SAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAAR,QAAQ,EAAE,SAAS;IAIhD,wBAAwB,CAAC,cAAc,EAAE,MAAM,GAAG,qBAAqB;CAqF1E;AAYD,qBAAa,8BAA8B;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,gBAAgB,CAClB,YAAY,EAAE,kBAAkB,EAChC,SAAS,CAAC,EAAE,eAAe,GAC5B,OAAO,CAAC,IAAI,CAAC;YAmBF,kBAAkB;YAoClB,eAAe;CAsBhC;AA4DD,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CA2CxE;AAID,MAAM,WAAW,yBAAyB;IACtC,SAAS,EAAE,iBAAiB,CAAA;IAC5B,aAAa,EAAE,8BAA8B,CAAA;IAC7C,OAAO,EAAE,6BAA6B,CAAA;IACtC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACzB;AAID,wBAAsB,+BAA+B,CACjD,MAAM,EAAE,UAAU,GACnB,OAAO,CAAC,yBAAyB,CAAC,CAgBpC"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Pool } from 'pg';
|
|
2
2
|
import { assertAnalyticsJsonValue, } from './analytics.js';
|
|
3
|
+
import { PostgresAnalyticsQueryService } from './postgres-analytics-query.js';
|
|
3
4
|
export class PostgresAnalytics {
|
|
4
5
|
database;
|
|
5
6
|
constructor(database) {
|
|
@@ -142,6 +143,8 @@ const migrations = [
|
|
|
142
143
|
{
|
|
143
144
|
version: 1,
|
|
144
145
|
sql: `
|
|
146
|
+
-- Analytics rationale: This is the channel-neutral parent row and top-level JSONB map.
|
|
147
|
+
-- Removing it leaves typed values with no lifecycle or common dimensions to attach to.
|
|
145
148
|
CREATE TABLE conversations (
|
|
146
149
|
conversation_id text PRIMARY KEY,
|
|
147
150
|
agent_id text,
|
|
@@ -159,6 +162,8 @@ const migrations = [
|
|
|
159
162
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
160
163
|
);
|
|
161
164
|
|
|
165
|
+
-- Analytics rationale: Phone-only data stays in a one-to-one child so text conversations
|
|
166
|
+
-- do not acquire call-specific columns or assumptions.
|
|
162
167
|
CREATE TABLE phone_calls (
|
|
163
168
|
conversation_id text PRIMARY KEY REFERENCES conversations(conversation_id)
|
|
164
169
|
ON DELETE CASCADE,
|
|
@@ -167,6 +172,8 @@ const migrations = [
|
|
|
167
172
|
ended_reason text
|
|
168
173
|
);
|
|
169
174
|
|
|
175
|
+
-- Analytics rationale: These indexes cover time-ordered listing and its common equality
|
|
176
|
+
-- filters; without them dashboards and Telescope lists degrade to full table scans.
|
|
170
177
|
CREATE INDEX conversations_started_at_idx
|
|
171
178
|
ON conversations (started_at DESC, conversation_id DESC);
|
|
172
179
|
CREATE INDEX conversations_agent_started_at_idx
|
|
@@ -175,6 +182,9 @@ const migrations = [
|
|
|
175
182
|
ON conversations (region, started_at DESC, conversation_id DESC);
|
|
176
183
|
CREATE INDEX conversations_channel_started_at_idx
|
|
177
184
|
ON conversations (channel, started_at DESC, conversation_id DESC);
|
|
185
|
+
|
|
186
|
+
-- Analytics rationale: GIN accelerates JSONB containment filters while the phone index
|
|
187
|
+
-- supports exact caller lookups joined through the conversation ID.
|
|
178
188
|
CREATE INDEX conversations_analytics_idx
|
|
179
189
|
ON conversations USING gin (analytics);
|
|
180
190
|
CREATE INDEX phone_calls_phone_number_idx
|
|
@@ -228,6 +238,7 @@ export async function createPostgresAnalyticsServices(config) {
|
|
|
228
238
|
return {
|
|
229
239
|
analytics: new PostgresAnalytics(pool),
|
|
230
240
|
conversations: new PostgresConversationRepository(pool),
|
|
241
|
+
queries: new PostgresAnalyticsQueryService(pool),
|
|
231
242
|
close: () => pool.end(),
|
|
232
243
|
};
|
|
233
244
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"timeouts-manager.d.ts","sourceRoot":"","sources":["../src/timeouts-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"timeouts-manager.d.ts","sourceRoot":"","sources":["../src/timeouts-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAI7C,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAKjD,MAAM,MAAM,aAAa,GAAG;IACxB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACnC,CAAA;AAKD,KAAK,kBAAkB,GAAG;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE,MAAM,CAAC,OAAO,CAAA;CACxB,CAAA;AAED,qBAAa,cAAc;IAOnB,QAAQ,CAAC,aAAa,EAAE,YAAY,CAAC,aAAa,CAAC;IACnD,QAAQ,CAAC,MAAM,EAAE,MAAM;IAEvB,OAAO,CAAC,SAAS;IANrB,QAAQ,CAAC,mBAAmB,kDAAwD;gBAGvE,aAAa,EAAE,YAAY,CAAC,aAAa,CAAC,EAC1C,MAAM,EAAE,MAAM,EAEf,SAAS,UAAQ;IAQ7B,gBAAgB,CAAC,MAAM,EAAE,MAAM;IAYzB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAWlE,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM;IA4B7D,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,MAAM;IAmEvF,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAYxD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@recombine-ai/platform",
|
|
3
|
-
"version": "0.2.6-test.
|
|
3
|
+
"version": "0.2.6-test.4",
|
|
4
4
|
"description": "Recombine AI: voice agent platform",
|
|
5
5
|
"license": "EULA",
|
|
6
6
|
"author": "recombine.ai",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@recombine-ai/engine": "1.0.0",
|
|
19
19
|
"@recombine-ai/telescope": "0.2.0",
|
|
20
|
+
"@types/pg": "8.15.5",
|
|
20
21
|
"pg": "8.16.3",
|
|
21
22
|
"redis": "5.11.0",
|
|
22
23
|
"zod": "^4.4.3"
|
|
@@ -32,7 +33,6 @@
|
|
|
32
33
|
"registry": "https://registry.npmjs.org"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
|
-
"@types/node": "^25.2.3"
|
|
36
|
-
"@types/pg": "8.15.5"
|
|
36
|
+
"@types/node": "^25.2.3"
|
|
37
37
|
}
|
|
38
38
|
}
|