contacts: calls on the Contrack timeline, summaries opt-in (C5)
Spec 11 §4.4: the contrackLog job puts each connected person's calls with their Contrack contacts on that contact's timeline once (contrack_call_log, migration 0011), from when they connected. With summaries on (My settings → Contacts, needs call summaries), the escaped summary and action items go in, and the first action item becomes a follow-up due the next day. Waits up to 30 min for a summary; Contrack down is retried, refusals noted once. Tested against a fake and a real Contrack 2.0.2.
This commit is contained in:
1 parent
b05361e840
commit
e6a1373f36
19 files changed
+5096
-13
No files matched your search
@@ -8,7 +8,11 @@
|
||||
// CONTRACK_LIVE_URL=http://127.0.0.1:3210 pnpm vitest run apps/api/test/contrack-live.test.ts
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { logCallsToContrack } from '../src/contacts/timeline.ts';
|
||||
import { callExtensions, calls, extensions, recordings, users } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { loadPhonebook } from '../src/provisioning/context.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
@@ -235,6 +239,74 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
|
||||
).toContain('Bob Smith');
|
||||
});
|
||||
|
||||
it('a call with Jane lands on her Contrack timeline, with the summary and a follow-up (C5)', async () => {
|
||||
const at = (minAgo: number) => new Date(Date.now() - minAgo * 60_000);
|
||||
await t.db
|
||||
.update(users)
|
||||
.set({ contrackLogSince: at(60) })
|
||||
.where(eq(users.id, danId));
|
||||
await setSetting(t.db, 'ai.callTranscripts', true);
|
||||
await setSetting(t.db, 'ai.callSummaries', true);
|
||||
expect((await dan('PATCH', '/api/me/contrack', { logSummaries: true })).statusCode).toBe(200);
|
||||
const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'live-r1',
|
||||
filePath: 'live-r1.wav',
|
||||
startedAt: at(20),
|
||||
summary:
|
||||
'Jane asked for a <quote> & delivery dates.\nAction items:\n- Dan sends the quote by Friday',
|
||||
aiDoneAt: at(15),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db
|
||||
.insert(calls)
|
||||
.values({
|
||||
linkedid: 'live-c1',
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: at(20),
|
||||
endedAt: at(17),
|
||||
fromNumber: '13125551234',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 185,
|
||||
answeredByExtensionId: ext!.id,
|
||||
recordingId: rec!.id,
|
||||
})
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: c!.id, extensionId: ext!.id, role: 'answered' });
|
||||
expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(1);
|
||||
expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(0);
|
||||
|
||||
const jane = (
|
||||
(await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
|
||||
id: string;
|
||||
name: string;
|
||||
}[]
|
||||
).find((x) => x.name === 'Jane Doe')!;
|
||||
const timeline = (await ctk(`/api/contacts/${jane.id}/timeline`, { token: tok.dan }))
|
||||
.body as unknown as Record<string, unknown>[];
|
||||
expect(timeline).toHaveLength(1);
|
||||
expect(timeline[0]).toMatchObject({
|
||||
type: 'call',
|
||||
title: 'Incoming call',
|
||||
date: c!.startedAt.toISOString(),
|
||||
duration: expect.anything(), // Contrack answers it as "4.0"
|
||||
actionItems: [
|
||||
expect.objectContaining({ title: 'Dan sends the quote by Friday', completedAt: null }),
|
||||
],
|
||||
});
|
||||
expect(Number(timeline[0]!.duration)).toBe(4);
|
||||
const content = String(timeline[0]!.content);
|
||||
expect(content).toContain('From (312) 555-1234 to (312) 555-0000 · 3:05 · recorded');
|
||||
expect(content).toContain('Jane asked for a <quote> & delivery dates.');
|
||||
expect(content).toContain('<li>Dan sends the quote by Friday</li>');
|
||||
expect(content).not.toContain('<quote>');
|
||||
});
|
||||
|
||||
it('a share is accepted into the recipient’s own Contrack account', async () => {
|
||||
const jane = (await dan('GET', '/api/me/contacts'))
|
||||
.json()
|
||||
|
||||
@@ -4,7 +4,19 @@ import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { forgetBook } from '../src/contacts/contrack.ts';
|
||||
import { logCallsToContrack } from '../src/contacts/timeline.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contrackCallLog,
|
||||
extensions,
|
||||
recordings,
|
||||
users,
|
||||
} from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { loadPhonebook } from '../src/provisioning/context.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
@@ -42,14 +54,34 @@ function fakeContrack() {
|
||||
ctk_sue_0001: [],
|
||||
};
|
||||
let n = 0;
|
||||
/** Timeline entries by contact id; `down` makes every API call fail with 503. */
|
||||
const timeline: Record<string, Record<string, unknown>[]> = {};
|
||||
const state = { down: false, readOnly: new Set<string>() };
|
||||
const server: Server = createServer((req, res) => {
|
||||
const send = (status: number, body: unknown) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (req.url === '/healthz') return send(200, { ok: true });
|
||||
const book = books[(req.headers.authorization ?? '').replace(/^Bearer /, '')];
|
||||
if (state.down) return send(503, { error: { code: 'DB_BUSY', message: 'Busy' } });
|
||||
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
|
||||
const book = books[token];
|
||||
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
|
||||
const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
|
||||
if (req.method === 'POST' && into) {
|
||||
if (state.readOnly.has(token))
|
||||
return send(403, { error: { code: 'TOKEN_READ_ONLY', message: 'Read-only token' } });
|
||||
if (!book.some((c) => c.id === into[1]))
|
||||
return send(404, { error: { code: 'NOT_FOUND', message: 'Contact not found' } });
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const id = `int-${++n}`;
|
||||
(timeline[into[1]!] ??= []).push({ id, ...(JSON.parse(raw) as object) });
|
||||
send(201, { id });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book);
|
||||
if (req.method === 'POST' && req.url === '/api/contacts') {
|
||||
let raw = '';
|
||||
@@ -75,7 +107,7 @@ function fakeContrack() {
|
||||
}
|
||||
send(404, { error: { code: 'ROUTE_NOT_FOUND', message: 'No route' } });
|
||||
});
|
||||
return { server, books };
|
||||
return { server, books, timeline, state };
|
||||
}
|
||||
|
||||
describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
@@ -165,6 +197,9 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
connected: false,
|
||||
tokenPrefix: null,
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
logCalls: true,
|
||||
logSummaries: false,
|
||||
summariesAvailable: false,
|
||||
});
|
||||
const before = (await dan('GET', '/api/me/contacts')).json();
|
||||
expect(before.provider).toBe('contrack');
|
||||
@@ -265,6 +300,142 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
it("calls go on the contact's timeline once, the summary when asked for (C5)", async () => {
|
||||
const now = Date.now();
|
||||
const at = (minAgo: number) => new Date(now - minAgo * 60_000);
|
||||
// Dan connected a moment ago: his calls from an hour back count for this test.
|
||||
await t.db
|
||||
.update(users)
|
||||
.set({ contrackLogSince: at(60) })
|
||||
.where(eq(users.id, danId));
|
||||
await setSetting(t.db, 'ai.callTranscripts', true);
|
||||
await setSetting(t.db, 'ai.callSummaries', true);
|
||||
const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
|
||||
const [done, pending] = await t.db
|
||||
.insert(recordings)
|
||||
.values([
|
||||
{
|
||||
linkedid: 'r1',
|
||||
filePath: 'r1.wav',
|
||||
startedAt: at(30),
|
||||
summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote',
|
||||
aiDoneAt: at(25),
|
||||
},
|
||||
{ linkedid: 'r2', filePath: 'r2.wav', startedAt: at(10) },
|
||||
])
|
||||
.returning();
|
||||
const call = (linkedid: string, minAgo: number, v: Partial<typeof calls.$inferInsert>) => ({
|
||||
linkedid,
|
||||
direction: 'inbound' as const,
|
||||
status: 'answered' as const,
|
||||
startedAt: at(minAgo + 3),
|
||||
endedAt: at(minAgo),
|
||||
fromNumber: '13125551234', // Jane Doe
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 125,
|
||||
...v,
|
||||
});
|
||||
const rows = await t.db
|
||||
.insert(calls)
|
||||
.values([
|
||||
call('k1', 30, { recordingId: done!.id }), // answered, recorded, summarized
|
||||
call('k2', 20, { status: 'missed', talkSeconds: 0 }),
|
||||
call('k3', 15, { direction: 'outbound', fromNumber: '101', toNumber: '13125551234' }),
|
||||
call('k4', 12, { fromNumber: '13125550101' }), // not a contact
|
||||
call('k5', 10, { recordingId: pending!.id }), // not summarized yet
|
||||
call('k6', 90, {}), // before Dan connected
|
||||
call('k7', 0.2, {}), // still being written (under a minute ago)
|
||||
])
|
||||
.returning();
|
||||
const role = (i: number) => (rows[i]!.direction === 'outbound' ? 'caller' : 'answered');
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values(rows.map((r, i) => ({ callId: r.id, extensionId: ext!.id, role: role(i) })));
|
||||
const run = (min = 0) =>
|
||||
logCallsToContrack({ db: t.db, log: t.app.log, now: new Date(now + min * 60_000) });
|
||||
|
||||
// Summaries off (the default): no summary, and nothing waits for one.
|
||||
expect(await run()).toBe(4); // k1, k2, k3, k5 (k4 a stranger, k6 too early, k7 too fresh)
|
||||
const jane = fake.timeline['jane-doe-00000001']!;
|
||||
expect(jane.map((e) => e.title)).toEqual([
|
||||
'Incoming call',
|
||||
'Missed call',
|
||||
'Outgoing call',
|
||||
'Incoming call',
|
||||
]);
|
||||
expect(jane[0]).toMatchObject({
|
||||
type: 'call',
|
||||
source: 'selfphoneng',
|
||||
date: rows[0]!.startedAt.toISOString(),
|
||||
duration: 3,
|
||||
content: '<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p>',
|
||||
});
|
||||
expect(jane[0]).not.toHaveProperty('actionItem');
|
||||
expect(await run()).toBe(0); // once
|
||||
|
||||
// With summaries: the next recorded call waits for its summary, then carries it.
|
||||
await dan('PATCH', '/api/me/contrack', { logSummaries: true });
|
||||
const [k8] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k8', 5, { recordingId: done!.id }))
|
||||
.returning();
|
||||
const [k9] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k9', 4, { recordingId: pending!.id }))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values([k8!, k9!].map((r) => ({ callId: r.id, extensionId: ext!.id, role: 'answered' })));
|
||||
expect(await run()).toBe(1); // k8; k9 waits
|
||||
expect(jane.at(-1)).toMatchObject({
|
||||
content:
|
||||
'<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p><p>Acme wants a quote.</p><p>Action items:</p><ul><li>Dan sends the quote</li></ul>',
|
||||
actionItem: {
|
||||
title: 'Dan sends the quote',
|
||||
dueAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
},
|
||||
});
|
||||
// 40 minutes on: k9 without a summary (it waited 30), and k7 is old enough now.
|
||||
expect(await run(40)).toBe(2);
|
||||
expect(jane.find((e) => e.date === k9!.startedAt.toISOString())!.content).toBe(
|
||||
'<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p>',
|
||||
);
|
||||
expect(jane).toHaveLength(7);
|
||||
|
||||
// Contrack down: nothing is lost, the call goes in on a later run.
|
||||
const [k10] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k10', 3, {}))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: k10!.id, extensionId: ext!.id, role: 'answered' });
|
||||
forgetBook(danId);
|
||||
fake.state.down = true;
|
||||
expect(await run()).toBe(0);
|
||||
fake.state.down = false;
|
||||
forgetBook(danId);
|
||||
expect(await run()).toBe(1);
|
||||
// A read-only token: noted once, not retried every minute.
|
||||
fake.state.readOnly.add('ctk_dan_0001');
|
||||
const [k11] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k11', 2, {}))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: k11!.id, extensionId: ext!.id, role: 'answered' });
|
||||
expect(await run()).toBe(0);
|
||||
const [noted] = await t.db
|
||||
.select()
|
||||
.from(contrackCallLog)
|
||||
.where(eq(contrackCallLog.callId, k11!.id));
|
||||
expect(noted).toMatchObject({ interactionId: null, error: 'Contrack: Read-only token' });
|
||||
fake.state.readOnly.clear();
|
||||
expect(await run()).toBe(0);
|
||||
expect(jane).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('back to builtin: the table again, tokens kept for later', async () => {
|
||||
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
|
||||
const book = (await dan('GET', '/api/me/contacts')).json();
|
||||
|
||||
@@ -24,6 +24,9 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
must_change_password: 'boolean|NO|false',
|
||||
last_login_at: 'timestamp with time zone|YES|',
|
||||
contrack_token_enc: 'text|YES|',
|
||||
contrack_log_calls: 'boolean|NO|true',
|
||||
contrack_log_summaries: 'boolean|NO|false',
|
||||
contrack_log_since: 'timestamp with time zone|YES|',
|
||||
...ts,
|
||||
},
|
||||
extensions: {
|
||||
@@ -112,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 42 tables', async () => {
|
||||
it('creates all 43 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 42 });
|
||||
expect(r.rows[0]).toEqual({ n: 43 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
Reference in new issue
Block a user