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
@@ -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 {
|
||||
|
||||
@@ -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 <wants> a quote & 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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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