contacts: Contrack as the robust contacts (C3), picked in Settings or the setup wizard
ci / check (push) Successful in 1m0s
ci / images (push) Successful in 32s

Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token
write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings,
books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in
Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack
(2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake
and a real Contrack 2.0.2 (contrack-live.test.ts).
This commit is contained in:
iamdoubz committed 2026-10-23 15:23:49 -05:00
1 parent a754974a90
commit 692dd59552
32 files changed
+5787 -120

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet
# --- optional ---
CONTRACK_HOSTNAME= # Contrack contacts (spec 11 §4): its own DNS name, e.g. contacts.example.com
TZ=America/Chicago
API_PORT=8080
POSTGRES_PORT=5433
+110 -28
View File
@@ -1,16 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The builtin address books (11 §3): a user's (owner_user_id) and the company's (null).
// ponytail: builtin only. Contrack (C3) adds its provider here, behind the same functions.
// Address books (11 §2): a user's (owner = their id) and the company's (owner null), kept in the
// `contacts` table (builtin) or in Contrack (contacts.provider = 'contrack', 11 §4). Every caller
// goes through these functions and doesn't care which.
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
import {
normalizeNumber,
type ContactBookDto,
type ContactData,
type ContactDto,
type ContactImportResult,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { contacts } from '../db/schema.ts';
import { badRequest } from '../errors.ts';
import { HttpError, badRequest } from '../errors.ts';
import {
ContrackError,
contrackBook,
contrackCreate,
contrackSettings,
needToken,
type Contrack,
} from './contrack.ts';
/** null = the company book. */
export type Owner = string | null;
@@ -26,8 +36,22 @@ export const toDto = (r: Row): ContactDto => ({
email: r.email,
numbers: r.numbers as ContactData['numbers'],
notes: r.notes,
url: null,
});
/** Contrack's errors as API answers (409 for "connect first", 502 for Contrack itself). */
const asHttp = (err: unknown): never => {
if (err instanceof ContrackError)
throw new HttpError(err.status === 409 ? 409 : 502, 'contrack', err.message);
throw err;
};
/** Editing, deleting and importing happen in Contrack itself in Contrack mode (11 §3.1, §4.3). */
export async function assertBuiltin(db: Db) {
if (await contrackSettings(db))
throw new HttpError(409, 'contrack', 'Contacts live in Contrack: change them there');
}
/** Normalized numbers (00 §8); `bad` gets the ones that aren't phone numbers. */
export function cleanNumbers(c: ContactData, bad?: (n: string) => void): ContactData['numbers'] {
const out: ContactData['numbers'] = [];
@@ -42,20 +66,55 @@ export function cleanNumbers(c: ContactData, bad?: (n: string) => void): Contact
return out;
}
/** My contacts, then the company's, by name. */
export async function listBooks(db: Db, userId: string): Promise<ContactDto[]> {
const rows = await db
.select()
.from(contacts)
.where(or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId)))
.orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
return rows.map(toDto);
const byName = (a: ContactDto, b: ContactDto) => a.name.localeCompare(b.name);
async function contrackList(db: Db, c: Contrack, owner: Owner) {
const b = await contrackBook(db, c, owner);
return { items: (b?.contacts ?? []).toSorted(byName), book: b };
}
export async function listBook(db: Db, owner: Owner): Promise<ContactDto[]> {
return (await db.select().from(contacts).where(inBook(owner)).orderBy(asc(contacts.name))).map(
toDto,
);
/**
* The books someone sees: my contacts, then the company's (userId), or only the company's (null,
* Admin → Contacts). `contrack` says whether that person's (or the company's) token works.
*/
export async function readBooks(db: Db, userId: string | null): Promise<ContactBookDto> {
const c = await contrackSettings(db);
if (!c) {
const rows = await db
.select()
.from(contacts)
.where(
userId
? or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId))
: isNull(contacts.ownerUserId),
)
.orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
return { items: rows.map(toDto), provider: 'builtin', contrack: null };
}
const company = await contrackList(db, c, null);
const mine = userId ? await contrackList(db, c, userId) : null;
const own = mine ?? company; // whose token the page is about
return {
items: [...(mine?.items ?? []), ...company.items],
provider: 'contrack',
contrack: {
publicUrl: c.publicUrl,
connected: !!own.book && !own.book.error,
error: own.book?.error ?? null,
},
};
}
/** 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) };
const c = await contrackSettings(db);
if (c) return contrackCreate(db, c, owner, clean).catch(asHttp);
const [row] = await db
.insert(contacts)
.values({ ...clean, ownerUserId: owner })
.returning();
return toDto(row!);
}
/** Same contact: same name (any case) and a number in common, or neither has a number. */
@@ -66,42 +125,53 @@ const isDuplicate = (a: ContactData, b: ContactData) =>
a.numbers.some((n) => b.numbers.some((m) => m.number === n.number)));
/**
* Adds contacts to a book, skipping ones it already has (11 §3.1). With `commit` false it only
* counts. Bad numbers are dropped and reported.
* Adds contacts to a book, skipping ones it already has (11 §3.1): an import or accepted shares.
* With `commit` false it only counts. Bad numbers are dropped and reported.
*/
export async function addToBook(
db: Pick<Db, 'select' | 'insert'>, // a transaction too
db: Db,
owner: Owner,
list: ContactData[],
commit: boolean,
errors: string[] = [],
): Promise<ContactImportResult> {
const have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
const c = await contrackSettings(db);
let have: ContactDto[];
if (c) {
const b = await contrackBook(db, c, owner);
if (!b) asHttp(needToken(owner));
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
have = b!.contacts;
} else have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
const fresh: ContactData[] = [];
let duplicates = 0;
for (const c of list) {
const numbers = cleanNumbers(c, (n) =>
errors.push(`${c.name}: "${n}" isn't a phone number, left out`),
for (const d of list) {
const numbers = cleanNumbers(d, (n) =>
errors.push(`${d.name}: "${n}" isn't a phone number, left out`),
);
const clean = { ...c, numbers };
const clean = { ...d, numbers };
if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
else fresh.push(clean);
}
// ponytail: one insert of up to 5,000 rows (the import cap).
if (commit && fresh.length)
await db.insert(contacts).values(fresh.map((c) => ({ ...c, ownerUserId: owner })));
if (commit && fresh.length) {
// ponytail: one Contrack request per contact; imports go to Contrack's own importer, so
// this is only accepted shares.
if (c) for (const d of fresh) await contrackCreate(db, c, owner, d).catch(asHttp);
// ponytail: one insert of up to 5,000 rows (the import cap).
else await db.insert(contacts).values(fresh.map((d) => ({ ...d, ownerUserId: owner })));
}
return {
committed: commit,
added: fresh.length,
duplicates,
noNumber: fresh.filter((c) => !c.numbers.length).length,
noNumber: fresh.filter((d) => !d.numbers.length).length,
errors: errors.slice(0, 20),
};
}
/**
* Names for numbers, from my book first, then the company's (11 §1). Numbers as calls store them
* (normalized).
* (normalized). userId null: the company book only.
*/
export async function contactNames(
db: Db,
@@ -111,6 +181,18 @@ export async function contactNames(
const want = [...new Set(numbers.filter((n): n is string => !!n))];
const out = new Map<string, string>();
if (!want.length) return out;
const c = await contrackSettings(db);
if (c) {
const books = [
userId ? await contrackBook(db, c, userId) : null,
await contrackBook(db, c, null),
];
for (const b of books)
for (const ct of b?.contacts ?? [])
for (const n of ct.numbers)
if (want.includes(n.number) && !out.has(n.number)) out.set(n.number, ct.name);
return out;
}
// ponytail: a scan of the two books' numbers; a GIN index when books reach tens of thousands.
const { rows } = await db.execute<{ name: string; number: string; mine: boolean }>(sql`
select c.name, n->>'number' as number, c.owner_user_id is not null as mine
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 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 { eq } from 'drizzle-orm';
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
import { AiError, assertLocalUrl } from '../ai/client.ts';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { users } from '../db/schema.ts';
import { getSetting } from '../services/settings.ts';
export class ContrackError extends Error {
status: number | undefined;
constructor(message: string, status?: number) {
super(message);
this.status = status;
}
}
/** One call to Contrack's API (base: the local address in Settings → Contacts). */
async function ctk<T>(
base: string,
token: string,
path: string,
init: RequestInit = {},
): Promise<T> {
try {
assertLocalUrl(base);
} catch (err) {
throw new ContrackError((err as AiError).message);
}
const url = new URL(path, base.endsWith('/') ? base : `${base}/`);
let r: Response;
try {
r = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
},
redirect: 'error',
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
const e = err as Error & { cause?: { code?: string } };
throw new ContrackError(
e.name === 'TimeoutError'
? `Contrack (${url.host}) didn't answer within 10 s`
: `Can't reach Contrack at ${url.host}${e.cause?.code ? ` (${e.cause.code})` : ''}`,
);
}
if (r.status === 401) throw new ContrackError('Contrack refused the token', 401);
if (!r.ok) {
const body = (await r.json().catch(() => null)) as { error?: { message?: string } } | null;
throw new ContrackError(`Contrack: ${body?.error?.message ?? `HTTP ${r.status}`}`, r.status);
}
return (await r.json()) as T;
}
type Slim = {
id: string;
name: string;
company: string | null;
emails: { email: string }[];
phones: { phone: string }[];
};
export type Contrack = { url: string; publicUrl: string | null; companyToken: string | null };
/** Settings → Contacts, or null in builtin mode. */
export async function contrackSettings(db: Db): Promise<Contrack | null> {
if ((await getSetting(db, 'contacts.provider')) !== 'contrack') return null;
const c = await getSetting(db, 'contacts.contrack');
if (!c) return null;
return {
url: c.url,
publicUrl: c.publicUrl,
companyToken: c.companyTokenEnc ? decrypt(c.companyTokenEnc) : null,
};
}
/** The token for a book: a user's own, or the company account's (owner null). */
export async function tokenFor(db: Db, c: Contrack, owner: string | null): Promise<string | null> {
if (owner === null) return c.companyToken;
const [u] = await db
.select({ enc: users.contrackTokenEnc })
.from(users)
.where(eq(users.id, owner));
return u?.enc ? decrypt(u.enc) : null;
}
const toDto = (c: Contrack, s: Slim, book: ContactDto['book']): ContactDto => {
const numbers: ContactData['numbers'] = [];
for (const p of s.phones)
try {
const number = normalizeNumber(p.phone);
if (!numbers.some((n) => n.number === number)) numbers.push({ label: '', number });
} catch {
// Not a number a phone can dial ("ask reception"): left out.
}
return {
id: s.id,
book,
name: s.name,
company: s.company,
email: s.emails[0]?.email ?? null,
numbers,
notes: null,
url: c.publicUrl ? `${c.publicUrl}/contact/${encodeURIComponent(s.id)}` : null,
};
};
type Book = { contacts: ContactDto[]; at: number; error: string | null };
/** By owner: a user id, or '' for the company account. Keyed by token too, so a new one refetches. */
const cache = new Map<string, Book & { token: string }>();
const TTL = 5 * 60_000;
/** Forget a book (after we add to it, or a token changes). */
export const forgetBook = (owner: string | null) => cache.delete(owner ?? '');
/**
* A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing
* Contrack keeps the last good list and says why.
*/
export async function contrackBook(
db: Db,
c: Contrack,
owner: string | null,
now = Date.now(),
): Promise<Book | null> {
const token = await tokenFor(db, c, owner);
if (!token) return null;
const key = owner ?? '';
const hit = cache.get(key);
if (hit && hit.token === token && now - hit.at < TTL) return hit;
const book = owner === null ? 'company' : 'mine';
try {
const rows = await ctk<Slim[]>(c.url, token, 'api/contacts?view=slim');
const fresh = { contacts: rows.map((r) => toDto(c, r, book)), at: now, error: null, token };
cache.set(key, fresh);
return fresh;
} catch (err) {
const stale = {
contacts: hit?.token === token ? hit.contacts : [],
at: now, // tried now: the next try is in 5 minutes, not on every lookup
error: (err as Error).message,
token,
};
cache.set(key, stale);
return stale;
}
}
/** Writing to a book whose owner hasn't connected Contrack. */
export const needToken = (owner: string | null) =>
new ContrackError(
owner
? 'Connect your Contrack account first (My settings → Contacts)'
: 'The company Contrack account has no token (Settings → Contacts)',
409,
);
/** Adds a contact to a Contrack book (11 §4.3: "Add to contacts", an accepted share). */
export async function contrackCreate(
db: Db,
c: Contrack,
owner: string | null,
d: ContactData,
): Promise<ContactDto> {
const token = await tokenFor(db, c, owner);
if (!token) throw needToken(owner);
const row = await ctk<Slim>(c.url, token, 'api/contacts', {
method: 'POST',
body: JSON.stringify({
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 toDto(c, row, owner === null ? 'company' : 'mine');
}
/** 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 {
assertLocalUrl(url);
} catch (err) {
throw new ContrackError((err as AiError).message);
}
const health = await fetch(new URL('healthz', url.endsWith('/') ? url : `${url}/`), {
redirect: 'error',
signal: AbortSignal.timeout(10_000),
}).catch(() => null);
if (!health?.ok) throw new ContrackError(`Can't reach Contrack at ${url}`);
if (!token) return 'Contrack answers.';
const rows = await ctk<Slim[]>(url, token, 'api/contacts?view=slim');
return `Contrack answers; the token reads ${rows.length} contact${rows.length === 1 ? '' : 's'}.`;
}
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "contrack_token_enc" text;
File diff suppressed because it is too large. Load diff
@@ -71,6 +71,13 @@
"when": 1792784242837,
"tag": "0009_contact_shares",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1792785318384,
"tag": "0010_contrack_token",
"breakpoints": true
}
]
}
+2
View File
@@ -63,6 +63,8 @@ export const users = pgTable('users', {
totpLastStep: bigint({ mode: 'number' }),
mustChangePassword: boolean().notNull().default(false),
lastLoginAt: tstz(),
/** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */
contrackTokenEnc: text(),
...timestamps,
});
+6 -8
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What a vendor template gets (07 §1). Templates are pure functions of this; no I/O there.
import { asc, eq, inArray, isNull } from 'drizzle-orm';
import { asc, eq, inArray } from 'drizzle-orm';
import { phoneTimezone, type PhoneTimezone, type PhoneVendor } from '@selfphoneng/shared';
import type { Config } from '../config.ts';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { contacts, devices, extensions } from '../db/schema.ts';
import { devices, extensions } from '../db/schema.ts';
import { readBooks } from '../contacts/book.ts';
import { getSetting } from '../services/settings.ts';
import { loadDialRules, type DialRule } from './dialplan.ts';
@@ -87,17 +88,14 @@ 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. */
/** 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[]> {
const exts = await db
.select({ name: extensions.name, number: extensions.number })
.from(extensions)
.orderBy(asc(extensions.name));
const company = await db
.select({ name: contacts.name, numbers: contacts.numbers })
.from(contacts)
.where(isNull(contacts.ownerUserId))
.orderBy(asc(contacts.name));
const company = (await readBooks(db, null)).items;
return [
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
...company.map((c) => ({
+69 -50
View File
@@ -8,6 +8,7 @@ import {
ContactInputSchema,
ContactShareDecisionSchema,
ContactShareSchema,
type ContactBookDto,
type ContactData,
type ContactDto,
type ContactImportResult,
@@ -17,9 +18,10 @@ import {
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import {
addToBook,
assertBuiltin,
cleanNumbers,
listBook,
listBooks,
createContact,
readBooks,
toDto,
type Owner,
} from '../contacts/book.ts';
@@ -28,6 +30,9 @@ import { parseVcards } from '../contacts/vcard.ts';
import { contactShares, contacts, extensions, users } from '../db/schema.ts';
import { badRequest, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decrypt } from '../crypto/secrets.ts';
import { contrackCheck } from '../contacts/contrack.ts';
const IdParams = z.object({ id: z.uuid() });
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
@@ -92,16 +97,14 @@ export async function contactRoutes(app: FastifyInstance) {
app.post(prefix, { preHandler: guard }, async (req, reply) => {
const c = ContactInputSchema.parse(req.body);
const [row] = await db
.insert(contacts)
.values({ ...c, numbers: cleanNumbers(c), ownerUserId: ownerOf(req.user!) })
.returning();
await log(req, 'create', row!.id, `Added ${c.name} to the company contacts`);
return reply.code(201).send(toDto(row!));
const dto = await createContact(db, ownerOf(req.user!), c);
await log(req, 'create', dto.id, `Added ${c.name} to the company contacts`);
return reply.code(201).send(dto);
});
app.patch(`${prefix}/:id`, { preHandler: guard }, async (req): Promise<ContactDto> => {
const { id } = IdParams.parse(req.params);
await assertBuiltin(db);
await mine(req.user!, id);
const c = ContactInputSchema.parse(req.body);
const [row] = await db
@@ -115,6 +118,7 @@ export async function contactRoutes(app: FastifyInstance) {
app.delete(`${prefix}/:id`, { preHandler: guard }, async (req) => {
const { id } = IdParams.parse(req.params);
await assertBuiltin(db);
const r = await mine(req.user!, id);
await db.delete(contacts).where(eq(contacts.id, id));
await log(req, 'delete', id, `Deleted the company contact ${r.name}`);
@@ -125,6 +129,7 @@ export async function contactRoutes(app: FastifyInstance) {
`${prefix}/import`,
{ preHandler: guard, bodyLimit: IMPORT_BODY },
async (req): Promise<ContactImportResult> => {
await assertBuiltin(db); // Contrack has its own importer (11 §3.1)
const { files } = ContactImportSchema.parse(req.body);
const commit = CommitQuery.parse(req.query).commit === '1';
const { list, errors } = readFiles(files);
@@ -138,36 +143,40 @@ export async function contactRoutes(app: FastifyInstance) {
// ───── my book ─────
/** Mine and the company's: the page filters, and the browser looks up names from it. */
app.get(
'/api/me/contacts',
{ preHandler: requireAuth },
async (req): Promise<ListResponse<ContactDto>> => ({
items: await listBooks(db, req.user!.id),
nextCursor: null,
}),
app.get('/api/me/contacts', { preHandler: requireAuth }, async (req): Promise<ContactBookDto> =>
readBooks(db, req.user!.id),
);
book('/api/me/contacts', requireAuth, (u) => u.id);
// ───── the company book ─────
app.get(
'/api/contacts',
{ preHandler: requireAdmin },
async (): Promise<ListResponse<ContactDto>> => ({
items: await listBook(db, null),
nextCursor: null,
}),
app.get('/api/contacts', { preHandler: requireAdmin }, async (): Promise<ContactBookDto> =>
readBooks(db, null),
);
book('/api/contacts', requireAdmin, () => null);
/** 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
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
});
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
const s = ContactShareSchema.parse(req.body);
const me = req.user!.id;
const picked = await db
.select()
.from(contacts)
.where(and(eq(contacts.ownerUserId, me), inArray(contacts.id, s.contactIds)));
if (picked.length !== s.contactIds.length) throw notFound('Contact not found');
const picked = (await readBooks(db, me)).items.filter(
(c) => c.book === 'mine' && s.contactIds.includes(c.id),
);
if (picked.length !== new Set(s.contactIds).size) throw notFound('Contact not found');
const to = s.everyone
? await db
.select({ id: users.id })
@@ -202,10 +211,12 @@ export async function contactRoutes(app: FastifyInstance) {
);
await tx.insert(contactShares).values(
to.flatMap((u) =>
picked.map((c) => {
const { id: _i, book: _b, ...contact } = toDto(c);
return { fromUserId: me, toUserId: u.id, sourceId: c.id, contact };
}),
picked.map(({ id, name, company, email, numbers, notes }) => ({
fromUserId: me,
toUserId: u.id,
sourceId: id,
contact: { name, company, email, numbers, notes },
})),
),
);
});
@@ -238,27 +249,35 @@ export async function contactRoutes(app: FastifyInstance) {
app.post(`/api/me/contacts/shares/${decision}`, { preHandler: requireAuth }, async (req) => {
const { ids } = ContactShareDecisionSchema.parse(req.body);
const me = req.user!.id;
return db.transaction(async (tx) => {
const offers = await tx
.update(contactShares)
.set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() })
.where(
and(
eq(contactShares.toUserId, me),
eq(contactShares.status, 'pending'),
inArray(contactShares.id, ids),
const mineAndPending = and(
eq(contactShares.toUserId, me),
eq(contactShares.status, 'pending'),
inArray(contactShares.id, ids),
);
const offers = await db.select().from(contactShares).where(mineAndPending);
// Added first, then marked: Contrack being down (or not connected) leaves the offers waiting.
const r =
decision === 'accept'
? await addToBook(
db,
me,
offers.map((o) => o.contact as ContactData),
true,
)
: null;
await db
.update(contactShares)
.set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() })
.where(
and(
mineAndPending,
inArray(
contactShares.id,
offers.map((o) => o.id),
),
)
.returning();
if (decision === 'deny') return { denied: offers.length };
const r = await addToBook(
tx,
me,
offers.map((o) => o.contact as ContactData),
true,
),
);
return { added: r.added, duplicates: r.duplicates };
});
return r ? { added: r.added, duplicates: r.duplicates } : { denied: offers.length };
});
}
+52
View File
@@ -27,8 +27,16 @@ import {
queueMembers,
queues,
userRuntimeState,
users,
voicemailMessages,
} from '../db/schema.ts';
import {
contrackBook,
contrackCheck,
contrackSettings,
forgetBook,
tokenFor,
} from '../contacts/contrack.ts';
import { setPaused } from '../services/queues.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts';
@@ -115,9 +123,53 @@ export async function meRoutes(app: FastifyInstance) {
callReportTime: e.callReportTime,
callReportWeekday: e.callReportWeekday,
callReportSummaries: e.callReportSummaries,
contrack: await myContrack(userId),
};
}
/** Contrack mode: is my token set, and does it work (11 §4.3). Null in builtin mode. */
async function myContrack(userId: string): Promise<MeDto['contrack']> {
const c = await contrackSettings(db);
if (!c) return null;
const token = await tokenFor(db, c, userId);
const book = token ? await contrackBook(db, c, userId) : null;
return {
connected: !!book && !book.error,
tokenPrefix: token ? `${token.slice(0, 12)}…` : null,
publicUrl: c.publicUrl,
};
}
/** Connect my Contrack account: the token must read my contacts before it's kept. */
app.put('/api/me/contrack', async (req) => {
const { token } = z.object({ token: z.string().trim().min(8).max(200) }).parse(req.body);
const c = await contrackSettings(db);
if (!c) throw conflict('Contacts are not in Contrack (Settings → Contacts)');
await contrackCheck(c.url, token).catch((err: Error) => {
throw badRequest(err.message);
});
await db
.update(users)
.set({ contrackTokenEnc: encrypt(token) })
.where(eq(users.id, req.user!.id));
forgetBook(req.user!.id);
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'user',
entityId: req.user!.id,
summary: 'Connected their Contrack account',
});
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);
return myState(req.user!.id);
});
app.get('/api/me', async (req) => myState(req.user!.id));
// Queue login from Home (05 §4, 04 §7.3), the same as *45<queue> on a phone.
+28 -1
View File
@@ -6,6 +6,7 @@ import { z } from 'zod';
import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
@@ -28,6 +29,7 @@ import { backupMissed } from '../services/backup.ts';
import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -45,9 +47,14 @@ const READ_ONLY = new Set<string>([
]);
async function toDto(db: Db): Promise<SettingsDto> {
const { smtp, ...rest } = await getAllSettings(db);
const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
return {
...rest,
'contacts.contrack': ctk && {
url: ctk.url,
publicUrl: ctk.publicUrl,
hasCompanyToken: ctk.companyTokenEnc !== '',
},
smtp: smtp && {
host: smtp.host,
port: smtp.port,
@@ -115,6 +122,26 @@ export async function settingsRoutes(app: FastifyInstance) {
from: input.from,
passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
};
} else if (key === 'contacts.contrack') {
// The company token is write-only too (11 §4.3).
const input = ContrackInputSchema.nullable().parse(raw);
const old = await getSetting(db, 'contacts.contrack');
if (input)
try {
assertLocalUrl(input.url);
} catch (err) {
throw badRequest(`Contrack address: ${(err as Error).message}`);
}
values[key] = input && {
url: input.url.replace(/\/+$/, ''),
publicUrl: input.publicUrl,
companyTokenEnc: input.companyToken
? encrypt(input.companyToken)
: input.clearCompanyToken
? ''
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
}
await checkRefs(db, values);
+261
View File
@@ -0,0 +1,261 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C3 against a real Contrack (decision 0003): the routes we use, as the pinned version answers
// them. Skipped unless CONTRACK_LIVE_URL is set; it needs a fresh Contrack with sign-in on and no
// account yet (it makes the accounts and tokens itself):
//
// docker run -d --rm --name contrack-live -p 127.0.0.1:3210:3210 -e AUTH_REQUIRED=true \
// ghcr.io/arvarik/contrack:2.0.2
// 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 { contactNames } from '../src/contacts/book.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const LIVE = process.env.CONTRACK_LIVE_URL;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
/** Contrack's own API, signed in with a session cookie (accounts, tokens) or a token. */
async function ctk(
path: string,
init: { method?: string; body?: unknown; cookie?: string; token?: string } = {},
) {
const r = await fetch(`${LIVE}${path}`, {
method: init.method ?? 'GET',
headers: {
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(init.cookie ? { Cookie: init.cookie } : {}),
...(init.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init.body ? JSON.stringify(init.body) : undefined,
});
const cookie = r.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
return {
status: r.status,
body: (await r.json().catch(() => null)) as Record<string, unknown>,
cookie,
};
}
const PASSWORD = 'Correct-horse-battery-9';
/** A Contrack account's read-write personal token that never expires. */
async function tokenOf(cookie: string, name: string) {
const t = await ctk('/api/auth/tokens', { method: 'POST', cookie, body: { name } });
expect(t.status, JSON.stringify(t.body)).toBe(201);
return t.body.token as string;
}
/** An admin makes a member, who signs in, sets their own password, and makes a token. */
async function member(adminCookie: string, username: string) {
const u = await ctk('/api/admin/users', {
method: 'POST',
cookie: adminCookie,
body: {
email: `${username}@example.com`,
username,
displayName: username,
temporaryPassword: 'Temporary-pass-123',
},
});
expect(u.status, JSON.stringify(u.body)).toBe(201);
const login = await ctk('/api/auth/login', {
method: 'POST',
body: { identifier: username, password: 'Temporary-pass-123' },
});
expect(login.status, JSON.stringify(login.body)).toBe(200);
const ch = await ctk('/api/auth/change-password', {
method: 'POST',
cookie: login.cookie,
body: { currentPassword: 'Temporary-pass-123', newPassword: PASSWORD },
});
expect(ch.status, JSON.stringify(ch.body)).toBeLessThan(300);
const again = await ctk('/api/auth/login', {
method: 'POST',
body: { identifier: username, password: PASSWORD },
});
return tokenOf(again.cookie, 'SelfPhoneNG');
}
describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: Req;
let dan: Req;
let sue: Req;
let danId: string;
const tok = { company: '', dan: '', sue: '' };
beforeAll(async () => {
// Contrack: the first account is the company's (and Contrack's admin); Dan and Sue are members.
const setup = await ctk('/api/auth/setup', {
method: 'POST',
body: {
email: 'company@example.com',
username: 'company',
password: PASSWORD,
displayName: 'Company',
},
});
expect(setup.status, `needs a fresh Contrack: ${JSON.stringify(setup.body)}`).toBe(201);
tok.company = await tokenOf(setup.cookie, 'SelfPhoneNG company');
tok.dan = await member(setup.cookie, 'dan');
tok.sue = await member(setup.cookie, 'sue');
for (const [token, body] of [
[tok.company, { name: 'Plumber', phones: ['+1 312 555 7777'] }],
[
tok.dan,
{
name: 'Jane Doe',
company: 'Acme',
emails: [{ email: 'jane@acme.example' }],
phones: [{ phone: '(312) 555-1234', label: 'mobile' }, 'front desk ext 123'],
},
],
] as const) {
const c = await ctk('/api/contacts', { method: 'POST', token, body });
expect(c.status, JSON.stringify(c.body)).toBe(201);
}
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as =
(cookie: string): Req =>
(method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
dan = as(await t.login('dan@example.com', 'dan-password-123'));
sue = as(await t.login('sue@example.com', 'sue-password-123'));
}, 60_000);
afterAll(() => t?.close());
it('Test, then save: Contrack answers and reads the company account', async () => {
expect(
(
await admin('POST', '/api/contacts/contrack/test', { url: LIVE, companyToken: tok.company })
).json(),
).toEqual({
ok: true,
detail: 'Contrack answers; the token reads 1 contact.',
});
expect(
(
await admin('POST', '/api/contacts/contrack/test', {
url: LIVE,
companyToken: 'ctk_wrong_token',
})
).json(),
).toEqual({
ok: false,
error: 'Contrack refused the token',
});
const r = await admin('PATCH', '/api/settings', {
'contacts.provider': 'contrack',
'contacts.contrack': {
url: LIVE,
publicUrl: 'https://contacts.example.com',
companyToken: tok.company,
},
});
expect(r.statusCode, r.body).toBe(200);
});
it('a person connects; their book and the company book come from Contrack', async () => {
expect((await dan('PUT', '/api/me/contrack', { token: 'ctk_wrong_token' })).statusCode).toBe(
400,
);
expect(
(await dan('PUT', '/api/me/contrack', { token: tok.dan })).json().contrack.connected,
).toBe(true);
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book.contrack).toEqual({
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
});
expect(book.items).toEqual([
expect.objectContaining({
book: 'mine',
name: 'Jane Doe',
company: 'Acme',
email: 'jane@acme.example',
numbers: [{ label: '', number: '13125551234' }],
url: expect.stringMatching(
/^https:\/\/contacts\.example\.com\/contact\/jane-doe-[0-9a-f]{8}$/,
),
}),
expect.objectContaining({
book: 'company',
name: 'Plumber',
numbers: [{ label: '', number: '13125557777' }],
}),
]);
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
expect(Object.fromEntries(names)).toEqual({
'13125551234': 'Jane Doe',
'13125557777': 'Plumber',
});
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
'Dan Doe',
'Sue Smith',
'Plumber',
]);
});
it('Add to contacts lands in Contrack, with its label', async () => {
const r = await dan('POST', '/api/me/contacts', {
name: 'Bob Smith',
company: 'Widgets',
email: 'bob@widgets.example',
numbers: [{ label: 'work', number: '312-555-9999' }],
});
expect(r.statusCode, r.body).toBe(201);
const id = r.json().id as string;
const full = await ctk(`/api/contacts/${id}`, { token: tok.dan });
expect(full.body).toMatchObject({
name: 'Bob Smith',
company: 'Widgets',
emails: [expect.objectContaining({ email: 'bob@widgets.example' })],
phones: [expect.objectContaining({ phone: '13125559999', label: 'work' })],
});
expect(
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toContain('Bob Smith');
});
it('a share is accepted into the recipient’s own Contrack account', async () => {
const jane = (await dan('GET', '/api/me/contacts'))
.json()
.items.find((c: { name: string }) => c.name === 'Jane Doe');
await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id], extensions: ['102'] });
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).statusCode,
).toBe(409);
await sue('PUT', '/api/me/contrack', { token: tok.sue });
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
).toEqual({ added: 1, duplicates: 0 });
const sues = (await ctk('/api/contacts?view=slim', { token: tok.sue })).body as unknown as {
name: string;
}[];
expect(sues.map((c) => c.name)).toEqual(['Jane Doe']);
// Dan's account is untouched: Sue got a copy.
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']);
});
});
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C3: contacts in Contrack (11 §4), against a fake Contrack that answers the routes we use.
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 { contactNames } from '../src/contacts/book.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
type Slim = {
id: string;
name: string;
company: string | null;
emails: { email: string }[];
phones: { phone: string }[];
};
/** Contrack's slim rows, one private book per token (11 §4.1). */
function fakeContrack() {
const books: Record<string, Slim[]> = {
ctk_company: [
{
id: 'plumber-1a2b3c4d',
name: 'Plumber',
company: null,
emails: [],
phones: [{ phone: '+1 312 555 7777' }],
},
],
ctk_dan_0001: [
{
id: 'jane-doe-00000001',
name: 'Jane Doe',
company: 'Acme',
emails: [{ email: 'jane@acme.example' }],
phones: [{ phone: '(312) 555-1234' }, { phone: 'ask reception' }],
},
],
ctk_sue_0001: [],
};
let n = 0;
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 (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book);
if (req.method === 'POST' && req.url === '/api/contacts') {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const b = JSON.parse(raw) as {
name: string;
company?: string;
emails?: { email: string }[];
phones: { phone: string }[];
};
const row = {
id: `new-${++n}`,
name: b.name,
company: b.company ?? null,
emails: b.emails ?? [],
phones: b.phones,
};
book.push(row);
send(201, row);
});
return;
}
send(404, { error: { code: 'ROUTE_NOT_FOUND', message: 'No route' } });
});
return { server, books };
}
describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: Req;
let dan: Req;
let sue: Req;
let danId: string;
const fake = fakeContrack();
let url: string;
beforeAll(async () => {
await new Promise<void>((ok) => fake.server.listen(0, '127.0.0.1', ok));
url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as =
(cookie: string): Req =>
(method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
dan = as(await t.login('dan@example.com', 'dan-password-123'));
sue = as(await t.login('sue@example.com', 'sue-password-123'));
});
afterAll(async () => {
await t?.close();
fake.server.close();
});
it('Settings → Contacts: a local address only, the company token write-only', async () => {
const far = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: 'https://contrack.example.com', companyToken: 'ctk_company' },
});
expect(far.statusCode).toBe(400);
expect(far.json().error.message).toMatch(/IP address/);
expect(
(
await admin('POST', '/api/contacts/contrack/test', { url, companyToken: 'ctk_company' })
).json(),
).toEqual({
ok: true,
detail: 'Contrack answers; the token reads 1 contact.',
});
expect(
(
await admin('POST', '/api/contacts/contrack/test', {
url: 'http://127.0.0.1:9',
companyToken: 'x',
})
).json().ok,
).toBe(false);
const r = await admin('PATCH', '/api/settings', {
'contacts.provider': 'contrack',
'contacts.contrack': {
url: `${url}/`,
publicUrl: 'https://contacts.example.com/',
companyToken: 'ctk_company',
},
});
expect(r.statusCode, r.body).toBe(200);
expect(r.json()['contacts.contrack']).toEqual({
url,
publicUrl: 'https://contacts.example.com',
hasCompanyToken: true,
});
expect(r.body).not.toContain('ctk_company');
// Saving again without a token keeps it.
await admin('PATCH', '/api/settings', {
'contacts.contrack': { url, publicUrl: 'https://contacts.example.com' },
});
expect((await admin('GET', '/api/settings')).json()['contacts.contrack'].hasCompanyToken).toBe(
true,
);
});
it('each person connects their own token; until then they see the company book', async () => {
expect((await dan('GET', '/api/me')).json().contrack).toEqual({
connected: false,
tokenPrefix: null,
publicUrl: 'https://contacts.example.com',
});
const before = (await dan('GET', '/api/me/contacts')).json();
expect(before.provider).toBe('contrack');
expect(before.contrack.connected).toBe(false);
expect(before.items.map((c: { name: string }) => c.name)).toEqual(['Plumber']);
const bad = await dan('PUT', '/api/me/contrack', { token: 'ctk_not_mine' });
expect(bad.statusCode).toBe(400);
expect(bad.json().error.message).toMatch(/refused the token/);
const ok = await dan('PUT', '/api/me/contrack', { token: 'ctk_dan_0001' });
expect(ok.json().contrack).toMatchObject({ connected: true, tokenPrefix: 'ctk_dan_0001…' });
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book.contrack).toEqual({
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
});
expect(book.items).toEqual([
{
id: 'jane-doe-00000001',
book: 'mine',
name: 'Jane Doe',
company: 'Acme',
email: 'jane@acme.example',
numbers: [{ label: '', number: '13125551234' }], // "ask reception" isn't a number
notes: null,
url: 'https://contacts.example.com/contact/jane-doe-00000001',
},
expect.objectContaining({ name: 'Plumber', book: 'company' }),
]);
});
it('adding works, editing and importing happen in Contrack', async () => {
const r = await dan('POST', '/api/me/contacts', {
name: 'Bob',
numbers: [{ label: 'mobile', number: '312-555-9999' }],
});
expect(r.statusCode, r.body).toBe(201);
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
name: 'Bob',
phones: [{ phone: '13125559999', label: 'mobile' }],
});
expect(
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toContain('Bob');
expect(
(
await dan('PATCH', `/api/me/contacts/${'00000000-0000-4000-8000-000000000000'}`, {
name: 'x',
})
).statusCode,
).toBe(409);
expect(
(
await dan('POST', '/api/me/contacts/import', {
files: [{ name: 'a.vcf', text: 'BEGIN:VCARD\nFN:A\nEND:VCARD' }],
})
).statusCode,
).toBe(409);
});
it('names come from my Contrack book, then the company account; desk phones get the company', async () => {
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
expect(Object.fromEntries(names)).toEqual({
'13125551234': 'Jane Doe',
'13125557777': 'Plumber',
});
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
'Dan Doe',
'Sue Smith',
'Plumber',
]);
});
it('a share waits until the recipient has connected Contrack, then lands in their account', async () => {
const r = await dan('POST', '/api/me/contacts/share', {
contactIds: ['jane-doe-00000001'],
extensions: ['102'],
});
expect(r.json()).toEqual({ offers: 1, people: 1 });
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
expect(offer.contact).toMatchObject({
name: 'Jane Doe',
numbers: [{ label: '', number: '13125551234' }],
});
const early = await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] });
expect(early.statusCode).toBe(409);
expect(early.json().error.message).toMatch(/Connect your Contrack account/);
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toHaveLength(1);
await sue('PUT', '/api/me/contrack', { token: 'ctk_sue_0001' });
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
).toEqual({ added: 1, duplicates: 0 });
expect(fake.books.ctk_sue_0001!.map((c) => c.name)).toEqual(['Jane Doe']);
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
});
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((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
});
+1
View File
@@ -66,6 +66,7 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
callReportTime: '08:00',
callReportWeekday: 1,
callReportSummaries: false,
contrack: null,
});
});
+1
View File
@@ -23,6 +23,7 @@ const EXPECTED: Record<string, Record<string, string>> = {
totp_last_step: 'bigint|YES|',
must_change_password: 'boolean|NO|false',
last_login_at: 'timestamp with time zone|YES|',
contrack_token_enc: 'text|YES|',
...ts,
},
extensions: {
+10 -1
View File
@@ -22,6 +22,7 @@ import type {
CallDetailDto,
CallDto,
CallFilter,
ContactBookDto,
ContactDto,
ContactImportResult,
ContactInput,
@@ -270,6 +271,8 @@ export const meApi = {
get: () => api<MeDto>('GET', '/api/me'),
softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'),
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'),
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) =>
@@ -293,7 +296,13 @@ export const meApi = {
export type ContactBase = '/api/me/contacts' | '/api/contacts';
export const contactsApi = {
list: (base: ContactBase) => api<ListResponse<ContactDto>>('GET', base).then((r) => r.items),
list: (base: ContactBase) => api<ContactBookDto>('GET', base),
testContrack: (url: string, companyToken?: string) =>
api<{ ok: true; detail: string } | { ok: false; error: string }>(
'POST',
'/api/contacts/contrack/test',
{ url, companyToken },
),
create: (base: ContactBase, c: ContactInput) => api<ContactDto>('POST', base, c),
update: (base: ContactBase, id: string, c: ContactInput) =>
api<ContactDto>('PATCH', `${base}/${id}`, c),
+16 -8
View File
@@ -19,7 +19,7 @@ import { DestinationPicker } from '../components/DestinationPicker.tsx';
import { ThemeToggle } from '../components/ThemeToggle.tsx';
import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
import { subscribeLive, useLive } from '../live/store.ts';
import { EmailTab, EmergencyTab } from './admin/SettingsPage.tsx';
import { ContactsTab, EmailTab, EmergencyTab } from './admin/SettingsPage.tsx';
import { ImportNumbers, TrunkForm, trunkStatus } from './admin/TrunksPage.tsx';
const STEPS = [
@@ -30,6 +30,7 @@ const STEPS = [
'Trunk',
'Phone numbers',
'Users',
'Contacts',
'Main number',
'Emergency',
'Done',
@@ -87,9 +88,10 @@ export function SetupPage() {
{step === 5 && <TrunkStep next={next} />}
{step === 6 && <NumbersStep next={next} />}
{step === 7 && <UsersStep next={next} />}
{step === 8 && <RoutingStep next={next} />}
{step === 9 && <SettingsStep tab="emergency" next={next} />}
{step === 10 && <DoneStep onFinish={() => finish.mutate()} busy={finish.isPending} />}
{step === 8 && <SettingsStep tab="contacts" next={next} skippable />}
{step === 9 && <RoutingStep next={next} />}
{step === 10 && <SettingsStep tab="emergency" next={next} />}
{step === 11 && <DoneStep onFinish={() => finish.mutate()} busy={finish.isPending} />}
</section>
</div>
);
@@ -355,7 +357,7 @@ function SettingsStep({
next,
skippable,
}: {
tab: 'email' | 'emergency';
tab: 'email' | 'emergency' | 'contacts';
next: () => void;
skippable?: boolean;
}) {
@@ -369,7 +371,13 @@ function SettingsStep({
!s.data['emergency.defaultCallerIdDidId'];
return (
<>
{tab === 'email' ? <EmailTab s={s.data} /> : <EmergencyTab s={s.data} />}
{tab === 'email' ? (
<EmailTab s={s.data} />
) : tab === 'contacts' ? (
<ContactsTab s={s.data} />
) : (
<EmergencyTab s={s.data} />
)}
<Nav
next={next}
canNext={!needCid}
@@ -450,7 +458,7 @@ function NumbersStep({ next }: { next: () => void }) {
return (
<>
<p className="text-sm text-zinc-500">
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 8.
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
</p>
{trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />}
{dids.data.length > 0 && (
@@ -728,7 +736,7 @@ function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
Set up each desk phone or softphone app with the SIP details under Users &amp; Extensions
→ Credentials.
</li>
<li>Call your main number from a mobile phone: the phone you chose in step 8 rings.</li>
<li>Call your main number from a mobile phone: the phone you chose in step 9 rings.</li>
<li>From a desk phone, call your mobile: it shows your main number as caller ID.</li>
<li>Everything here can be changed later in the admin pages.</li>
</ul>
+146
View File
@@ -15,6 +15,7 @@ import {
import {
aiApi,
apiTokensApi,
contactsApi,
didsApi,
extensionsApi,
settingsApi,
@@ -36,6 +37,7 @@ const TABS = [
{ id: 'webhooks', label: 'Webhooks' },
{ id: 'tokens', label: 'API tokens' },
{ id: 'ai', label: 'AI' },
{ id: 'contacts', label: 'Contacts' },
] as const;
export function SettingsPage() {
@@ -71,6 +73,7 @@ export function SettingsPage() {
{settings.data && tab === 'webhooks' && <WebhooksTab s={settings.data} />}
{tab === 'tokens' && <TokensTab />}
{settings.data && tab === 'ai' && <AiTab s={settings.data} />}
{settings.data && tab === 'contacts' && <ContactsTab s={settings.data} />}
</section>
);
}
@@ -1215,3 +1218,146 @@ function TokensTab() {
</div>
);
}
/** Contacts (spec 11): the builtin books, or Contrack running beside the PBX. Also a setup step. */
export function ContactsTab({ s }: { s: SettingsDto }) {
const save = useSave();
const ctk = s['contacts.contrack'];
const [provider, setProvider] = useState(s['contacts.provider']);
const [v, setV] = useState({
url: ctk?.url ?? 'http://127.0.0.1:3210',
publicUrl: ctk?.publicUrl ?? '',
companyToken: '',
});
const test = useMutation({
mutationFn: () => contactsApi.testContrack(v.url, v.companyToken || undefined),
});
const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
setV({ ...v, [k]: e.target.value });
return (
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
'contacts.provider': provider,
...(provider === 'contrack'
? {
'contacts.contrack': {
url: v.url,
publicUrl: v.publicUrl,
companyToken: v.companyToken || undefined,
},
}
: {}),
});
setV({ ...v, companyToken: '' });
}}
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Where contacts live</legend>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="provider"
checked={provider === 'builtin'}
onChange={() => setProvider('builtin')}
/>
<span>
<span className="font-medium">Simple</span>: a phonebook inside SelfPhoneNG. Names,
numbers, .vcf and CSV import, sharing. Nothing else to run.
</span>
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="radio"
name="provider"
checked={provider === 'contrack'}
onChange={() => setProvider('contrack')}
/>
<span>
<span className="font-medium">Contrack</span>: a full contact and relationship manager
next to the phone system (notes, reminders, relationship tracking, imports from Google,
Apple and LinkedIn, CardDAV sync). Each person has their own Contrack account, and the
company has one everyone sees. It runs as its own container:{' '}
<code>docker compose --env-file .env -f deploy/contrack/compose.yml up -d</code>.
</span>
</label>
</fieldset>
{provider === 'contrack' && (
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Contrack</legend>
<Field
label="Address the phone system uses"
hint="On this server or the local network: localhost or a local IP"
>
{(a) => <input {...a} className={inputClass} value={v.url} onChange={set('url')} />}
</Field>
<Field
label="Address people open"
hint="Its own host name, such as https://contacts.example.com (CONTRACK_HOSTNAME in .env)"
>
{(a) => (
<input
{...a}
className={inputClass}
placeholder="https://contacts.example.com"
value={v.publicUrl}
onChange={set('publicUrl')}
/>
)}
</Field>
<Field
label="Company account token"
hint={
ctk?.hasCompanyToken
? 'Saved. Paste a new one to replace it.'
: 'Make a Contrack account for the company (such as company@…), then in its Settings → Account → API tokens, create one that never expires.'
}
>
{(a) => (
<input
{...a}
type="password"
autoComplete="off"
placeholder={ctk?.hasCompanyToken ? '••••••••' : 'ctk_…'}
className={inputClass}
value={v.companyToken}
onChange={set('companyToken')}
/>
)}
</Field>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => test.mutate()} disabled={test.isPending}>
Test
</Button>
{test.data && (
<span
role="status"
className={`text-sm ${test.data.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}
>
{test.data.ok ? test.data.detail : test.data.error}
</span>
)}
</div>
<p className="text-xs text-zinc-500">
Each person then connects their own Contrack account in My settings, Contacts. If the AI
inside Contrack uses a cloud provider, what is stored there reaches that provider: turn
it off in Contrack, or point it at your local AI.
</p>
</fieldset>
)}
{save.isError && <Alert>{save.error.message}</Alert>}
{save.isSuccess && (
<p role="status" className="text-sm text-green-700 dark:text-green-400">
Saved.
</p>
)}
<div>
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
</div>
</form>
);
}
+37 -4
View File
@@ -28,7 +28,7 @@ export function useContactName() {
const list = useQuery({ queryKey: key(MINE), queryFn: () => contactsApi.list(MINE) });
const names = useMemo(() => {
const m = new Map<string, string>();
for (const c of list.data ?? [])
for (const c of list.data?.items ?? [])
for (const n of c.numbers) if (!m.has(n.number)) m.set(n.number, c.name);
return m;
}, [list.data]);
@@ -94,14 +94,16 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
const words = q.trim().toLowerCase();
const digits = q.replace(/\D/g, '');
// ponytail: the whole book is loaded and filtered here; page it on the server if books get huge.
const shown = (list.data ?? []).filter(
const ctk = list.data?.contrack ?? null;
const shown = (list.data?.items ?? []).filter(
(c) =>
!words ||
`${c.name} ${c.company ?? ''} ${c.email ?? ''}`.toLowerCase().includes(words) ||
(digits.length >= 3 && c.numbers.some((n) => n.number.includes(digits))),
);
const canShare = base === MINE;
const mine = (c: ContactDto) => base === COMPANY || c.book === 'mine';
// Contrack mode: contacts are changed in Contrack, so rows link there (11 §4.3).
const mine = (c: ContactDto) => !c.url && (base === COMPANY || c.book === 'mine');
return (
<section aria-labelledby="contacts-h" className="flex flex-col gap-3">
@@ -123,7 +125,20 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
>
Add contact
</Button>
<Button onClick={() => setImporting(true)}>Import</Button>
{!ctk ? (
<Button onClick={() => setImporting(true)}>Import</Button>
) : (
ctk.publicUrl && (
<a
href={ctk.publicUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
>
Import in Contrack
</a>
)
)}
{canShare && (
<Button disabled={!picked.size} onClick={() => setSharing(true)}>
Share{picked.size ? ` (${picked.size})` : ''}
@@ -136,6 +151,14 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
</p>
)}
{list.error && <Alert>{list.error.message}</Alert>}
{ctk && !ctk.connected && (
<Alert>
{ctk.error ??
(base === MINE
? 'Your contacts are in Contrack: connect your account in Settings → Contacts to see and add them.'
: 'The company Contrack account has no token yet: Settings → Contacts.')}
</Alert>
)}
{list.data && shown.length === 0 && (
<p className="text-sm text-zinc-500">
{q ? 'No contact matches that.' : 'No contacts yet.'}
@@ -181,6 +204,16 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
</button>
))}
</span>
{c.url && (
<a
href={c.url}
target="_blank"
rel="noreferrer"
className="text-sky-700 underline dark:text-sky-400"
>
Open in Contrack
</a>
)}
{mine(c) && (
<span className="flex gap-1">
<Button variant="ghost" className="px-2 py-1" onClick={() => setEditing(c)}>
+81 -1
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call
// report email.
// report email, my Contrack account (11 §4.3).
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
@@ -63,6 +63,7 @@ function SettingsForms({ me }: { me: MeDto }) {
</section>
{me.voicemailEnabled && <Voicemail me={me} />}
<CallReportForm me={me} />
{me.contrack && <ContrackConnect contrack={me.contrack} />}
<section aria-labelledby="app-h" className="flex flex-col gap-3">
<h2 id="app-h" className="text-lg font-semibold">
Set up a phone app
@@ -302,3 +303,82 @@ function CallReportForm({ me }: { me: MeDto }) {
</section>
);
}
/** Contrack mode (11 §4.3): my own Contrack account, through a personal token I make there. */
function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']> }) {
const qc = useQueryClient();
const [token, setToken] = useState('');
const done = (me: MeDto) => {
qc.setQueryData(['me', 'settings'], me);
setToken('');
void qc.invalidateQueries({ queryKey: ['contacts'] });
};
const connect = useMutation({ mutationFn: () => meApi.connectContrack(token), onSuccess: done });
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
const where = contrack.publicUrl ? (
<a
className="text-sky-700 underline dark:text-sky-400"
href={contrack.publicUrl}
target="_blank"
rel="noreferrer"
>
Contrack
</a>
) : (
'Contrack'
);
return (
<section aria-labelledby="ctk-h" className="flex flex-col gap-3">
<h2 id="ctk-h" className="text-lg font-semibold">
Contacts
</h2>
{contrack.tokenPrefix ? (
<div className="flex flex-wrap items-center gap-3 text-sm">
<span>
{contrack.connected ? 'Connected to your' : 'Your token for'} {where} account (
{contrack.tokenPrefix})
{!contrack.connected && (
<span className="text-red-700 dark:text-red-400">: it doesn't work any more</span>
)}
</span>
<Button onClick={() => disconnect.mutate()} disabled={disconnect.isPending}>
Disconnect
</Button>
</div>
) : (
<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
Expires: Never, and paste it here. Only you see your contacts unless you share them.
</p>
)}
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
connect.mutate();
}}
>
<Field label={contrack.tokenPrefix ? 'Replace the token' : 'Contrack token'}>
{(p) => (
<input
{...p}
type="password"
autoComplete="off"
placeholder="ctk_…"
className={`${inputClass} w-72`}
value={token}
onChange={(e) => setToken(e.target.value)}
/>
)}
</Field>
<Button type="submit" variant="primary" disabled={!token.trim() || connect.isPending}>
Connect
</Button>
</form>
{(connect.error ?? disconnect.error) && (
<Alert>{(connect.error ?? disconnect.error)!.message}</Alert>
)}
</section>
);
}
+7
View File
@@ -8,3 +8,10 @@
reverse_proxy 127.0.0.1:8080
}
}
# Contrack (spec 11 §4), when you run deploy/contrack: uncomment, and set CONTRACK_HOSTNAME in .env
# (its own DNS name; Contrack can't live under a path).
# {$CONTRACK_HOSTNAME} {
# encode zstd gzip
# reverse_proxy 127.0.0.1:3210
# }
+32
View File
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Contrack (spec 11 §4, admin guide: Contacts): the "Robust" contacts, its own stack beside the PBX
# on the same server. Contrack is a separate open source program (AGPL-3.0) we talk to over HTTP.
#
# docker compose --env-file .env -f deploy/contrack/compose.yml up -d
#
# It listens on 127.0.0.1 only: the PBX (host networking) reaches it there, and people reach it
# through the reverse proxy at CONTRACK_HOSTNAME (deploy/caddy/Caddyfile, deploy/nginx).
# Then Settings → Contacts → Contrack, address http://127.0.0.1:3210.
services:
contrack:
# Pinned: SelfPhoneNG is tested against this version's API (decision 0003).
image: ghcr.io/arvarik/contrack:2.0.2
restart: unless-stopped
ports: ['127.0.0.1:3210:3210']
environment:
AUTH_REQUIRED: 'true' # everyone signs in; tokens need it
TRUST_PROXY_HOPS: '1'
PUBLIC_URL: https://${CONTRACK_HOSTNAME:?set CONTRACK_HOSTNAME in .env}
# Contrack's own AI and address lookups are outside SelfPhoneNG's "nothing leaves the
# server" rule. Uncomment to keep everything here (or point its AI at your local Ollama in
# Contrack's Settings → AI):
# AI_DISABLED: 'true'
# GEOCODING_DISABLED: 'true'
volumes:
- contrack-data:/app/data # its database, uploads and its own backups: back this up too
logging:
driver: json-file
options: { max-size: 10m, max-file: '3' }
volumes:
contrack-data:
+16
View File
@@ -45,3 +45,19 @@ server {
proxy_read_timeout 3600s;
}
}
# Contrack (spec 11 §4), when you run deploy/contrack: its own name (it can't live under a path).
# server {
# listen 443 ssl;
# http2 on;
# server_name contacts.example.com;
# ssl_certificate /etc/letsencrypt/live/contacts.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/contacts.example.com/privkey.pem;
# client_max_body_size 50m; # contact imports and attachments
# location / {
# proxy_pass http://127.0.0.1:3210;
# proxy_set_header Host $host;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
+1
View File
@@ -87,6 +87,7 @@ services:
network_mode: host
environment:
PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME:?set in .env}
CONTRACK_HOSTNAME: ${CONTRACK_HOSTNAME:-} # deploy/caddy/Caddyfile, when Contrack runs
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy:/data
+1
View File
@@ -11,6 +11,7 @@ keeping the system safe, legal and up to date.
6. [Updating, backups and monitoring](maintenance.md)
7. [Analog phones (ATAs)](ata.md)
8. [Local AI](ai.md): voicemail transcripts
9. [Contacts](contacts.md): the builtin phonebook, or Contrack
---
+85
View File
@@ -0,0 +1,85 @@
# 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
in the setup wizard or in **Settings → Contacts**:
- **Simple**: a phonebook inside SelfPhoneNG. Nothing else to run.
- **Contrack**: [Contrack](https://github.com/arvarik/contrack), a full contact and relationship
manager, running next to the phone system as its own container.
Everyone has their own contacts, and there are **company contacts** that everyone sees (only
admins change them, in **Admin → Contacts**).
## Simple
- **My Phone → Contacts**: add, edit, search, click a number to call. Calls and voicemails from a
number that isn't a contact have **Add to contacts**.
- **Import**: one or more `.vcf` files (exported from an iPhone, Android, Google Contacts,
Outlook or Apple Contacts) or a CSV with a header row (`name, company, email, label1, number1,
label2, number2, …`). You see what will be added first; contacts already there (same name and
number) are skipped. Up to 5,000 contacts at a time.
- **Share**: tick contacts, **Share**, pick colleagues or everyone. They get "Shared with you" at
the top of their Contacts page and on Home, and choose **Accept** (a copy goes into their
contacts) or **Deny**. Nothing is added to anyone's contacts without their click.
## Contrack
Contrack keeps each person's contacts in their own private Contrack account, and the company's in
a Contrack account of its own. SelfPhoneNG reads them (names on calls, the directory on desk
phones), adds to them ("Add to contacts", accepted shares), and links to them. Editing, importing,
notes and the rest happen in Contrack.
### Set it up
1. Give it a DNS name of its own, such as `contacts.example.com` (Contrack can't live under a
path of the phone system's name), pointing at this server. Set it in `.env`:
```bash
CONTRACK_HOSTNAME=contacts.example.com
```
2. Start it:
```bash
docker compose --env-file .env -f deploy/contrack/compose.yml up -d
```
It listens on `127.0.0.1:3210` only.
3. Put it behind your reverse proxy: with the bundled Caddy, uncomment the Contrack block at the
end of `deploy/caddy/Caddyfile` and restart Caddy; with your own nginx, see the end of
`deploy/nginx/selfphoneng.conf.example`.
4. Open `https://contacts.example.com`. The first visit creates the first (admin) account. Then,
in Contrack's **Settings → Administration → Invitations**, invite each person, and make one
account for the company, such as `company@example.com`.
5. Signed in to Contrack as the company account: **Settings → Account → API tokens → Create
token**. Under **Expires** choose **Never** (or 1 year, and replace it before then): the
default is 90 days, and names stop showing when a token expires. **Read only** is enough;
**Read and write** lets admins add company contacts from SelfPhoneNG.
6. In SelfPhoneNG, **Settings → Contacts → Contrack**: address `http://127.0.0.1:3210`, the
address people open (`https://contacts.example.com`), the company token, **Test**, **Save**.
7. Each person: in Contrack, **Settings → Account → API tokens → Create token**, **Read and
write**, expiring **Never** (or 1 year), then in SelfPhoneNG **My settings → Contacts**, paste
it, **Connect**. My settings says so when a token stops working.
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.
### Privacy
SelfPhoneNG only talks to Contrack on this server or the local network, and keeps the tokens
encrypted. Contrack itself can use AI (cloud providers, or your local Ollama) and look addresses
up on OpenStreetMap: those are Contrack's settings, outside SelfPhoneNG's "nothing leaves the
server" rule. To keep everything here, set `AI_DISABLED` and `GEOCODING_DISABLED` in
`deploy/contrack/compose.yml`, or point Contrack's AI at your local AI in its **Settings →
Administration → AI**.
### Backups
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.
### Going back to Simple
**Settings → Contacts → Simple**. The builtin contacts are still there, and the Contrack tokens
are kept in case you switch back.
+15
View File
@@ -0,0 +1,15 @@
# 0003 — Contrack for "Robust" contacts
Owner, 2026-10-23: contacts come in two kinds (spec 11), and the robust one is
[Contrack](https://github.com/arvarik/contrack), a self-hosted contact and relationship manager.
It already does what a phonebook would take years to grow into: notes, timelines, reminders,
relationship tracking, duplicate merging, search, imports from Google, Apple and LinkedIn, and
CardDAV sync. It's allowed because it is open source (AGPL-3.0-only) and runs as **its own
program** (`deploy/contrack/compose.yml`, image pinned to 2.0.2) that SelfPhoneNG talks to over
HTTP on 127.0.0.1: none of its code is in our dependency tree, and there is no license mixing.
The risk is a young project with one maintainer: we use seven of its routes (`/healthz`,
`/api/contacts` list and create, and in C5 the timeline routes), pin the version, and
`apps/api/test/contrack.test.ts` keeps a fake of exactly those routes, and
`apps/api/test/contrack-live.test.ts` runs the same flows against a real Contrack
(`CONTRACK_LIVE_URL`, a fresh container; passed against 2.0.2 on 2026-10-23). A new Contrack
version is a deliberate update: read its CHANGELOG, run the live test against it, bump the tag.
+2 -1
View File
@@ -76,7 +76,8 @@ create table users (
totp_recovery_hashes text[] not null default '{}', -- sha256 of unused recovery codes (M11)
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
last_login_at timestamptz,
contrack_token_enc text -- their Contrack personal token (11 §4.3)
);
create table sessions (
+27 -15
View File
@@ -170,8 +170,10 @@ against the pinned version's `openapi.json`.
volume, and `AUTH_REQUIRED=true`, `TRUST_PROXY_HOPS=1`, `PUBLIC_URL=https://${CONTRACK_HOSTNAME}`.
- People open Contrack on **its own host name** (`PUBLIC_URL` takes no path), e.g.
`contacts.example.com`. The Caddyfile gets a second site block when `CONTRACK_HOSTNAME` is set;
the nginx example gets a second server block.
- The PBX reaches it at `http://127.0.0.1:3210`. `contacts.contrackUrl` must pass the same
the nginx example gets a second server block. As built: both are commented blocks to uncomment
(a Caddy site with an empty name would fail), and `docker compose --env-file .env -f
deploy/contrack/compose.yml up -d` (a compose file in a subfolder reads no `.env` by itself).
- The PBX reaches it at `http://127.0.0.1:3210`. `contacts.contrack.url` must pass the same
local-address check as the AI endpoints (10 §1), so contact data and tokens stay on the network.
- Backups: Contrack keeps its own snapshots in its volume. The SelfPhoneNG backup (pg_dump) does
not include it; the admin guide says to back up the volume too.
@@ -182,25 +184,35 @@ against the pinned version's `openapi.json`.
### 4.3 Connecting users
- Admin, Settings → Contacts: provider, Contrack URL, **Test** (`/healthz`, then the token below
against `/api/contacts?view=slim&limit=1`).
- Admin, Settings → Contacts (`contacts.provider`, `contacts.contrack` { url, publicUrl,
companyTokenEnc }): **Test** (`POST /api/contacts/contrack/test`: `/healthz`, then the token
against `/api/contacts?view=slim`; Contrack's `/api/auth/me` refuses tokens, so a token is
checked by reading contacts).
- Admin, same page: the **company Contrack account's token** (read-only is enough; read-write lets
"Add to company contacts" work), in `contacts.companyToken` {enc}. The admin makes that account
in Contrack (e.g. `company@…`) and fills it like any other.
"Add to company contacts" work), write-only like the SMTP password (`hasCompanyToken` in GET).
The admin makes that account in Contrack (e.g. `company@…`) and fills it like any other.
- Contrack tokens expire in 90 days unless made with **Never**: the guide and the forms say to pick
Never (or a year).
- Each user, My settings → Contacts: **paste a Contrack personal token** (read-write; read-only
allowed, then nothing is written to the timeline). Stored with `encrypt()` in
`extensions.contrack_token_enc`. Never logged, never returned (only "connected as …, token
ctk_AbCd…").
allowed, then nothing is written to the timeline). `PUT /api/me/contrack { token }` checks it
reads contacts, then stores it with `encrypt()` in `users.contrack_token_enc` (contacts are a
person's, not an extension's); `DELETE` disconnects. Never logged, never returned: `GET /api/me`
has `contrack: { connected, tokenPrefix: "ctk_AbCdEfGh…", publicUrl }`.
- Why not have the PBX create Contrack accounts: Contrack's admin routes refuse tokens
(`SESSION_REQUIRED`), so the PBX would have to keep an admin password. A pasted token is
explicit, revocable from Contrack, and scoped to one person.
- **Lookup cache**: per connected user and for the company account, `GET /api/contacts?view=slim`, every phone through
`normalizeNumber`, held in memory as `Map<number, ContactHit>`. Refreshed every 5 minutes, at
start, and right after SelfPhoneNG creates a contact. A user whose token fails (401) is marked
"disconnected" in My settings and skipped; no error email.
- **Lookup cache** (`contacts/contrack.ts`): per connected user and for the company account,
`GET /api/contacts?view=slim`, every phone through `normalizeNumber` (text that isn't a number
is left out), held in memory. As built: fetched on use when older than 5 minutes (no timer),
forgotten when SelfPhoneNG adds a contact or the token changes. A failing Contrack keeps the
last good list and the error, and is tried again 5 minutes later; a refused token (401) shows as
"doesn't work any more" in My settings. No error email.
- `/me/contacts` in this mode: search and list from Contrack (read-only rows, "Open in Contrack"
link to `${PUBLIC_URL}/contacts/:id`), plus the company book. "Add to contacts" does
`POST /api/contacts { name, phones: [number] }`. Editing happens in Contrack.
link to `${publicUrl}/contact/:id`), plus the company book. "Add to contacts" does
`POST /api/contacts { name, company, emails, phones: [{ phone, label }] }`. Editing, deleting
and importing answer 409 in this mode: they happen in Contrack. Desk phone directories (07) get
the company account's contacts. Accepting a share creates the copy with the recipient's token;
without one it answers 409 and the offer waits.
### 4.4 Calls on the Contrack timeline
+12 -1
View File
@@ -40,9 +40,20 @@ export type ContactData = {
};
export type ContactDto = ContactData & {
/** A uuid, or a Contrack contact id such as jane-smith-f60e8536. */
id: string;
/** mine: I edit it; company: read-only for users (admins edit it in Admin → Contacts). */
book: 'mine' | 'company';
/** Contrack: the contact's page there (editing happens in Contrack). */
url: string | null;
};
/** GET /api/me/contacts and /api/contacts: the book(s), and where they live. */
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;
};
/** POST …/contacts/import: the files' text, read in the browser. */
@@ -66,7 +77,7 @@ export type ContactImportResult = {
export const ContactShareSchema = z
.object({
contactIds: z.array(z.uuid()).min(1).max(500),
contactIds: z.array(z.string().min(1).max(100)).min(1).max(500),
/** Colleagues by extension number. */
extensions: z.array(z.string().max(20)).max(500).default([]),
everyone: z.boolean().default(false),
+2
View File
@@ -55,4 +55,6 @@ export type MeDto = {
callReportTime: string;
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;
};
+29 -2
View File
@@ -104,6 +104,18 @@ export const SettingsSchemas = {
'ai.callTranscripts': z.boolean().default(false),
/** …and summarized by ai.llm. */
'ai.callSummaries': z.boolean().default(false),
/** Contacts (spec 11): the builtin books, or Contrack. */
'contacts.provider': z.enum(['builtin', 'contrack']).default('builtin'),
/** Contrack (11 §4.2): `url` the api calls (local only), `publicUrl` the one people open. */
'contacts.contrack': z
.object({
url: z.string().min(1),
publicUrl: z.string().nullable(),
/** The company account's personal token (11 §4.3), encrypted; '' = none. */
companyTokenEnc: z.string(),
})
.nullable()
.default(null),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};
@@ -122,11 +134,26 @@ export const SmtpInputSchema = z.object({
});
export type SmtpInput = z.input<typeof SmtpInputSchema>;
/** GET /api/settings: every key, secrets replaced by hasPassword. */
/** PATCH /api/settings `contacts.contrack`: the company token is write-only (empty keeps it). */
export const ContrackInputSchema = z.object({
url: z.url({ protocol: /^https?$/ }).trim(),
publicUrl: z
.url({ protocol: /^https?$/ })
.trim()
.nullish()
.or(z.literal(''))
.transform((s) => s?.replace(/\/+$/, '') || null),
companyToken: z.string().trim().max(200).optional(),
clearCompanyToken: z.boolean().optional(),
});
export type ContrackInput = z.input<typeof ContrackInputSchema>;
/** GET /api/settings: every key, secrets replaced by hasPassword / hasCompanyToken. */
export type SettingsDto = {
[K in Exclude<SettingKey, 'smtp' | 'sms.webhookToken'>]: SettingValue<K>;
[K in Exclude<SettingKey, 'smtp' | 'sms.webhookToken' | 'contacts.contrack'>]: SettingValue<K>;
} & {
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
};
/** POST /api/ai/test: one endpoint tried with a tiny request. `audio`: the TTS test, a data: URL. */