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

+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>
);
}