contacts: calls on the Contrack timeline, summaries opt-in (C5)
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 20s

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:
iamdoubz committed 2026-10-23 15:59:53 -05:00
1 parent b05361e840
commit e6a1373f36
19 files changed
+5096 -13

No files matched your search

+72
View File
@@ -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 &lt;quote&gt; &amp; 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()
+173 -2
View File
@@ -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();
+5 -2
View File
@@ -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) => {