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>
+10
View File
@@ -65,6 +65,16 @@ notes and the rest happen in Contrack.
Names refresh from Contrack every 5 minutes (at once after SelfPhoneNG adds a contact). If
Contrack is down, the last names it gave keep working.
### Calls on the timeline
Once connected, each person's calls with their Contrack contacts go on that contact's timeline in
Contrack ("Incoming call", "Missed call, voicemail left", …, with the time on the phone and whether
it was recorded), a minute or so after the call. In **My settings → Contacts** they can turn this
off, or add each recorded call's summary and action items (when call summaries are on in
**Settings → AI**); the first action item becomes a Contrack follow-up, due the next day. Calls
with numbers that aren't in their Contrack aren't added; **Add to contacts** first. Only calls
from the moment they connected are logged.
### Privacy
SelfPhoneNG only talks to Contrack on this server or the local network, and keeps the tokens
+13 -1
View File
@@ -77,7 +77,10 @@ create table users (
totp_last_step bigint, -- last 30 s step signed in with: no replay (M11)
must_change_password boolean not null default false,
last_login_at timestamptz,
contrack_token_enc text -- their Contrack personal token (11 §4.3)
contrack_token_enc text, -- their Contrack personal token (11 §4.3)
contrack_log_calls boolean not null default true, -- calls on their Contrack timelines (11 §4.4)
contrack_log_summaries boolean not null default false, -- …with recorded calls' summaries
contrack_log_since timestamptz -- calls that ended after this
);
create table sessions (
@@ -450,6 +453,15 @@ create table voicemail_messages ( -- index of files Asterisk
transcript text -- speech to text (10), when it's on
);
create table contrack_call_log ( -- a call put on a Contrack timeline (11 §4.4)
call_id uuid not null references calls(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
interaction_id text, -- null: no contact has the number, or refused
error text,
logged_at timestamptz not null default now(),
primary key (call_id, user_id)
);
create table contact_shares ( -- a contact offered to a colleague (11 §2.1)
id uuid primary key default gen_random_uuid(),
from_user_id uuid references users(id) on delete set null,
+1
View File
@@ -458,6 +458,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
| `diskWatch` | hourly | if free disk < 10% → email admins once a day |
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
Jobs must never overlap with themselves (use a running flag).
+21 -1
View File
@@ -245,6 +245,26 @@ New per-user options (My settings → Contacts, shown when connected):
- Unknown numbers are not added to Contrack automatically; "Add to contacts" does it, and calls
from then on are logged.
As built (C5, `contacts/timeline.ts`):
- Options on `users` (`contrack_log_calls`, `contrack_log_summaries`, `contrack_log_since`),
`PATCH /api/me/contrack { logCalls?, logSummaries? }`, shown in `GET /api/me` `contrack` with
`summariesAvailable`. Calls count from `contrack_log_since`: set when the token is first
connected, and when logging is turned back on, so connecting doesn't fill the timeline with the
last 2 days. Migration 0011 sets it to the migration time for people connected before.
- Calls between colleagues (`internal`) aren't logged. Every person on the call (caller, rang,
answered) gets it on their own timeline, as in My calls, with their own contact.
- Titles: "Incoming call", "Incoming call, answered by Sue", "Missed call", "Missed call, voicemail
left", "Outgoing call", "Outgoing call, busy" / "no answer". Content: `<p>From … to … · 3:12 ·
recorded</p>`, then the summary's sentences and an `<ul>` of its action items, all escaped.
`source: "selfphoneng"`. Contrack requires a due date on a follow-up: the day after the call,
in the company time zone.
- `contrack_call_log` also has `error`: a refused write (4xx other than 401/429: a read-only
token, a deleted contact, a bad request) is noted once and not retried. Contrack down (5xx,
unreachable), or a token it refuses (401): tried again on the next run.
- Proven against Contrack 2.0.2 (`contrack-live.test.ts`): the entry, its follow-up, and the
escaped content arrive. Contrack answers `duration` as a string ("4.0").
### 4.5 Not in Contrack mode
- Personal contacts are not on desk-phone phonebooks at first (company book only). Phase C6.
@@ -271,7 +291,7 @@ New per-user options (My settings → Contacts, shown when connected):
| C2 | Company book on desk phones (07) | a Yealink shows company contacts in its directory. Already built in M10-T2 (`loadPhonebook`, the builtin company book); C3 adds the Contrack company account to it |
| C3 | Contrack: decision 0003, `deploy/contrack`, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step | with the owner's Contrack, a call from a Contrack contact shows their name |
| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book. Done, see §6.1 |
| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary |
| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary. Done, see §4.4 as built |
| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional |
### 6.1 As built: C4
+17 -1
View File
@@ -56,5 +56,21 @@ export type MeDto = {
callReportWeekday: number;
callReportSummaries: boolean;
/** Contacts in Contrack mode (11 §4.3): my token, never shown again; null in builtin mode. */
contrack: { connected: boolean; tokenPrefix: string | null; publicUrl: string | null } | null;
contrack: {
connected: boolean;
tokenPrefix: string | null;
publicUrl: string | null;
/** 11 §4.4: my calls on my contacts' Contrack timelines, and with their summaries. */
logCalls: boolean;
logSummaries: boolean;
/** Call summaries are on (Settings → AI), so logSummaries can do something. */
summariesAvailable: boolean;
} | null;
};
/** PATCH /api/me/contrack. */
export const ContrackOptionsSchema = z.object({
logCalls: z.boolean().optional(),
logSummaries: z.boolean().optional(),
});
export type ContrackOptions = z.input<typeof ContrackOptionsSchema>;