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: {
+9 -2
View File
@@ -1,7 +1,8 @@
# Contacts
Contacts put names on numbers: in call history, voicemail, the browser phone while it rings, the
call report email, and the directory on desk phones (the company contacts). Pick where they live
Contacts put names on numbers: in call history, voicemail, the browser phone and desk phones
while they ring, the call report email, and the directory on desk phones (each phone has its
owner's contacts and the company's). Pick where they live
in the setup wizard or in **Settings → Contacts**:
- **Simple**: a phonebook inside SelfPhoneNG. Nothing else to run.
@@ -89,6 +90,12 @@ Administration → AI**.
SelfPhoneNG's backup doesn't include Contrack. Contrack makes its own snapshots in its
`contrack-data` volume (its **Settings → Backups**); back up that volume too.
### Moving to Contrack with contacts already in SelfPhoneNG
Switching from Simple keeps everyone's contacts. Each person (and an admin, for the company
contacts) sees **Copy them to Contrack** on their Contacts page until they're copied; contacts
Contrack already has are skipped.
### Going back to Simple
**Settings → Contacts → Simple**. The builtin contacts are still there, and the Contrack tokens
+1 -1
View File
@@ -377,7 +377,7 @@ Auth & self:
| GET | `/api/me/greetings/:kind/audio` | user | the wav |
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1) |
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) |
| POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45<queue>` |
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out |
+1 -1
View File
@@ -37,7 +37,7 @@ type ProvisionContext = {
| `GET /prov/<mac>.cfg` | Yealink (also Polycom master, see §3.3) | MAC lowercase. Decide by `devices.vendor` |
| `GET /prov/y0000000000<xx>.cfg` | Yealink common file | Return an empty valid file `#!version:1.0.0.1\n` |
| `GET /prov/cfg<mac>.xml` | Grandstream | |
| `GET /prov/phonebook/<mac>.xml` | both | company phonebook + extensions, vendor-specific XML |
| `GET /prov/phonebook/<mac>.xml` | both | extensions + the phone owner's contacts + company phonebook (11 §6.2), vendor-specific XML |
| `GET /prov/linphone/<token>.xml` | Linphone (§5) | token auth instead of Basic |
Rules:
+15 -2
View File
@@ -267,7 +267,6 @@ As built (C5, `contacts/timeline.ts`):
### 4.5 Not in Contrack mode
- Personal contacts are not on desk-phone phonebooks at first (company book only). Phase C6.
- Editing a Contrack contact from SelfPhoneNG.
- A contact in several books is shown once per book; lookup takes the first in §1's order.
@@ -292,7 +291,7 @@ As built (C5, `contacts/timeline.ts`):
| 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. Done, see §4.4 as built |
| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional |
| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional. Done, see §6.2 |
### 6.1 As built: C4
@@ -315,6 +314,20 @@ As built (C5, `contacts/timeline.ts`):
contact name not reaching 102 after a no-answer forward, and the phone still ringing (with the
provider's name) when the api hangs.
### 6.2 As built: C6
- A phone's directory (`/prov/phonebook/<mac>.xml`, 07) is the extensions, then **its owner's own
contacts** (accepted shares are among them), then the company's, each by name, from the
builtin books or Contrack. A phone whose extension has no user gets the extensions and the
company only.
- Contrack mode: `GET /api/me/contacts` (and `/api/contacts`) says in `contrack.toCopy` how many
builtin contacts that book's Contrack account doesn't have yet (same name and number), and the
page offers **Copy them to Contrack**: `POST /api/me/contacts/copy-to-contrack` (admin:
`/api/contacts/copy-to-contrack`, audited) sends them in one `POST /api/contacts/bulk` (with an
`X-Import-Id`). The builtin rows stay, for switching back; once copied the offer is gone.
Contrack's own duplicate check then runs on the import as usual.
- Proven against Contrack 2.0.2 (`contrack-live.test.ts`).
Tests: builtin lookup/search/import against the test DB; contrack provider against a fake HTTP
server built from the pinned `openapi.json`; one e2e script against a real Contrack container.
+10 -1
View File
@@ -53,9 +53,18 @@ export type ContactBookDto = {
items: ContactDto[];
provider: 'builtin' | 'contrack';
/** Contrack mode: the address people open, and whether this book's token works. */
contrack: { publicUrl: string | null; connected: boolean; error: string | null } | null;
contrack: {
publicUrl: string | null;
connected: boolean;
error: string | null;
/** Contacts still only in the builtin book (from before Contrack): "Copy them" (11 §5). */
toCopy: number;
} | null;
};
/** POST …/contacts/copy-to-contrack (11 §5, C6). */
export type ContactCopyResult = { added: number; duplicates: number; failed: number };
/** POST …/contacts/import: the files' text, read in the browser. */
export const ContactImportSchema = z.object({
files: z