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

+16
View File
@@ -204,6 +204,22 @@ export async function contrackCreate(
return toDto(c, row, owner === null ? 'company' : 'mine');
}
/** A timeline entry on a contact (11 §4.4); answers its id. */
export async function contrackInteraction(
c: Contrack,
token: string,
contactId: string,
body: Record<string, unknown>,
): Promise<string> {
const r = await ctk<{ id: string }>(
c.url,
token,
`api/contacts/${encodeURIComponent(contactId)}/interactions`,
{ method: 'POST', body: JSON.stringify(body) },
);
return r.id;
}
/** Settings → Contacts → Test, and connecting a token: reachable, and the token reads contacts. */
export async function contrackCheck(url: string, token: string | null): Promise<string> {
try {
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C5: what a call looks like on a Contrack timeline (11 §4.4).
import { describe, expect, it } from 'vitest';
import { splitSummary, timelineEntry, type TimelineCall } from './timeline.ts';
const call = (c: Partial<TimelineCall>): TimelineCall => ({
out: false,
status: 'answered',
fromNumber: '13125551234',
toNumber: '13125550000',
talkSeconds: 192,
answeredBy: null,
recorded: false,
...c,
});
describe('timelineEntry', () => {
it('titles calls from where the person sat', () => {
const t = (c: Partial<TimelineCall>) => timelineEntry(call(c), null).title;
expect(t({})).toBe('Incoming call');
expect(t({ answeredBy: 'Sue Smith' })).toBe('Incoming call, answered by Sue Smith');
expect(t({ status: 'missed', talkSeconds: 0 })).toBe('Missed call');
expect(t({ status: 'voicemail', talkSeconds: 0 })).toBe('Missed call, voicemail left');
expect(t({ out: true })).toBe('Outgoing call');
expect(t({ out: true, status: 'busy', talkSeconds: 0 })).toBe('Outgoing call, busy');
expect(t({ out: true, status: 'failed', talkSeconds: 0 })).toBe('Outgoing call, no answer');
});
it('writes the facts, the summary and its action items as escaped HTML', () => {
const e = timelineEntry(
call({ recorded: true }),
'Acme <wants> a quote & a call back.\nAction items:\n- Dan sends the quote\n- Sue books a visit',
);
expect(e).toEqual({
title: 'Incoming call',
content:
'<p>From (312) 555-1234 to (312) 555-0000 · 3:12 · recorded</p>' +
'<p>Acme &lt;wants&gt; a quote &amp; a call back.</p>' +
'<p>Action items:</p><ul><li>Dan sends the quote</li><li>Sue books a visit</li></ul>',
duration: 4,
firstItem: 'Dan sends the quote',
});
const missed = timelineEntry(call({ status: 'missed', talkSeconds: 0 }), null);
expect([missed.content, missed.duration, missed.firstItem]).toEqual([
'<p>From (312) 555-1234 to (312) 555-0000</p>',
null,
null,
]);
expect(timelineEntry(call({ out: true }), null).content).toBe(
'<p>To (312) 555-0000 · 3:12</p>',
);
});
it('reads "Action items: none" as none', () => {
expect(splitSummary('Wrong number.\nAction items: none')).toEqual({
text: ['Wrong number.'],
items: [],
});
expect(splitSummary('Just chatted.')).toEqual({ text: ['Just chatted.'], items: [] });
expect(splitSummary('X.\nAction items:\n- none')).toEqual({ text: ['X.'], items: [] });
});
});
+218
View File
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Calls on the Contrack timeline (11 §4.4): each call of a connected person with one of their
// Contrack contacts becomes a "call" interaction on that contact, with the recorded call's
// summary if they asked for it. Unknown numbers are not added to Contrack.
import { and, asc, eq, gt, isNotNull, lt, ne, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { formatNumber } from '@selfphoneng/shared';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import {
callExtensions,
calls,
contrackCallLog,
extensions,
recordings,
users,
} from '../db/schema.ts';
import { todayIn } from '../generator/load.ts';
import { getSetting } from '../services/settings.ts';
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
/** A recorded call waits this long for its summary before it's logged without one. */
const SUMMARY_WAIT_MS = 30 * 60_000;
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'an unknown number');
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
export type TimelineCall = {
out: boolean;
status: string;
fromNumber: string | null;
toNumber: string | null;
talkSeconds: number;
/** Someone else answered (a ring group or queue colleague). */
answeredBy: string | null;
recorded: boolean;
};
/** "Action items:" lines of a summary (10 §3), and the text before them. */
export function splitSummary(summary: string): { text: string[]; items: string[] } {
const lines = summary
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
const at = lines.findIndex((l) => /^action items:/i.test(l));
if (at < 0) return { text: lines, items: [] };
return {
text: lines.slice(0, at),
items: lines
.slice(at + 1)
.filter((l) => /^[-*•]\s+/.test(l))
.map((l) => l.replace(/^[-*•]\s+/, ''))
.filter((l) => !/^none\.?$/i.test(l)),
};
}
/** The interaction for one call: title, HTML content, minutes, the first action item. */
export function timelineEntry(c: TimelineCall, summary: string | null) {
const missed = ['missed', 'voicemail', 'abandoned'].includes(c.status);
const title = c.out
? c.status === 'answered'
? 'Outgoing call'
: `Outgoing call, ${c.status === 'busy' ? 'busy' : 'no answer'}`
: c.status === 'voicemail'
? 'Missed call, voicemail left'
: missed || c.status !== 'answered'
? 'Missed call'
: c.answeredBy
? `Incoming call, answered by ${c.answeredBy}`
: 'Incoming call';
const facts = [
c.out ? `To ${num(c.toNumber)}` : `From ${num(c.fromNumber)} to ${num(c.toNumber)}`,
...(c.talkSeconds ? [mmss(c.talkSeconds)] : []),
...(c.recorded ? ['recorded'] : []),
];
const parts = [`<p>${esc(facts.join(' · '))}</p>`];
const s = summary ? splitSummary(summary) : null;
if (s?.text.length) parts.push(...s.text.map((l) => `<p>${esc(l)}</p>`));
if (s?.items.length)
parts.push(`<p>Action items:</p><ul>${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>`);
return {
title,
content: parts.join(''),
duration: c.talkSeconds ? Math.ceil(c.talkSeconds / 60) : null,
firstItem: s?.items[0] ?? null,
};
}
const answerer = alias(extensions, 'answerer');
/**
* One run of the contrackLog job: calls that ended over a minute ago (and within 2 days), of
* people who connected Contrack and log calls, not looked at yet. Contrack down: tried again on
* the next run.
*/
export async function logCallsToContrack(d: {
db: Db;
log: { error(o: object, m: string): void };
now?: Date;
}): Promise<number> {
const c = await contrackSettings(d.db);
if (!c) return 0;
const now = d.now ?? new Date();
const tz = await getSetting(d.db, 'general.timezone');
const summariesOn =
(await getSetting(d.db, 'ai.callTranscripts')) && (await getSetting(d.db, 'ai.callSummaries'));
const rows = await d.db
.select({
call: calls,
role: callExtensions.role,
userId: users.id,
withSummary: users.contrackLogSummaries,
tokenEnc: users.contrackTokenEnc,
extId: extensions.id,
answeredById: answerer.id,
answeredBy: answerer.name,
summary: recordings.summary,
aiDoneAt: recordings.aiDoneAt,
})
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.innerJoin(users, eq(users.id, extensions.userId))
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
.where(
and(
isNotNull(users.contrackTokenEnc),
eq(users.contrackLogCalls, true),
eq(users.isActive, true),
ne(calls.direction, 'internal'),
gt(calls.endedAt, users.contrackLogSince),
lt(calls.endedAt, new Date(now.getTime() - 60_000)),
gt(calls.endedAt, new Date(now.getTime() - 2 * 24 * 3600_000)),
sql`not exists (select 1 from ${contrackCallLog} l where l.call_id = ${calls.id} and l.user_id = ${users.id})`,
),
)
.orderBy(asc(calls.endedAt))
.limit(500);
// One entry per call and person (a person can be in a call as caller, rang and answered).
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
for (const r of rows) {
const k = `${r.call.id}|${r.userId}`;
const out = r.role === 'caller' || todo.get(k)?.out === true;
todo.set(k, { ...r, out });
}
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
let logged = 0;
for (const r of todo.values()) {
if (skipUser.has(r.userId)) continue;
const record = (interactionId: string | null, error: string | null = null) =>
d.db
.insert(contrackCallLog)
.values({ callId: r.call.id, userId: r.userId, interactionId, error })
.onConflictDoNothing();
const book = await contrackBook(d.db, c, r.userId);
if (!book || book.error) {
skipUser.add(r.userId);
continue;
}
const other = r.out ? r.call.toNumber : r.call.fromNumber;
const contact = other
? book.contacts.find((x) => x.numbers.some((n) => n.number === other))
: undefined;
if (!contact) {
await record(null);
continue;
}
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
continue; // the transcript job hasn't summarized it yet
const e = timelineEntry(
{
out: r.out,
status: r.call.status,
fromNumber: r.call.fromNumber,
toNumber: r.call.toNumber,
talkSeconds: r.call.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
recorded: r.call.recordingId !== null,
},
wantSummary ? r.summary : null,
);
try {
const id = await contrackInteraction(c, decrypt(r.tokenEnc!), contact.id, {
type: 'call',
title: e.title,
content: e.content,
date: r.call.startedAt.toISOString(),
duration: e.duration,
source: 'selfphoneng',
...(e.firstItem
? {
actionItem: {
title: e.firstItem.slice(0, 200),
dueAt: todayIn(tz, new Date(r.call.startedAt.getTime() + 24 * 3600_000)),
},
}
: {}),
});
await record(id);
logged++;
} catch (err) {
const status = err instanceof ContrackError ? err.status : undefined;
// Refused (bad request, a read-only token, a deleted contact): noted, not retried.
if (status && status >= 400 && status < 500 && status !== 401 && status !== 429)
await record(null, (err as Error).message);
else {
skipUser.add(r.userId);
d.log.error({ err, user: r.userId }, 'contrack: call not logged yet');
}
}
}
return logged;
}
@@ -0,0 +1,16 @@
CREATE TABLE "contrack_call_log" (
"call_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"interaction_id" text,
"error" text,
"logged_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "contrack_call_log_call_id_user_id_pk" PRIMARY KEY("call_id","user_id")
);
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "contrack_log_calls" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "contrack_log_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "contrack_log_since" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-- People already connected (C3) start logging now, not with the last 2 days of calls.
UPDATE "users" SET "contrack_log_since" = now() WHERE "contrack_token_enc" IS NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -78,6 +78,13 @@
"when": 1792785318384,
"tag": "0010_contrack_token",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1792788393381,
"tag": "0011_contrack_call_log",
"breakpoints": true
}
]
}
+23
View File
@@ -65,6 +65,11 @@ export const users = pgTable('users', {
lastLoginAt: tstz(),
/** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */
contrackTokenEnc: text(),
/** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */
contrackLogCalls: boolean().notNull().default(true),
/** …with each recorded call's summary (10 §3). */
contrackLogSummaries: boolean().notNull().default(false),
contrackLogSince: tstz(),
...timestamps,
});
@@ -622,6 +627,24 @@ export const contacts = pgTable('contacts', {
...timestamps,
});
/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */
export const contrackCallLog = pgTable(
'contrack_call_log',
{
callId: uuid()
.notNull()
.references(() => calls.id, { onDelete: 'cascade' }),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */
interactionId: text(),
error: text(),
loggedAt: tstz().notNull().defaultNow(),
},
(t) => [primaryKey({ columns: [t.callId, t.userId] })],
);
/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
export const contactShares = pgTable(
'contact_shares',
+16 -1
View File
@@ -26,6 +26,7 @@ import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logCallsToContrack } from './contacts/timeline.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
@@ -256,7 +257,20 @@ export function createJobs(d: JobDeps) {
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
};
return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports, contactsWarm };
const contrackLog = async () => {
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
};
return {
daily,
backup,
publicIpWatch,
trunkWatch,
diskWatch,
callReports,
contactsWarm,
contrackLog,
};
}
/** Starts the jobs; returns a stop function. */
@@ -280,6 +294,7 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
+34 -2
View File
@@ -3,10 +3,11 @@
import { existsSync } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
ContrackOptionsSchema,
GREETINGS,
MeUpdateSchema,
type ListResponse,
@@ -133,10 +134,18 @@ export async function meRoutes(app: FastifyInstance) {
if (!c) return null;
const token = await tokenFor(db, c, userId);
const book = token ? await contrackBook(db, c, userId) : null;
const [u] = await db
.select({ logCalls: users.contrackLogCalls, logSummaries: users.contrackLogSummaries })
.from(users)
.where(eq(users.id, userId));
return {
connected: !!book && !book.error,
tokenPrefix: token ? `${token.slice(0, 12)}…` : null,
publicUrl: c.publicUrl,
logCalls: u?.logCalls ?? true,
logSummaries: u?.logSummaries ?? false,
summariesAvailable:
(await getSetting(db, 'ai.callTranscripts')) && (await getSetting(db, 'ai.callSummaries')),
};
}
@@ -148,9 +157,13 @@ export async function meRoutes(app: FastifyInstance) {
await contrackCheck(c.url, token).catch((err: Error) => {
throw badRequest(err.message);
});
// Calls are logged from now on, not the ones before (11 §4.4).
await db
.update(users)
.set({ contrackTokenEnc: encrypt(token) })
.set({
contrackTokenEnc: encrypt(token),
contrackLogSince: sql`coalesce(${users.contrackLogSince}, now())`,
})
.where(eq(users.id, req.user!.id));
forgetBook(req.user!.id);
await audit(db, {
@@ -164,6 +177,25 @@ export async function meRoutes(app: FastifyInstance) {
return myState(req.user!.id);
});
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
app.patch('/api/me/contrack', async (req) => {
const o = ContrackOptionsSchema.parse(req.body);
const [u] = await db
.select({ logCalls: users.contrackLogCalls })
.from(users)
.where(eq(users.id, req.user!.id));
await db
.update(users)
.set({
...(o.logCalls !== undefined ? { contrackLogCalls: o.logCalls } : {}),
...(o.logSummaries !== undefined ? { contrackLogSummaries: o.logSummaries } : {}),
// Turned back on: from now, not the calls while it was off.
...(o.logCalls && !u?.logCalls ? { contrackLogSince: new Date() } : {}),
})
.where(eq(users.id, req.user!.id));
return myState(req.user!.id);
});
app.delete('/api/me/contrack', async (req) => {
await db.update(users).set({ contrackTokenEnc: null }).where(eq(users.id, req.user!.id));
forgetBook(req.user!.id);
+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) => {
+2
View File
@@ -23,6 +23,7 @@ import type {
CallDto,
CallFilter,
ContactBookDto,
ContrackOptions,
ContactDto,
ContactImportResult,
ContactInput,
@@ -273,6 +274,7 @@ export const meApi = {
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
connectContrack: (token: string) => api<MeDto>('PUT', '/api/me/contrack', { token }),
disconnectContrack: () => api<MeDto>('DELETE', '/api/me/contrack'),
contrackOptions: (o: ContrackOptions) => api<MeDto>('PATCH', '/api/me/contrack', o),
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
setQueuePaused: (queueId: string, paused: boolean) =>
+37 -3
View File
@@ -315,6 +315,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
};
const connect = useMutation({ mutationFn: () => meApi.connectContrack(token), onSuccess: done });
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done });
const where = contrack.publicUrl ? (
<a
className="text-sky-700 underline dark:text-sky-400"
@@ -332,7 +333,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
<h2 id="ctk-h" className="text-lg font-semibold">
Contacts
</h2>
{contrack.tokenPrefix ? (
{contrack.tokenPrefix && (
<div className="flex flex-wrap items-center gap-3 text-sm">
<span>
{contrack.connected ? 'Connected to your' : 'Your token for'} {where} account (
@@ -345,7 +346,40 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
Disconnect
</Button>
</div>
) : (
)}
{contrack.tokenPrefix && (
<div className="flex flex-col gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={contrack.logCalls}
disabled={options.isPending}
onChange={(e) => options.mutate({ logCalls: e.target.checked })}
/>
Put my calls on my contacts' timelines in Contrack
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1"
checked={contrack.logSummaries && contrack.summariesAvailable}
disabled={!contrack.logCalls || !contrack.summariesAvailable || options.isPending}
onChange={(e) => options.mutate({ logSummaries: e.target.checked })}
/>
<span>
With the summary and action items of each recorded call
{!contrack.summariesAvailable && (
<span className="text-zinc-500"> (when your admin turns on call summaries)</span>
)}
<span className="block text-xs text-zinc-500">
What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
reach that provider.
</span>
</span>
</label>
</div>
)}
{!contrack.tokenPrefix && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Your contacts live in your own {where} account. There, open Settings → Account → API
tokens → Create token, with Read and write (so contacts shared with you can be added) and
@@ -376,7 +410,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
Connect
</Button>
</form>
{(connect.error ?? disconnect.error) && (
{(connect.error ?? disconnect.error ?? options.error) && (
<Alert>{(connect.error ?? disconnect.error)!.message}</Alert>
)}
</section>