contacts: Contrack as the robust contacts (C3), picked in Settings or the setup wizard
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:
1 parent
a754974a90
commit
692dd59552
32 files changed
+5787
-120
No files matched your search
+110
-28
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
|
||||
callReportTime: '08:00',
|
||||
callReportWeekday: 1,
|
||||
callReportSummaries: false,
|
||||
contrack: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user