contacts: owners' contacts on their desk phones, copy builtin contacts to Contrack (C6)
ci / check (push) Successful in 1m2s
ci / images (push) Successful in 20s
ci / check (pull_request) Successful in 1m4s
ci / images (pull_request) Successful in 5s

Spec 11 §6.2: a phone's directory is the extensions, its owner's own contacts (accepted shares
included), then the company's, in both modes. In Contrack mode, contacts from before Contrack
are offered for copying (contrack.toCopy, POST …/copy-to-contrack: one Contrack bulk import,
skipping what's already there; the builtin rows stay). Tested against a fake and a real
Contrack 2.0.2.
This commit is contained in:
iamdoubz committed 2026-10-23 16:07:19 -05:00
1 parent e6a1373f36
commit 604e40adf1
16 files changed
+294 -15

No files matched your search

+30
View File
@@ -6,6 +6,7 @@ import { asc, eq, isNull, or, sql } from 'drizzle-orm';
import {
normalizeNumber,
type ContactBookDto,
type ContactCopyResult,
type ContactData,
type ContactDto,
type ContactImportResult,
@@ -17,6 +18,7 @@ import {
ContrackError,
contrackBook,
contrackCreate,
contrackBulk,
contrackSettings,
needToken,
type Contrack,
@@ -101,10 +103,38 @@ export async function readBooks(db: Db, userId: string | null): Promise<ContactB
publicUrl: c.publicUrl,
connected: !!own.book && !own.book.error,
error: own.book?.error ?? null,
toCopy:
own.book && !own.book.error
? (await notInContrack(db, userId, own.book.contacts)).length
: 0,
},
};
}
/** This book's builtin contacts that its Contrack account doesn't have yet. */
async function notInContrack(db: Db, owner: Owner, have: ContactDto[]): Promise<ContactData[]> {
const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
return rows.filter((r) => !have.some((h) => isDuplicate(h, r)));
}
/**
* "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet,
* in one Contrack import. The builtin ones stay, for switching back.
*/
export async function copyToContrack(db: Db, owner: Owner): Promise<ContactCopyResult> {
const c = await contrackSettings(db);
if (!c)
throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)');
const b = await contrackBook(db, c, owner);
if (!b) asHttp(needToken(owner));
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
const all = (await db.select().from(contacts).where(inBook(owner))).length;
const fresh = await notInContrack(db, owner, b!.contacts);
if (!fresh.length) return { added: 0, duplicates: all, failed: 0 };
const r = await contrackBulk(db, c, owner, fresh).catch(asHttp);
return { added: r.count, duplicates: all - fresh.length, failed: r.failed };
}
/** One contact added by hand (or from a call): a row, or a Contrack contact. */
export async function createContact(db: Db, owner: Owner, d: ContactData): Promise<ContactDto> {
const clean = { ...d, numbers: cleanNumbers(d) };
+27
View File
@@ -2,6 +2,7 @@
// Contrack (11 §4): a self-hosted contact manager beside the PBX. We call a handful of its REST
// routes with a person's (or the company account's) personal token, and keep each book in memory
// for 5 minutes, so name lookups don't wait on it.
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
import { AiError, assertLocalUrl } from '../ai/client.ts';
@@ -36,6 +37,7 @@ async function ctk<T>(
r = await fetch(url, {
...init,
headers: {
...(init.headers as Record<string, string> | undefined),
Authorization: `Bearer ${token}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
},
@@ -204,6 +206,31 @@ export async function contrackCreate(
return toDto(c, row, owner === null ? 'company' : 'mine');
}
/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */
export async function contrackBulk(
db: Db,
c: Contrack,
owner: string | null,
list: ContactData[],
): Promise<{ count: number; failed: number }> {
const token = await tokenFor(db, c, owner);
if (!token) throw needToken(owner);
const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', {
method: 'POST',
headers: { 'X-Import-Id': randomUUID() },
body: JSON.stringify(
list.slice(0, 5000).map((d) => ({
name: d.name,
...(d.company ? { company: d.company } : {}),
...(d.email ? { emails: [{ email: d.email }] } : {}),
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
})),
),
});
forgetBook(owner);
return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 };
}
/** A timeline entry on a contact (11 §4.4); answers its id. */
export async function contrackInteraction(
c: Contrack,
+10 -4
View File
@@ -27,6 +27,8 @@ export type ProvisionContext = {
provPassword: string;
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
phonebookUrl: string;
/** Whose phone: their own contacts go in its directory (11 §6 C6). */
ownerUserId: string | null;
ntpServer: string;
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
dialRules: DialRule[];
@@ -80,6 +82,7 @@ export async function loadContext(
provUser: d.mac,
provPassword,
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
ownerUserId: e.userId,
ntpServer: NTP_SERVER,
dialRules: await loadDialRules(db),
},
@@ -88,14 +91,17 @@ export async function loadContext(
export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] };
/** The company phonebook (07 §2): every extension, then the company contacts, by name (from
* Contrack's company account in Contrack mode, 11 §4.3). */
export async function loadPhonebook(db: Db): Promise<PhonebookEntry[]> {
/** A phone's directory (07 §2): every extension, then its owner's own contacts (11 §6 C6), then
* the company's, each by name. Contrack's books in Contrack mode (11 §4.3). */
export async function loadPhonebook(
db: Db,
ownerUserId: string | null = null,
): Promise<PhonebookEntry[]> {
const exts = await db
.select({ name: extensions.name, number: extensions.number })
.from(extensions)
.orderBy(asc(extensions.name));
const company = (await readBooks(db, null)).items;
const company = (await readBooks(db, ownerUserId)).items; // mine first, then the company's
return [
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
...company.map((c) => ({
@@ -31,6 +31,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
provPassword: 'Pr0vPassw0rdXyZ12345',
phonebookUrl:
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
ownerUserId: null,
ntpServer: 'pool.ntp.org',
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
...over,
+25
View File
@@ -9,6 +9,7 @@ import {
ContactShareDecisionSchema,
ContactShareSchema,
type ContactBookDto,
type ContactCopyResult,
type ContactData,
type ContactDto,
type ContactImportResult,
@@ -21,6 +22,7 @@ import {
assertBuiltin,
cleanNumbers,
contactNames,
copyToContrack,
createContact,
readBooks,
toDto,
@@ -188,6 +190,29 @@ export async function contactRoutes(app: FastifyInstance) {
.send(phoneSafeName(names.get(q.data.number) ?? ''));
});
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
app.post(
'/api/me/contacts/copy-to-contrack',
{ preHandler: requireAuth },
async (req): Promise<ContactCopyResult> => copyToContrack(db, req.user!.id),
);
app.post(
'/api/contacts/copy-to-contrack',
{ preHandler: requireAdmin },
async (req): Promise<ContactCopyResult> => {
const r = await copyToContrack(db, null);
if (r.added)
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'create',
entityType: 'contact',
summary: `Copied ${r.added} company contacts to Contrack`,
});
return r;
},
);
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
const b = z
+1 -1
View File
@@ -66,7 +66,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
/** The config, or (phonebook) the directory in the phone's vendor's format. */
async function render(c: ProvisionContext, phonebook = false): Promise<string> {
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
const entries = await loadPhonebook(db);
const entries = await loadPhonebook(db, c.ownerUserId);
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
}
+30 -2
View File
@@ -11,7 +11,14 @@ 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 {
callExtensions,
calls,
contacts,
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';
@@ -188,6 +195,7 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
toCopy: 0,
});
expect(book.items).toEqual([
expect.objectContaining({
@@ -307,6 +315,26 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
expect(content).not.toContain('<quote>');
});
it('contacts from before Contrack copy into the account in one import (C6)', async () => {
await t.db.insert(contacts).values([
{
name: 'Old Friend',
ownerUserId: danId,
numbers: [{ label: 'home', number: '13125550202' }],
},
{ name: 'Jane Doe', ownerUserId: danId, numbers: [{ label: '', number: '13125551234' }] },
]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
const r = await dan('POST', '/api/me/contacts/copy-to-contrack');
expect(r.json(), r.body).toEqual({ added: 1, duplicates: 1, failed: 0 });
const rows = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
phones: { phone: string }[];
}[];
expect(rows.find((x) => x.name === 'Old Friend')?.phones).toEqual([{ phone: '13125550202' }]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
});
it('a share is accepted into the recipient’s own Contrack account', async () => {
const jane = (await dan('GET', '/api/me/contacts'))
.json()
@@ -328,6 +356,6 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
const dans = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
}[];
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe']);
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe', 'Old Friend']);
});
});
+80 -1
View File
@@ -11,6 +11,7 @@ import { logCallsToContrack } from '../src/contacts/timeline.ts';
import {
callExtensions,
calls,
contacts,
contrackCallLog,
extensions,
recordings,
@@ -67,6 +68,30 @@ function fakeContrack() {
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const book = books[token];
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
if (req.method === 'POST' && req.url === '/api/contacts/bulk') {
if (!/^[0-9a-f-]{36}$/.test(String(req.headers['x-import-id'])))
return send(400, { error: { code: 'VALIDATION_ERROR', message: 'X-Import-Id' } });
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const rows = JSON.parse(raw) as { name: string; phones: { phone: string }[] }[];
for (const r of rows)
book.push({
id: `bulk-${++n}`,
name: r.name,
company: null,
emails: [],
phones: r.phones,
});
send(201, {
success: true,
count: rows.length,
failed: 0,
importId: req.headers['x-import-id'],
});
});
return;
}
const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
if (req.method === 'POST' && into) {
if (state.readOnly.has(token))
@@ -217,6 +242,7 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
toCopy: 0,
});
expect(book.items).toEqual([
{
@@ -436,10 +462,63 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
expect(jane).toHaveLength(8);
});
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
await t.db.insert(contacts).values([
{
name: 'Jane Doe',
ownerUserId: danId,
numbers: [{ label: 'mobile', number: '13125551234' }],
}, // in Contrack
{
name: 'Old Friend',
ownerUserId: danId,
numbers: [{ label: 'home', number: '13125550202' }],
},
{ name: 'Plumber', numbers: [{ label: '', number: '13125557777' }] }, // in the company account
{ name: 'Locksmith', numbers: [{ label: 'work', number: '13125550303' }] },
]);
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
added: 1,
duplicates: 1,
failed: 0,
});
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
name: 'Old Friend',
phones: [{ phone: '13125550202', label: 'home' }],
});
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
added: 0,
duplicates: 2,
failed: 0,
});
// The company book, by an admin only.
expect((await dan('POST', '/api/contacts/copy-to-contrack')).statusCode).toBe(403);
expect((await admin('GET', '/api/contacts')).json().contrack.toCopy).toBe(1);
expect((await admin('POST', '/api/contacts/copy-to-contrack')).json()).toEqual({
added: 1,
duplicates: 1,
failed: 0,
});
expect(fake.books.ctk_company!.map((c) => c.name)).toEqual(['Plumber', 'Locksmith']);
// Dan's desk phone: his Contrack contacts, then the company's.
const names = (await loadPhonebook(t.db, danId)).map((e) => e.name);
expect(names.indexOf('Old Friend')).toBeGreaterThan(-1);
expect(names.indexOf('Old Friend')).toBeLessThan(names.indexOf('Locksmith'));
expect((await loadPhonebook(t.db, null)).map((e) => e.name)).not.toContain('Old Friend');
});
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();
expect(book).toEqual({ items: [], provider: 'builtin', contrack: null });
expect([book.provider, book.contrack]).toEqual(['builtin', null]);
expect(book.items.map((c: { name: string }) => c.name)).toEqual([
'Jane Doe',
'Old Friend',
'Locksmith',
'Plumber',
]);
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
});
+26
View File
@@ -245,6 +245,32 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
await setSetting(t.db, 'general.timezone', 'America/Chicago');
});
it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => {
const owner = await t.addUser('dan@example.com', 'dan-password-123');
const other = await t.addUser('sue@example.com', 'sue-password-123');
await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id });
await t.db.insert(contacts).values([
{ name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] },
{
name: 'Sue Secret',
ownerUserId: other.id,
numbers: [{ label: '', number: '13125550102' }],
},
]);
const book = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(book).toContain('<Telephone label="mobile">13125550101</Telephone>');
expect(book).not.toContain('Sue Secret');
expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom'));
expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's
await req('PATCH', `/api/extensions/${extId}`, { userId: null });
const unowned = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(unowned).not.toContain('Mom');
});
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
expect(r.statusCode, r.body).toBe(200);
+2
View File
@@ -23,6 +23,7 @@ import type {
CallDto,
CallFilter,
ContactBookDto,
ContactCopyResult,
ContrackOptions,
ContactDto,
ContactImportResult,
@@ -299,6 +300,7 @@ export type ContactBase = '/api/me/contacts' | '/api/contacts';
export const contactsApi = {
list: (base: ContactBase) => api<ContactBookDto>('GET', base),
copyToContrack: (base: ContactBase) => api<ContactCopyResult>('POST', `${base}/copy-to-contrack`),
testContrack: (url: string, companyToken?: string) =>
api<{ ok: true; detail: string } | { ok: false; error: string }>(
'POST',
+26
View File
@@ -159,6 +159,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
: 'The company Contrack account has no token yet: Settings → Contacts.')}
</Alert>
)}
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
{list.data && shown.length === 0 && (
<p className="text-sm text-zinc-500">
{q ? 'No contact matches that.' : 'No contacts yet.'}
@@ -261,6 +262,31 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
);
}
/** Contacts from before Contrack (11 §5, C6): copied once, kept here for switching back. */
function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
const qc = useQueryClient();
const copy = useMutation({
mutationFn: () => contactsApi.copyToContrack(base),
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
});
return (
<div
role="status"
className="flex flex-wrap items-center gap-2 rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950"
>
<span className="flex-1">
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
{count === 1 ? "isn't" : "aren't"} in {base === COMPANY ? "the company's" : 'your'} Contrack
yet.
</span>
<Button variant="primary" disabled={copy.isPending} onClick={() => copy.mutate()}>
Copy {count === 1 ? 'it' : 'them'} to Contrack
</Button>
{copy.error && <Alert>{copy.error.message}</Alert>}
</div>
);
}
const LABELS = ['mobile', 'work', 'home', 'main', 'fax', 'other'];
function ContactDrawer(props: {