Merge pull request 'Feat/contacts' (#2) from feat/contacts into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
commit
53aefaa5d7
103 files changed
+18470
-72
No files matched your search
@@ -7,6 +7,7 @@ INITIAL_ADMIN_EMAIL=admin@example.com
|
||||
INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet
|
||||
|
||||
# --- optional ---
|
||||
CONTRACK_HOSTNAME= # Contrack contacts (spec 11 §4): its own DNS name, e.g. contacts.example.com
|
||||
TZ=America/Chicago
|
||||
API_PORT=8080
|
||||
POSTGRES_PORT=5433
|
||||
|
||||
@@ -27,6 +27,7 @@ write the software around it: database, config generator, API, web UI.
|
||||
| `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning |
|
||||
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
|
||||
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
|
||||
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
|
||||
|
||||
## Hard rules
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import { liveCallRoutes } from './routes/livecalls.ts';
|
||||
import { integrationRoutes } from './routes/integrations.ts';
|
||||
import { reportRoutes } from './routes/reports.ts';
|
||||
import { aboutRoutes } from './routes/about.ts';
|
||||
import { contactRoutes } from './routes/contacts.ts';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
@@ -146,6 +147,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
|
||||
app.register(settingsRoutes);
|
||||
app.register(liveRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(contactRoutes);
|
||||
app.register(soundRoutes);
|
||||
app.register(ringGroupRoutes);
|
||||
app.register(queueRoutes);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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 ContactCopyResult,
|
||||
type ContactData,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { contacts } from '../db/schema.ts';
|
||||
import { HttpError, badRequest } from '../errors.ts';
|
||||
import {
|
||||
ContrackError,
|
||||
contrackBook,
|
||||
contrackCreate,
|
||||
contrackBulk,
|
||||
contrackSettings,
|
||||
needToken,
|
||||
type Contrack,
|
||||
} from './contrack.ts';
|
||||
|
||||
/** null = the company book. */
|
||||
export type Owner = string | null;
|
||||
const inBook = (owner: Owner) =>
|
||||
owner ? eq(contacts.ownerUserId, owner) : isNull(contacts.ownerUserId);
|
||||
|
||||
type Row = typeof contacts.$inferSelect;
|
||||
export const toDto = (r: Row): ContactDto => ({
|
||||
id: r.id,
|
||||
book: r.ownerUserId ? 'mine' : 'company',
|
||||
name: r.name,
|
||||
company: r.company,
|
||||
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'] = [];
|
||||
for (const n of c.numbers)
|
||||
try {
|
||||
const number = normalizeNumber(n.number);
|
||||
if (!out.some((o) => o.number === number)) out.push({ label: n.label, number });
|
||||
} catch {
|
||||
if (!bad) throw badRequest(`"${n.number}" isn't a phone number`);
|
||||
bad(n.number);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
toCopy:
|
||||
own.book && !own.book.error
|
||||
? (await notInContrack(db, userId, own.book.contacts)).length
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** This book's builtin contacts that its Contrack account doesn't have yet. */
|
||||
async function notInContrack(db: Db, owner: Owner, have: ContactDto[]): Promise<ContactData[]> {
|
||||
const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
|
||||
return rows.filter((r) => !have.some((h) => isDuplicate(h, r)));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet,
|
||||
* in one Contrack import. The builtin ones stay, for switching back.
|
||||
*/
|
||||
export async function copyToContrack(db: Db, owner: Owner): Promise<ContactCopyResult> {
|
||||
const c = await contrackSettings(db);
|
||||
if (!c)
|
||||
throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)');
|
||||
const b = await contrackBook(db, c, owner);
|
||||
if (!b) asHttp(needToken(owner));
|
||||
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
|
||||
const all = (await db.select().from(contacts).where(inBook(owner))).length;
|
||||
const fresh = await notInContrack(db, owner, b!.contacts);
|
||||
if (!fresh.length) return { added: 0, duplicates: all, failed: 0 };
|
||||
const r = await contrackBulk(db, c, owner, fresh).catch(asHttp);
|
||||
return { added: r.count, duplicates: all - fresh.length, failed: r.failed };
|
||||
}
|
||||
|
||||
/** One contact added by hand (or from a call): a row, or a Contrack contact. */
|
||||
export async function createContact(db: Db, owner: Owner, d: ContactData): Promise<ContactDto> {
|
||||
const clean = { ...d, numbers: cleanNumbers(d) };
|
||||
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. */
|
||||
const sameKey = (c: ContactData) => c.name.trim().toLowerCase();
|
||||
const isDuplicate = (a: ContactData, b: ContactData) =>
|
||||
sameKey(a) === sameKey(b) &&
|
||||
((!a.numbers.length && !b.numbers.length) ||
|
||||
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): an import or accepted shares.
|
||||
* With `commit` false it only counts. Bad numbers are dropped and reported.
|
||||
*/
|
||||
export async function addToBook(
|
||||
db: Db,
|
||||
owner: Owner,
|
||||
list: ContactData[],
|
||||
commit: boolean,
|
||||
errors: string[] = [],
|
||||
): Promise<ContactImportResult> {
|
||||
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 d of list) {
|
||||
const numbers = cleanNumbers(d, (n) =>
|
||||
errors.push(`${d.name}: "${n}" isn't a phone number, left out`),
|
||||
);
|
||||
const clean = { ...d, numbers };
|
||||
if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
|
||||
else fresh.push(clean);
|
||||
}
|
||||
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((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). userId null: the company book only.
|
||||
*/
|
||||
export async function contactNames(
|
||||
db: Db,
|
||||
userId: string | null,
|
||||
numbers: (string | null)[],
|
||||
opts: { wait?: boolean } = {},
|
||||
): Promise<Map<string, string>> {
|
||||
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, opts) : null,
|
||||
await contrackBook(db, c, null, opts),
|
||||
];
|
||||
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
|
||||
from ${contacts} c, jsonb_array_elements(c.numbers) n
|
||||
where ${userId ? sql`(c.owner_user_id = ${userId} or c.owner_user_id is null)` : sql`c.owner_user_id is null`}
|
||||
and n->>'number' in ${want}`);
|
||||
for (const r of rows.sort((a, b) => Number(b.mine) - Number(a.mine)))
|
||||
if (!out.has(r.number)) out.set(r.number, r.name);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Caller names on desk phones while they ring (11 §6 C4): before ringing a user's phones, the
|
||||
// dialplan asks GET /api/internal/caller-name (func_curl, 2 s at most) for that user's name for
|
||||
// the caller, and the phones show it.
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** The dialplan's key for the route, derived from APP_SECRET (stable across restarts). */
|
||||
export const callerNameToken = (appSecret: string) =>
|
||||
createHmac('sha256', appSecret).update('selfphoneng caller-name').digest('hex');
|
||||
|
||||
export const sameToken = (a: string, b: string) =>
|
||||
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
/**
|
||||
* What a phone can show and the dialplan can carry: letters, digits, spaces and . ' & @ -, at
|
||||
* most 40 characters. Nothing that Dial's options (, ^ ( ) |) or a SIP header (" < > \ ;) could
|
||||
* read as syntax.
|
||||
*/
|
||||
export const phoneSafeName = (name: string) =>
|
||||
name
|
||||
.normalize('NFC')
|
||||
.replace(/[^\p{L}\p{M}\p{N} .'&@-]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
.trim();
|
||||
@@ -0,0 +1,265 @@
|
||||
// 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 { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
|
||||
import { AiError, assertLocalUrl } from '../ai/client.ts';
|
||||
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: {
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
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 ?? '');
|
||||
|
||||
const inflight = new Map<string, Promise<Book>>();
|
||||
|
||||
/**
|
||||
* 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. `wait: false` (a phone ringing, 11 §6 C4)
|
||||
* never waits on Contrack: it answers what the cache has and refreshes in the background.
|
||||
*/
|
||||
export async function contrackBook(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
{ now = Date.now(), wait = true }: { now?: number; wait?: boolean } = {},
|
||||
): Promise<Book | null> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) return null;
|
||||
const key = owner ?? '';
|
||||
const hit = cache.get(key);
|
||||
const usable = hit && hit.token === token ? hit : undefined;
|
||||
if (usable && now - usable.at < TTL) return usable;
|
||||
if (!inflight.has(key))
|
||||
inflight.set(
|
||||
key,
|
||||
fetchBook(c, owner, token, usable, now).finally(() => inflight.delete(key)),
|
||||
);
|
||||
if (!wait) return usable ?? null;
|
||||
return inflight.get(key)!;
|
||||
}
|
||||
|
||||
async function fetchBook(
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
token: string,
|
||||
hit: Book | undefined,
|
||||
now: number,
|
||||
): Promise<Book> {
|
||||
const key = owner ?? '';
|
||||
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?.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');
|
||||
}
|
||||
|
||||
/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */
|
||||
export async function contrackBulk(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
list: ContactData[],
|
||||
): Promise<{ count: number; failed: number }> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) throw needToken(owner);
|
||||
const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Import-Id': randomUUID() },
|
||||
body: JSON.stringify(
|
||||
list.slice(0, 5000).map((d) => ({
|
||||
name: d.name,
|
||||
...(d.company ? { company: d.company } : {}),
|
||||
...(d.email ? { emails: [{ email: d.email }] } : {}),
|
||||
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
|
||||
})),
|
||||
),
|
||||
});
|
||||
forgetBook(owner);
|
||||
return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 };
|
||||
}
|
||||
|
||||
/** A timeline entry on a contact (11 §4.4); answers its id. */
|
||||
export async function contrackInteraction(
|
||||
c: Contrack,
|
||||
token: string,
|
||||
contactId: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const r = await ctk<{ id: string }>(
|
||||
c.url,
|
||||
token,
|
||||
`api/contacts/${encodeURIComponent(contactId)}/interactions`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
);
|
||||
return r.id;
|
||||
}
|
||||
|
||||
/** 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,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// CSV to contacts (04 §7.4): name,company,email,label1,number1,label2,number2,… with a header row
|
||||
// (any order, any case; notes too).
|
||||
import type { ContactData } from '@selfphoneng/shared';
|
||||
|
||||
/** RFC 4180 rows (blank ones too): quoted cells may hold commas, quotes ("") and line breaks. */
|
||||
export function csvRows(input: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = '';
|
||||
let quoted = false;
|
||||
const s = input.replace(/^\uFEFF/, '');
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i]!;
|
||||
if (quoted) {
|
||||
if (c === '"' && s[i + 1] === '"') {
|
||||
cell += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else cell += c;
|
||||
} else if (c === '"') quoted = true;
|
||||
else if (c === ',') {
|
||||
row.push(cell);
|
||||
cell = '';
|
||||
} else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && s[i + 1] === '\n') i++;
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
cell = '';
|
||||
} else cell += c;
|
||||
}
|
||||
if (cell || row.length) rows.push([...row, cell]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function parseCsv(input: string): { contacts: ContactData[]; errors: string[] } {
|
||||
const [header, ...rows] = csvRows(input);
|
||||
if (!header) return { contacts: [], errors: [] };
|
||||
const col = new Map(header.map((h, i) => [h.trim().toLowerCase(), i]));
|
||||
if (!col.has('name'))
|
||||
return { contacts: [], errors: ['The first row must name the columns, with a "name" column'] };
|
||||
const contacts: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
rows.forEach((r, i) => {
|
||||
if (!r.some((c) => c.trim())) return;
|
||||
const get = (k: string) => {
|
||||
const at = col.get(k);
|
||||
return at === undefined ? '' : (r[at] ?? '').trim();
|
||||
};
|
||||
const numbers: ContactData['numbers'] = [];
|
||||
for (let n = 1; col.has(`number${n}`); n++)
|
||||
if (get(`number${n}`)) numbers.push({ label: get(`label${n}`), number: get(`number${n}`) });
|
||||
const name = get('name');
|
||||
if (!name) {
|
||||
errors.push(`Row ${i + 2}: no name, skipped`);
|
||||
return;
|
||||
}
|
||||
contacts.push({
|
||||
name,
|
||||
company: get('company') || null,
|
||||
email: get('email') || null,
|
||||
numbers,
|
||||
notes: get('notes') || null,
|
||||
});
|
||||
});
|
||||
return { contacts, errors };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C5: what a call looks like on a Contrack timeline (11 §4.4).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { splitSummary, timelineEntry, type TimelineCall } from './timeline.ts';
|
||||
|
||||
const call = (c: Partial<TimelineCall>): TimelineCall => ({
|
||||
out: false,
|
||||
status: 'answered',
|
||||
fromNumber: '13125551234',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 192,
|
||||
answeredBy: null,
|
||||
recorded: false,
|
||||
...c,
|
||||
});
|
||||
|
||||
describe('timelineEntry', () => {
|
||||
it('titles calls from where the person sat', () => {
|
||||
const t = (c: Partial<TimelineCall>) => timelineEntry(call(c), null).title;
|
||||
expect(t({})).toBe('Incoming call');
|
||||
expect(t({ answeredBy: 'Sue Smith' })).toBe('Incoming call, answered by Sue Smith');
|
||||
expect(t({ status: 'missed', talkSeconds: 0 })).toBe('Missed call');
|
||||
expect(t({ status: 'voicemail', talkSeconds: 0 })).toBe('Missed call, voicemail left');
|
||||
expect(t({ out: true })).toBe('Outgoing call');
|
||||
expect(t({ out: true, status: 'busy', talkSeconds: 0 })).toBe('Outgoing call, busy');
|
||||
expect(t({ out: true, status: 'failed', talkSeconds: 0 })).toBe('Outgoing call, no answer');
|
||||
});
|
||||
|
||||
it('writes the facts, the summary and its action items as escaped HTML', () => {
|
||||
const e = timelineEntry(
|
||||
call({ recorded: true }),
|
||||
'Acme <wants> a quote & a call back.\nAction items:\n- Dan sends the quote\n- Sue books a visit',
|
||||
);
|
||||
expect(e).toEqual({
|
||||
title: 'Incoming call',
|
||||
content:
|
||||
'<p>From (312) 555-1234 to (312) 555-0000 · 3:12 · recorded</p>' +
|
||||
'<p>Acme <wants> a quote & a call back.</p>' +
|
||||
'<p>Action items:</p><ul><li>Dan sends the quote</li><li>Sue books a visit</li></ul>',
|
||||
duration: 4,
|
||||
firstItem: 'Dan sends the quote',
|
||||
});
|
||||
const missed = timelineEntry(call({ status: 'missed', talkSeconds: 0 }), null);
|
||||
expect([missed.content, missed.duration, missed.firstItem]).toEqual([
|
||||
'<p>From (312) 555-1234 to (312) 555-0000</p>',
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(timelineEntry(call({ out: true }), null).content).toBe(
|
||||
'<p>To (312) 555-0000 · 3:12</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads "Action items: none" as none', () => {
|
||||
expect(splitSummary('Wrong number.\nAction items: none')).toEqual({
|
||||
text: ['Wrong number.'],
|
||||
items: [],
|
||||
});
|
||||
expect(splitSummary('Just chatted.')).toEqual({ text: ['Just chatted.'], items: [] });
|
||||
expect(splitSummary('X.\nAction items:\n- none')).toEqual({ text: ['X.'], items: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Calls on the Contrack timeline (11 §4.4): each call of a connected person with one of their
|
||||
// Contrack contacts becomes a "call" interaction on that contact, with the recorded call's
|
||||
// summary if they asked for it. Unknown numbers are not added to Contrack.
|
||||
import { and, asc, eq, gt, isNotNull, lt, ne, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { formatNumber } from '@selfphoneng/shared';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contrackCallLog,
|
||||
extensions,
|
||||
recordings,
|
||||
users,
|
||||
} from '../db/schema.ts';
|
||||
import { todayIn } from '../generator/load.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
|
||||
|
||||
/** A recorded call waits this long for its summary before it's logged without one. */
|
||||
const SUMMARY_WAIT_MS = 30 * 60_000;
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'an unknown number');
|
||||
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
|
||||
export type TimelineCall = {
|
||||
out: boolean;
|
||||
status: string;
|
||||
fromNumber: string | null;
|
||||
toNumber: string | null;
|
||||
talkSeconds: number;
|
||||
/** Someone else answered (a ring group or queue colleague). */
|
||||
answeredBy: string | null;
|
||||
recorded: boolean;
|
||||
};
|
||||
|
||||
/** "Action items:" lines of a summary (10 §3), and the text before them. */
|
||||
export function splitSummary(summary: string): { text: string[]; items: string[] } {
|
||||
const lines = summary
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const at = lines.findIndex((l) => /^action items:/i.test(l));
|
||||
if (at < 0) return { text: lines, items: [] };
|
||||
return {
|
||||
text: lines.slice(0, at),
|
||||
items: lines
|
||||
.slice(at + 1)
|
||||
.filter((l) => /^[-*•]\s+/.test(l))
|
||||
.map((l) => l.replace(/^[-*•]\s+/, ''))
|
||||
.filter((l) => !/^none\.?$/i.test(l)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The interaction for one call: title, HTML content, minutes, the first action item. */
|
||||
export function timelineEntry(c: TimelineCall, summary: string | null) {
|
||||
const missed = ['missed', 'voicemail', 'abandoned'].includes(c.status);
|
||||
const title = c.out
|
||||
? c.status === 'answered'
|
||||
? 'Outgoing call'
|
||||
: `Outgoing call, ${c.status === 'busy' ? 'busy' : 'no answer'}`
|
||||
: c.status === 'voicemail'
|
||||
? 'Missed call, voicemail left'
|
||||
: missed || c.status !== 'answered'
|
||||
? 'Missed call'
|
||||
: c.answeredBy
|
||||
? `Incoming call, answered by ${c.answeredBy}`
|
||||
: 'Incoming call';
|
||||
const facts = [
|
||||
c.out ? `To ${num(c.toNumber)}` : `From ${num(c.fromNumber)} to ${num(c.toNumber)}`,
|
||||
...(c.talkSeconds ? [mmss(c.talkSeconds)] : []),
|
||||
...(c.recorded ? ['recorded'] : []),
|
||||
];
|
||||
const parts = [`<p>${esc(facts.join(' · '))}</p>`];
|
||||
const s = summary ? splitSummary(summary) : null;
|
||||
if (s?.text.length) parts.push(...s.text.map((l) => `<p>${esc(l)}</p>`));
|
||||
if (s?.items.length)
|
||||
parts.push(`<p>Action items:</p><ul>${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>`);
|
||||
return {
|
||||
title,
|
||||
content: parts.join(''),
|
||||
duration: c.talkSeconds ? Math.ceil(c.talkSeconds / 60) : null,
|
||||
firstItem: s?.items[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const answerer = alias(extensions, 'answerer');
|
||||
|
||||
/**
|
||||
* One run of the contrackLog job: calls that ended over a minute ago (and within 2 days), of
|
||||
* people who connected Contrack and log calls, not looked at yet. Contrack down: tried again on
|
||||
* the next run.
|
||||
*/
|
||||
export async function logCallsToContrack(d: {
|
||||
db: Db;
|
||||
log: { error(o: object, m: string): void };
|
||||
now?: Date;
|
||||
}): Promise<number> {
|
||||
const c = await contrackSettings(d.db);
|
||||
if (!c) return 0;
|
||||
const now = d.now ?? new Date();
|
||||
const tz = await getSetting(d.db, 'general.timezone');
|
||||
const summariesOn =
|
||||
(await getSetting(d.db, 'ai.callTranscripts')) && (await getSetting(d.db, 'ai.callSummaries'));
|
||||
const rows = await d.db
|
||||
.select({
|
||||
call: calls,
|
||||
role: callExtensions.role,
|
||||
userId: users.id,
|
||||
withSummary: users.contrackLogSummaries,
|
||||
tokenEnc: users.contrackTokenEnc,
|
||||
extId: extensions.id,
|
||||
answeredById: answerer.id,
|
||||
answeredBy: answerer.name,
|
||||
summary: recordings.summary,
|
||||
aiDoneAt: recordings.aiDoneAt,
|
||||
})
|
||||
.from(calls)
|
||||
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
|
||||
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
|
||||
.innerJoin(users, eq(users.id, extensions.userId))
|
||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
|
||||
.where(
|
||||
and(
|
||||
isNotNull(users.contrackTokenEnc),
|
||||
eq(users.contrackLogCalls, true),
|
||||
eq(users.isActive, true),
|
||||
ne(calls.direction, 'internal'),
|
||||
gt(calls.endedAt, users.contrackLogSince),
|
||||
lt(calls.endedAt, new Date(now.getTime() - 60_000)),
|
||||
gt(calls.endedAt, new Date(now.getTime() - 2 * 24 * 3600_000)),
|
||||
sql`not exists (select 1 from ${contrackCallLog} l where l.call_id = ${calls.id} and l.user_id = ${users.id})`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(calls.endedAt))
|
||||
.limit(500);
|
||||
|
||||
// One entry per call and person (a person can be in a call as caller, rang and answered).
|
||||
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
|
||||
for (const r of rows) {
|
||||
const k = `${r.call.id}|${r.userId}`;
|
||||
const out = r.role === 'caller' || todo.get(k)?.out === true;
|
||||
todo.set(k, { ...r, out });
|
||||
}
|
||||
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
|
||||
let logged = 0;
|
||||
for (const r of todo.values()) {
|
||||
if (skipUser.has(r.userId)) continue;
|
||||
const record = (interactionId: string | null, error: string | null = null) =>
|
||||
d.db
|
||||
.insert(contrackCallLog)
|
||||
.values({ callId: r.call.id, userId: r.userId, interactionId, error })
|
||||
.onConflictDoNothing();
|
||||
const book = await contrackBook(d.db, c, r.userId);
|
||||
if (!book || book.error) {
|
||||
skipUser.add(r.userId);
|
||||
continue;
|
||||
}
|
||||
const other = r.out ? r.call.toNumber : r.call.fromNumber;
|
||||
const contact = other
|
||||
? book.contacts.find((x) => x.numbers.some((n) => n.number === other))
|
||||
: undefined;
|
||||
if (!contact) {
|
||||
await record(null);
|
||||
continue;
|
||||
}
|
||||
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
|
||||
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
|
||||
continue; // the transcript job hasn't summarized it yet
|
||||
const e = timelineEntry(
|
||||
{
|
||||
out: r.out,
|
||||
status: r.call.status,
|
||||
fromNumber: r.call.fromNumber,
|
||||
toNumber: r.call.toNumber,
|
||||
talkSeconds: r.call.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
|
||||
recorded: r.call.recordingId !== null,
|
||||
},
|
||||
wantSummary ? r.summary : null,
|
||||
);
|
||||
try {
|
||||
const id = await contrackInteraction(c, decrypt(r.tokenEnc!), contact.id, {
|
||||
type: 'call',
|
||||
title: e.title,
|
||||
content: e.content,
|
||||
date: r.call.startedAt.toISOString(),
|
||||
duration: e.duration,
|
||||
source: 'selfphoneng',
|
||||
...(e.firstItem
|
||||
? {
|
||||
actionItem: {
|
||||
title: e.firstItem.slice(0, 200),
|
||||
dueAt: todayIn(tz, new Date(r.call.startedAt.getTime() + 24 * 3600_000)),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await record(id);
|
||||
logged++;
|
||||
} catch (err) {
|
||||
const status = err instanceof ContrackError ? err.status : undefined;
|
||||
// Refused (bad request, a read-only token, a deleted contact): noted, not retried.
|
||||
if (status && status >= 400 && status < 500 && status !== 401 && status !== 429)
|
||||
await record(null, (err as Error).message);
|
||||
else {
|
||||
skipUser.add(r.userId);
|
||||
d.log.error({ err, user: r.userId }, 'contrack: call not logged yet');
|
||||
}
|
||||
}
|
||||
}
|
||||
return logged;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C1: vCard and CSV imports read what phones and address books export (11 §3.1).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCsv } from './csv.ts';
|
||||
import { parseVcards } from './vcard.ts';
|
||||
|
||||
const IPHONE = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'PRODID:-//Apple Inc.//iPhone OS 17.0//EN',
|
||||
'N:Doe;Jane;;;',
|
||||
'FN:Jane Doe',
|
||||
'ORG:Acme Corp;',
|
||||
'EMAIL;type=INTERNET;type=HOME;type=pref:jane@example.com',
|
||||
'TEL;type=CELL;type=VOICE;type=pref:(312) 555-1234',
|
||||
'item1.TEL:+1 312 555 0000',
|
||||
'item1.X-ABLabel:_$!<Main>!$_',
|
||||
'NOTE:Met at the fair\\, 2026\\nCall after 5',
|
||||
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD',
|
||||
' /2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJ',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const GOOGLE = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Bob Smith
|
||||
N:Smith;Bob;;;
|
||||
TEL;TYPE=WORK:+13125559999
|
||||
TEL;TYPE=HOME:3125558888
|
||||
EMAIL;TYPE=INTERNET:bob@example.com
|
||||
ORG:Widgets Inc.
|
||||
item1.URL:http\\://example.com
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:;;;;
|
||||
TEL;TYPE=CELL:3125550101
|
||||
END:VCARD
|
||||
`;
|
||||
|
||||
const V21 = `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.;
|
||||
TEL;CELL:+49301234567
|
||||
TEL;WORK;FAX:+49301234500
|
||||
NOTE;ENCODING=QUOTED-PRINTABLE:Line one=0D=0ALine tw=
|
||||
o
|
||||
END:VCARD`;
|
||||
|
||||
const V40 = `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Carol Lee
|
||||
TEL;VALUE=uri;TYPE="voice,cell":tel:+1-312-555-7777
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
NOTE:nothing to call
|
||||
END:VCARD`;
|
||||
|
||||
describe('parseVcards', () => {
|
||||
it('reads an iPhone export: names, company, labels, note; skips the photo', () => {
|
||||
expect(parseVcards(IPHONE)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme Corp',
|
||||
email: 'jane@example.com',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '(312) 555-1234' },
|
||||
{ label: 'main', number: '+1 312 555 0000' },
|
||||
],
|
||||
notes: 'Met at the fair, 2026\nCall after 5',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a Google export, and names a card with only a number after it', () => {
|
||||
const { contacts } = parseVcards(GOOGLE);
|
||||
expect(contacts.map((c) => [c.name, c.company, c.numbers])).toEqual([
|
||||
[
|
||||
'Bob Smith',
|
||||
'Widgets Inc.',
|
||||
[
|
||||
{ label: 'work', number: '+13125559999' },
|
||||
{ label: 'home', number: '3125558888' },
|
||||
],
|
||||
],
|
||||
['3125550101', null, [{ label: 'mobile', number: '3125550101' }]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads vCard 2.1: quoted-printable UTF-8, bare types, soft line breaks', () => {
|
||||
const [c] = parseVcards(V21).contacts;
|
||||
expect(c).toMatchObject({
|
||||
name: 'Dr. Jürgen Müller',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '+49301234567' },
|
||||
{ label: 'fax', number: '+49301234500' },
|
||||
],
|
||||
notes: 'Line one\r\nLine two',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads vCard 4.0 tel: URIs, and reports a card with nothing to call', () => {
|
||||
expect(parseVcards(V40)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Carol Lee',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '+1-312-555-7777' }],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Card 2: no name or number, skipped'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('reads the 04 §7.4 columns in any order, with quotes and line breaks', () => {
|
||||
const csv =
|
||||
'Number1,Name,label1,company,notes,number2,label2\r\n' +
|
||||
'312-555-1234,"Doe, Jane",mobile,Acme,"two\nlines",,\r\n' +
|
||||
',,,,,,\r\n' +
|
||||
'3125550000,,work,,,,\r\n' +
|
||||
'+44 20 7946 0958,"Bob ""B"" Smith",,,,101,desk\r\n';
|
||||
expect(parseCsv(csv)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Doe, Jane',
|
||||
company: 'Acme',
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '312-555-1234' }],
|
||||
notes: 'two\nlines',
|
||||
},
|
||||
{
|
||||
name: 'Bob "B" Smith',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [
|
||||
{ label: '', number: '+44 20 7946 0958' },
|
||||
{ label: 'desk', number: '101' },
|
||||
],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Row 4: no name, skipped'],
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a header with a name column', () => {
|
||||
expect(parseCsv('Jane,3125551234\n').errors[0]).toMatch(/"name" column/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and
|
||||
// the note. Everything else (photos, addresses, birthdays) is ignored.
|
||||
import type { ContactData } from '@selfphoneng/shared';
|
||||
|
||||
type Prop = { group: string; name: string; params: Map<string, string[]>; value: string };
|
||||
|
||||
/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */
|
||||
function parseParams(parts: string[]): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const p of parts) {
|
||||
const eq = p.indexOf('=');
|
||||
const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase();
|
||||
const raw = eq < 0 ? p : p.slice(eq + 1);
|
||||
const vals = raw.replace(/"/g, '').split(',').filter(Boolean);
|
||||
// 2.1's bare QUOTED-PRINTABLE is an encoding, not a type.
|
||||
const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key;
|
||||
out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */
|
||||
function parseLine(line: string): Prop | null {
|
||||
let quoted = false;
|
||||
let colon = -1;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === '"') quoted = !quoted;
|
||||
else if (line[i] === ':' && !quoted) {
|
||||
colon = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (colon < 0) return null;
|
||||
const [head = '', ...params] = line.slice(0, colon).split(';');
|
||||
const dot = head.lastIndexOf('.');
|
||||
return {
|
||||
group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(),
|
||||
name: head.slice(dot + 1).toUpperCase(),
|
||||
params: parseParams(params),
|
||||
value: line.slice(colon + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeQp(v: string, charset: string): string {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const hex = v.slice(i + 1, i + 3);
|
||||
if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) {
|
||||
bytes.push(parseInt(hex, 16));
|
||||
i += 2;
|
||||
} else bytes.push(v.charCodeAt(i) & 0xff);
|
||||
}
|
||||
try {
|
||||
return new TextDecoder(charset).decode(new Uint8Array(bytes));
|
||||
} catch {
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */
|
||||
function components(v: string, sep: ';' | ','): string[] {
|
||||
const out = [''];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const c = v[i]!;
|
||||
if (c === '\\' && i + 1 < v.length) {
|
||||
const n = v[++i]!;
|
||||
out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n;
|
||||
} else if (c === sep) out.push('');
|
||||
else out[out.length - 1] += c;
|
||||
}
|
||||
return out.map((s) => s.trim());
|
||||
}
|
||||
const text = (v: string) => components(v, ';').join(';').trim();
|
||||
|
||||
/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */
|
||||
function telLabel(types: string[], abLabel: string | undefined): string {
|
||||
if (abLabel) {
|
||||
const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim();
|
||||
if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase();
|
||||
}
|
||||
if (types.includes('FAX')) return 'fax';
|
||||
if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile';
|
||||
if (types.includes('WORK')) return 'work';
|
||||
if (types.includes('HOME')) return 'home';
|
||||
if (types.includes('MAIN')) return 'main';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Every card in the text, in order. A card with no name, company or number is an error. */
|
||||
export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } {
|
||||
// Unfold: a line starting with a space or tab continues the one before (and 2.1
|
||||
// quoted-printable continues a line that ends with "=").
|
||||
const lines: string[] = [];
|
||||
for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) {
|
||||
const prev = lines.at(-1);
|
||||
if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!))
|
||||
lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, '');
|
||||
else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1);
|
||||
else lines.push(l);
|
||||
}
|
||||
|
||||
const contacts: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
let card: Prop[] | null = null;
|
||||
for (const line of lines) {
|
||||
const p = parseLine(line);
|
||||
if (!p) continue;
|
||||
if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = [];
|
||||
else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) {
|
||||
const c = toContact(card);
|
||||
if (c) contacts.push(c);
|
||||
else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`);
|
||||
card = null;
|
||||
} else if (card) {
|
||||
const enc = p.params.get('ENCODING') ?? [];
|
||||
if (enc.includes('QUOTED-PRINTABLE'))
|
||||
p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8');
|
||||
card.push(p);
|
||||
}
|
||||
}
|
||||
return { contacts, errors };
|
||||
}
|
||||
|
||||
function toContact(props: Prop[]): ContactData | null {
|
||||
const first = (n: string) => props.find((p) => p.name === n)?.value;
|
||||
const fn = first('FN') ? text(first('FN')!) : '';
|
||||
const n = first('N');
|
||||
const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n
|
||||
? components(n, ';')
|
||||
: [];
|
||||
const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' ');
|
||||
const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null;
|
||||
const email = first('EMAIL') ? text(first('EMAIL')!) || null : null;
|
||||
const abLabels = new Map(
|
||||
props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]),
|
||||
);
|
||||
const numbers = props
|
||||
.filter((p) => p.name === 'TEL')
|
||||
.map((p) => ({
|
||||
label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined),
|
||||
number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(),
|
||||
}))
|
||||
.filter((t) => t.number);
|
||||
const note = first('NOTE') ? text(first('NOTE')!) || null : null;
|
||||
const name = fn || fromN || org || numbers[0]?.number;
|
||||
if (!name) return null;
|
||||
return { name, company: org, email, numbers, notes: note };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contact_shares" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"from_user_id" uuid,
|
||||
"to_user_id" uuid NOT NULL,
|
||||
"source_id" text NOT NULL,
|
||||
"contact" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"decided_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_token_enc" text;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contrack_call_log" (
|
||||
"call_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"interaction_id" text,
|
||||
"error" text,
|
||||
"logged_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contrack_call_log_call_id_user_id_pk" PRIMARY KEY("call_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_calls" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "contrack_log_since" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
-- People already connected (C3) start logging now, not with the last 2 days of calls.
|
||||
UPDATE "users" SET "contrack_log_since" = now() WHERE "contrack_token_enc" IS NOT NULL;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -64,6 +64,27 @@
|
||||
"when": 1792781321577,
|
||||
"tag": "0008_call_reports",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1792784242837,
|
||||
"tag": "0009_contact_shares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1792785318384,
|
||||
"tag": "0010_contrack_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1792788393381,
|
||||
"tag": "0011_contrack_call_log",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -63,6 +63,13 @@ 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(),
|
||||
/** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */
|
||||
contrackLogCalls: boolean().notNull().default(true),
|
||||
/** …with each recorded call's summary (10 §3). */
|
||||
contrackLogSummaries: boolean().notNull().default(false),
|
||||
contrackLogSince: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -620,6 +627,49 @@ export const contacts = pgTable('contacts', {
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */
|
||||
export const contrackCallLog = pgTable(
|
||||
'contrack_call_log',
|
||||
{
|
||||
callId: uuid()
|
||||
.notNull()
|
||||
.references(() => calls.id, { onDelete: 'cascade' }),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */
|
||||
interactionId: text(),
|
||||
error: text(),
|
||||
loggedAt: tstz().notNull().defaultNow(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.callId, t.userId] })],
|
||||
);
|
||||
|
||||
/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
|
||||
export const contactShares = pgTable(
|
||||
'contact_shares',
|
||||
{
|
||||
id: id(),
|
||||
fromUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
toUserId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The shared contact's id in the sender's book: offering it again replaces a pending offer. */
|
||||
sourceId: text().notNull(),
|
||||
/** A copy taken when shared: { name, company, email, numbers, notes }. */
|
||||
contact: jsonb().notNull(),
|
||||
status: text().notNull().default('pending'),
|
||||
decidedAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index()
|
||||
.on(t.toUserId)
|
||||
.where(sql`${t.status} = 'pending'`),
|
||||
check('contact_shares_status_check', sql`${t.status} in ('pending', 'accepted', 'denied')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const smsMessages = pgTable(
|
||||
'sms_messages',
|
||||
{
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_103=2
|
||||
SP_CLASS_104=2
|
||||
SP_CLASS_105=2
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_TCADMIN_101=1
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_99=2
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -260,6 +282,7 @@ exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1)
|
||||
exten => s,1,Set(__SP_TARGET=550)
|
||||
same => n,Answer()
|
||||
same => n,Set(CALLERID(name)=Support: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Support: ${SP_CNAM_PREFIX})
|
||||
same => n,Gosub(sp-sub-record,s,1)
|
||||
same => n,Queue(550,t,,,300)
|
||||
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
|
||||
|
||||
+2
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_101=2
|
||||
SP_CLASS_102=2
|
||||
SP_CLASS_103=2
|
||||
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -260,6 +282,7 @@ exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1)
|
||||
[sp-rg-22222222-2222-4222-8222-222222222500]
|
||||
exten => s,1,Set(__SP_TARGET=500)
|
||||
same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=Sales: ${SP_CNAM_PREFIX})
|
||||
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm)
|
||||
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
|
||||
same => n,Goto(sp-vm-direct,101,1)
|
||||
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_1=2
|
||||
SP_CLASS_12=2
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
+2
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=13125551234
|
||||
SP_DEFAULT_E911_CID=13125551234
|
||||
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=http://127.0.0.1:8080/api/internal/caller-name
|
||||
SP_CNAM_TOKEN=0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
|
||||
SP_CLASS_101=2
|
||||
+24
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
@@ -284,6 +306,7 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
|
||||
exten => 13125559876,1,Set(__SP_DID=13125559876)
|
||||
same => n,Set(CDR(SP_DID)=13125559876)
|
||||
same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})
|
||||
same => n,Set(__SP_CNAM_PREFIX=SALES: ${SP_CNAM_PREFIX})
|
||||
same => n,Goto(sp-ext-forward,13125550000,1)
|
||||
; anything else
|
||||
exten => _[0-9a-z].,1,Hangup()
|
||||
|
||||
@@ -117,5 +117,9 @@
|
||||
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
|
||||
}
|
||||
],
|
||||
"recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448"
|
||||
"recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448",
|
||||
"callerNames": {
|
||||
"url": "http://127.0.0.1:8080/api/internal/caller-name",
|
||||
"token": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"
|
||||
}
|
||||
}
|
||||
+2
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
|
||||
SP_DEFAULT_E911_CID=
|
||||
SP_REC_ANNOUNCE=
|
||||
SP_REC_SIDES=
|
||||
SP_CNAM_URL=
|
||||
SP_CNAM_TOKEN=
|
||||
SP_CLASS_99=0
|
||||
SP_CLASS_102=1
|
||||
+23
-1
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -95,7 +95,13 @@ export function renderDids(snap: Snapshot): string {
|
||||
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
|
||||
`exten => ${num},1,Set(__SP_DID=${num})`,
|
||||
` same => n,Set(CDR(SP_DID)=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
// Recorded number: the caller hears the notice (if set) before the recording starts.
|
||||
...(d.recordInbound && snap.recordingAnnouncement
|
||||
? [' same => n,Answer()', ` same => n,Playback(${s.sound(snap.recordingAnnouncement)})`]
|
||||
|
||||
@@ -75,6 +75,9 @@ export function renderGlobals(snap: Snapshot): string {
|
||||
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
|
||||
// Spec 10 §3: also record each side on its own, for the transcript job.
|
||||
`SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`,
|
||||
// 11 §6 C4: where sp-sub-cnam asks for a caller's name in the ringing user's contacts.
|
||||
`SP_CNAM_URL=${snap.callerNames ? s.localUrl(snap.callerNames.url) : ''}`,
|
||||
`SP_CNAM_TOKEN=${snap.callerNames ? s.secret(snap.callerNames.token) : ''}`,
|
||||
];
|
||||
const exts = [...snap.extensions].sort(byNumber);
|
||||
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
|
||||
|
||||
@@ -18,7 +18,13 @@ export function renderQueueDialplan(snap: Snapshot): string {
|
||||
`[sp-queue-${s.ident(q.id)}]`,
|
||||
`exten => s,1,Set(__SP_TARGET=${num})`,
|
||||
' same => n,Answer()',
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []),
|
||||
` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`,
|
||||
` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`,
|
||||
|
||||
@@ -22,7 +22,13 @@ export function renderRingGroups(snap: Snapshot): string {
|
||||
`; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`,
|
||||
`[sp-rg-${s.ident(g.id)}]`,
|
||||
`exten => s,1,Set(__SP_TARGET=${num})`,
|
||||
...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
|
||||
...(prefix
|
||||
? [
|
||||
` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
|
||||
// …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
|
||||
` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
|
||||
]
|
||||
: []),
|
||||
...(g.members.length === 0
|
||||
? []
|
||||
: g.strategy === 'ringall'
|
||||
|
||||
@@ -12,16 +12,38 @@ exten => s,1,Set(N=\${FILTER(0123456789+,\${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web))
|
||||
same => n,GotoIf($["\${DS}" = ""]?unavail)
|
||||
same => n,Dial(\${DS},\${ARG2},tx)
|
||||
same => n,GotoIf($["\${SP_DIRECTION}" != "inbound" | "\${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(\${ARG1}))
|
||||
same => n,GotoIf($["\${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=\${SP_CNAM_PREFIX}\${GOSUB_RETVAL})
|
||||
same => n,Dial(\${DS},\${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(\${DS},\${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(\${CURL(\${SP_CNAM_URL}?ext=\${FILTER(0-9,\${ARG1})}&number=\${FILTER(0-9,\${CALLERID(num)})}&token=\${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=\${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -26,6 +26,7 @@ export function loadCase(name: string): Snapshot {
|
||||
holidays?: Snapshot['holidays'];
|
||||
announcements?: Snapshot['announcements'];
|
||||
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
|
||||
callerNames?: Snapshot['callerNames'];
|
||||
};
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
@@ -34,6 +35,7 @@ export function loadCase(name: string): Snapshot {
|
||||
) as Snapshot['settings'];
|
||||
return {
|
||||
today: raw.today ?? '2026-10-17',
|
||||
...(raw.callerNames ? { callerNames: raw.callerNames } : {}),
|
||||
settings,
|
||||
dids: (raw.dids ?? []).map((d) => ({
|
||||
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
|
||||
|
||||
@@ -103,6 +103,8 @@ export type GeneratorDeps = {
|
||||
debounceMs?: number;
|
||||
/** RTP_PORT_START/END (config.ts). */
|
||||
rtpPorts?: { start: number; end: number };
|
||||
/** The caller-name lookup (11 §6 C4). */
|
||||
callerNames?: { url: string; token: string };
|
||||
};
|
||||
|
||||
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
@@ -167,7 +169,11 @@ export function createGenerator(deps: GeneratorDeps) {
|
||||
restartRequired: false,
|
||||
};
|
||||
try {
|
||||
const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
|
||||
const snap = {
|
||||
...(await loadSnapshot(db)),
|
||||
rtpPorts: deps.rtpPorts,
|
||||
callerNames: deps.callerNames,
|
||||
};
|
||||
const files = render(snap);
|
||||
await mkdir(join(dir, '.prev'), { recursive: true });
|
||||
const current = new Map<GeneratedFileName, string | null>();
|
||||
|
||||
@@ -205,6 +205,8 @@ export type Snapshot = {
|
||||
recordingAnnouncement: string | null;
|
||||
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
|
||||
rtpPorts?: { start: number; end: number };
|
||||
/** Caller names on ringing phones (11 §6 C4): the api's lookup URL and its key. */
|
||||
callerNames?: { url: string; token: string };
|
||||
};
|
||||
|
||||
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
|
||||
|
||||
@@ -29,6 +29,12 @@ export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
|
||||
/** Context-name parts (uuids), MOH class names. */
|
||||
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
|
||||
/** Generated SIP passwords. */
|
||||
/** The api on this machine, e.g. http://127.0.0.1:8080/api/internal/caller-name. */
|
||||
export const localUrl = check(
|
||||
/^http:\/\/(?:127\.0\.0\.1|localhost):[0-9]{1,5}\/[a-z0-9/_-]+$/,
|
||||
'local url',
|
||||
100,
|
||||
);
|
||||
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
|
||||
/** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */
|
||||
export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { watchPinChanges } from './services/vmpin.ts';
|
||||
import { startJobs } from './jobs.ts';
|
||||
import { watchWebhooks } from './services/webhooks.ts';
|
||||
import { watchEvents } from './services/events.ts';
|
||||
import { callerNameToken } from './contacts/callername.ts';
|
||||
|
||||
let config;
|
||||
try {
|
||||
@@ -47,6 +48,10 @@ const generator = createGenerator({
|
||||
ami,
|
||||
dir: resolve(config.ASTERISK_CONFIG_DIR),
|
||||
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
|
||||
callerNames: {
|
||||
url: `http://127.0.0.1:${config.API_PORT}/api/internal/caller-name`,
|
||||
token: callerNameToken(config.APP_SECRET),
|
||||
},
|
||||
log: app.log,
|
||||
onApplied: (r) => {
|
||||
live.emit('message', {
|
||||
|
||||
+32
-2
@@ -3,7 +3,7 @@
|
||||
// ponytail: setInterval instead of croner: every schedule in 04 §8 is a whole number of minutes.
|
||||
import { statfs } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { and, eq, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Config } from './config.ts';
|
||||
import type { Db } from './db/client.ts';
|
||||
@@ -19,11 +19,14 @@ import {
|
||||
import type { Generator } from './generator/index.ts';
|
||||
import { todayIn } from './generator/load.ts';
|
||||
import type { LiveState } from './live/state.ts';
|
||||
import { cleanupContactShares } from './routes/contacts.ts';
|
||||
import { cleanupRecordings } from './routes/recordings.ts';
|
||||
import { transcribeCalls } from './ai/calls.ts';
|
||||
import { audit } from './services/audit.ts';
|
||||
import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { sendCallReports } from './services/callreport.ts';
|
||||
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
|
||||
import { logCallsToContrack } from './contacts/timeline.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
import type { SendMail } from './services/mail.ts';
|
||||
@@ -108,6 +111,7 @@ export async function retentionCleanup(d: JobDeps) {
|
||||
await d.db
|
||||
.delete(systemEvents)
|
||||
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
|
||||
await cleanupContactShares(d.db);
|
||||
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
|
||||
}
|
||||
|
||||
@@ -238,11 +242,35 @@ export function createJobs(d: JobDeps) {
|
||||
);
|
||||
};
|
||||
|
||||
/** Contrack books warmed every 5 minutes, so a ringing phone's lookup finds them (11 §6 C4). */
|
||||
const contactsWarm = async () => {
|
||||
const c = await contrackSettings(d.db);
|
||||
if (!c) return;
|
||||
const owners = await d.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.isActive, true), isNotNull(users.contrackTokenEnc)));
|
||||
for (const owner of [null, ...owners.map((o) => o.id)]) await contrackBook(d.db, c, owner);
|
||||
};
|
||||
|
||||
const callReports = async () => {
|
||||
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports };
|
||||
const contrackLog = async () => {
|
||||
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
return {
|
||||
daily,
|
||||
backup,
|
||||
publicIpWatch,
|
||||
trunkWatch,
|
||||
diskWatch,
|
||||
callReports,
|
||||
contactsWarm,
|
||||
contrackLog,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts the jobs; returns a stop function. */
|
||||
@@ -265,6 +293,8 @@ export function startJobs(d: JobDeps): () => void {
|
||||
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
|
||||
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
|
||||
run('callReports', j.callReports);
|
||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||
run('contrackLog', j.contrackLog);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -26,6 +27,8 @@ export type ProvisionContext = {
|
||||
provPassword: string;
|
||||
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
|
||||
phonebookUrl: string;
|
||||
/** Whose phone: their own contacts go in its directory (11 §6 C6). */
|
||||
ownerUserId: string | null;
|
||||
ntpServer: string;
|
||||
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
|
||||
dialRules: DialRule[];
|
||||
@@ -79,6 +82,7 @@ export async function loadContext(
|
||||
provUser: d.mac,
|
||||
provPassword,
|
||||
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
|
||||
ownerUserId: e.userId,
|
||||
ntpServer: NTP_SERVER,
|
||||
dialRules: await loadDialRules(db),
|
||||
},
|
||||
@@ -87,17 +91,17 @@ export async function loadContext(
|
||||
|
||||
export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] };
|
||||
|
||||
/** The company phonebook (07 §2): every extension, then the company contacts, by name. */
|
||||
export async function loadPhonebook(db: Db): Promise<PhonebookEntry[]> {
|
||||
/** A phone's directory (07 §2): every extension, then its owner's own contacts (11 §6 C6), then
|
||||
* the company's, each by name. Contrack's books in Contrack mode (11 §4.3). */
|
||||
export async function loadPhonebook(
|
||||
db: Db,
|
||||
ownerUserId: string | null = null,
|
||||
): Promise<PhonebookEntry[]> {
|
||||
const exts = await db
|
||||
.select({ name: extensions.name, number: extensions.number })
|
||||
.from(extensions)
|
||||
.orderBy(asc(extensions.name));
|
||||
const company = await db
|
||||
.select({ name: contacts.name, numbers: contacts.numbers })
|
||||
.from(contacts)
|
||||
.where(isNull(contacts.ownerUserId))
|
||||
.orderBy(asc(contacts.name));
|
||||
const company = (await readBooks(db, ownerUserId)).items; // mine first, then the company's
|
||||
return [
|
||||
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
|
||||
...company.map((c) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
|
||||
provPassword: 'Pr0vPassw0rdXyZ12345',
|
||||
phonebookUrl:
|
||||
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
|
||||
ownerUserId: null,
|
||||
ntpServer: 'pool.ntp.org',
|
||||
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
|
||||
...over,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/me/contacts (my book, shares) and /api/contacts (the company book, admin): spec 11.
|
||||
import { and, desc, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ContactImportSchema,
|
||||
ContactInputSchema,
|
||||
ContactShareDecisionSchema,
|
||||
ContactShareSchema,
|
||||
type ContactBookDto,
|
||||
type ContactCopyResult,
|
||||
type ContactData,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
type ContactShareDto,
|
||||
type ListResponse,
|
||||
} from '@selfphoneng/shared';
|
||||
import { requireAdmin, requireAuth } from '../auth/guards.ts';
|
||||
import {
|
||||
addToBook,
|
||||
assertBuiltin,
|
||||
cleanNumbers,
|
||||
contactNames,
|
||||
copyToContrack,
|
||||
createContact,
|
||||
readBooks,
|
||||
toDto,
|
||||
type Owner,
|
||||
} from '../contacts/book.ts';
|
||||
import { parseCsv } from '../contacts/csv.ts';
|
||||
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';
|
||||
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
|
||||
/** 5,000 contacts or 5 MB of files per import (11 §3.1). */
|
||||
const MAX_IMPORT = 5000;
|
||||
const IMPORT_BODY = 6 * 1024 * 1024;
|
||||
|
||||
/** Every contact in the files, and what couldn't be read. */
|
||||
function readFiles(files: { name: string; text: string }[]) {
|
||||
const list: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const f of files) {
|
||||
const vcf = /\.vcf$/i.test(f.name) || /^\s*BEGIN:VCARD/i.test(f.text);
|
||||
const r = vcf ? parseVcards(f.text) : parseCsv(f.text);
|
||||
list.push(...r.contacts);
|
||||
errors.push(...r.errors.map((e) => `${f.name}: ${e}`));
|
||||
}
|
||||
if (list.length > MAX_IMPORT) throw badRequest(`At most ${MAX_IMPORT} contacts per import`);
|
||||
return { list, errors };
|
||||
}
|
||||
|
||||
export async function contactRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
|
||||
// ───── the routes both books have; `owner` says whose ─────
|
||||
const book = (
|
||||
prefix: string,
|
||||
guard: typeof requireAuth,
|
||||
ownerOf: (u: { id: string }) => Owner,
|
||||
) => {
|
||||
const mine = (u: { id: string }, id: string) =>
|
||||
db
|
||||
.select()
|
||||
.from(contacts)
|
||||
.where(
|
||||
and(
|
||||
eq(contacts.id, id),
|
||||
ownerOf(u) ? eq(contacts.ownerUserId, ownerOf(u)!) : isNull(contacts.ownerUserId),
|
||||
),
|
||||
)
|
||||
.then(([r]) => {
|
||||
if (!r) throw notFound('Contact not found');
|
||||
return r;
|
||||
});
|
||||
const company = (u: { id: string }) => ownerOf(u) === null;
|
||||
const log = (
|
||||
req: { user: { id: string } | null; ip: string },
|
||||
action: 'create' | 'update' | 'delete',
|
||||
id: string | undefined,
|
||||
summary: string,
|
||||
) =>
|
||||
company(req.user!)
|
||||
? audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action,
|
||||
entityType: 'contact',
|
||||
entityId: id,
|
||||
summary,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
app.post(prefix, { preHandler: guard }, async (req, reply) => {
|
||||
const c = ContactInputSchema.parse(req.body);
|
||||
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
|
||||
.update(contacts)
|
||||
.set({ ...c, numbers: cleanNumbers(c), updatedAt: new Date() })
|
||||
.where(eq(contacts.id, id))
|
||||
.returning();
|
||||
await log(req, 'update', id, `Changed the company contact ${c.name}`);
|
||||
return toDto(row!);
|
||||
});
|
||||
|
||||
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}`);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post(
|
||||
`${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);
|
||||
const r = await addToBook(db, ownerOf(req.user!), list, commit, errors);
|
||||
if (commit && r.added)
|
||||
await log(req, 'create', undefined, `Imported ${r.added} company contacts`);
|
||||
return r;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// ───── 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<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<ContactBookDto> =>
|
||||
readBooks(db, null),
|
||||
);
|
||||
book('/api/contacts', requireAdmin, () => null);
|
||||
|
||||
/**
|
||||
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
|
||||
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
|
||||
*/
|
||||
app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
|
||||
const q = z
|
||||
.object({
|
||||
ext: z.string().regex(/^\d{1,10}$/),
|
||||
number: z.string().regex(/^\d{3,20}$/),
|
||||
token: z.string().max(100),
|
||||
})
|
||||
.safeParse(req.query);
|
||||
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
|
||||
if (
|
||||
!local ||
|
||||
req.headers['x-forwarded-for'] ||
|
||||
!q.success ||
|
||||
!sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
|
||||
)
|
||||
return reply.code(404).send('');
|
||||
const [e] = await db
|
||||
.select({ userId: extensions.userId })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.number, q.data.ext));
|
||||
const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
|
||||
() => new Map<string, string>(),
|
||||
);
|
||||
return reply
|
||||
.type('text/plain; charset=utf-8')
|
||||
.send(phoneSafeName(names.get(q.data.number) ?? ''));
|
||||
});
|
||||
|
||||
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
|
||||
app.post(
|
||||
'/api/me/contacts/copy-to-contrack',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<ContactCopyResult> => copyToContrack(db, req.user!.id),
|
||||
);
|
||||
app.post(
|
||||
'/api/contacts/copy-to-contrack',
|
||||
{ preHandler: requireAdmin },
|
||||
async (req): Promise<ContactCopyResult> => {
|
||||
const r = await copyToContrack(db, null);
|
||||
if (r.added)
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'contact',
|
||||
summary: `Copied ${r.added} company contacts to Contrack`,
|
||||
});
|
||||
return r;
|
||||
},
|
||||
);
|
||||
|
||||
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
|
||||
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
|
||||
const b = z
|
||||
.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 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 })
|
||||
.from(users)
|
||||
.where(and(eq(users.isActive, true), ne(users.id, me)))
|
||||
: await db
|
||||
.select({ id: users.id })
|
||||
.from(extensions)
|
||||
.innerJoin(users, eq(users.id, extensions.userId))
|
||||
.where(
|
||||
and(
|
||||
inArray(extensions.number, s.extensions),
|
||||
eq(users.isActive, true),
|
||||
ne(users.id, me),
|
||||
),
|
||||
);
|
||||
if (!to.length) throw badRequest('Nobody to share with');
|
||||
await db.transaction(async (tx) => {
|
||||
// The same contact offered again replaces the pending offer.
|
||||
await tx.delete(contactShares).where(
|
||||
and(
|
||||
eq(contactShares.status, 'pending'),
|
||||
inArray(
|
||||
contactShares.toUserId,
|
||||
to.map((u) => u.id),
|
||||
),
|
||||
inArray(
|
||||
contactShares.sourceId,
|
||||
picked.map((c) => c.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tx.insert(contactShares).values(
|
||||
to.flatMap((u) =>
|
||||
picked.map(({ id, name, company, email, numbers, notes }) => ({
|
||||
fromUserId: me,
|
||||
toUserId: u.id,
|
||||
sourceId: id,
|
||||
contact: { name, company, email, numbers, notes },
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
return { offers: picked.length * to.length, people: to.length };
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/api/me/contacts/shares',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<ListResponse<ContactShareDto>> => {
|
||||
const rows = await db
|
||||
.select({ s: contactShares, fromName: users.displayName })
|
||||
.from(contactShares)
|
||||
.leftJoin(users, eq(users.id, contactShares.fromUserId))
|
||||
.where(and(eq(contactShares.toUserId, req.user!.id), eq(contactShares.status, 'pending')))
|
||||
.orderBy(desc(contactShares.createdAt));
|
||||
return {
|
||||
items: rows.map(({ s, fromName }) => ({
|
||||
id: s.id,
|
||||
from: s.fromUserId && fromName ? { userId: s.fromUserId, name: fromName } : null,
|
||||
contact: s.contact as ContactData,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
})),
|
||||
nextCursor: null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
for (const decision of ['accept', 'deny'] as const)
|
||||
app.post(`/api/me/contacts/shares/${decision}`, { preHandler: requireAuth }, async (req) => {
|
||||
const { ids } = ContactShareDecisionSchema.parse(req.body);
|
||||
const me = req.user!.id;
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
return r ? { added: r.added, duplicates: r.duplicates } : { denied: offers.length };
|
||||
});
|
||||
}
|
||||
|
||||
/** Decided offers go after 30 days (11 §2.1); retentionCleanup calls it. */
|
||||
export const cleanupContactShares = (db: FastifyInstance['db']) =>
|
||||
db
|
||||
.delete(contactShares)
|
||||
.where(
|
||||
and(
|
||||
ne(contactShares.status, 'pending'),
|
||||
lt(contactShares.decidedAt, sql`now() - interval '30 days'`),
|
||||
),
|
||||
);
|
||||
@@ -3,10 +3,11 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ContrackOptionsSchema,
|
||||
GREETINGS,
|
||||
MeUpdateSchema,
|
||||
type ListResponse,
|
||||
@@ -27,8 +28,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 +124,84 @@ 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;
|
||||
const [u] = await db
|
||||
.select({ logCalls: users.contrackLogCalls, logSummaries: users.contrackLogSummaries })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
return {
|
||||
connected: !!book && !book.error,
|
||||
tokenPrefix: token ? `${token.slice(0, 12)}…` : null,
|
||||
publicUrl: c.publicUrl,
|
||||
logCalls: u?.logCalls ?? true,
|
||||
logSummaries: u?.logSummaries ?? false,
|
||||
summariesAvailable:
|
||||
(await getSetting(db, 'ai.callTranscripts')) && (await getSetting(db, 'ai.callSummaries')),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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);
|
||||
});
|
||||
// Calls are logged from now on, not the ones before (11 §4.4).
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
contrackTokenEnc: encrypt(token),
|
||||
contrackLogSince: sql`coalesce(${users.contrackLogSince}, now())`,
|
||||
})
|
||||
.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);
|
||||
});
|
||||
|
||||
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
|
||||
app.patch('/api/me/contrack', async (req) => {
|
||||
const o = ContrackOptionsSchema.parse(req.body);
|
||||
const [u] = await db
|
||||
.select({ logCalls: users.contrackLogCalls })
|
||||
.from(users)
|
||||
.where(eq(users.id, req.user!.id));
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
...(o.logCalls !== undefined ? { contrackLogCalls: o.logCalls } : {}),
|
||||
...(o.logSummaries !== undefined ? { contrackLogSummaries: o.logSummaries } : {}),
|
||||
// Turned back on: from now, not the calls while it was off.
|
||||
...(o.logCalls && !u?.logCalls ? { contrackLogSince: new Date() } : {}),
|
||||
})
|
||||
.where(eq(users.id, req.user!.id));
|
||||
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.
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
||||
/** The config, or (phonebook) the directory in the phone's vendor's format. */
|
||||
async function render(c: ProvisionContext, phonebook = false): Promise<string> {
|
||||
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
|
||||
const entries = await loadPhonebook(db);
|
||||
const entries = await loadPhonebook(db, c.ownerUserId);
|
||||
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { formatNumber, type CallReport } from '@selfphoneng/shared';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
|
||||
import type { SendMail } from './mail.ts';
|
||||
@@ -56,6 +57,8 @@ export type ReportCall = {
|
||||
status: string;
|
||||
fromNumber: string | null;
|
||||
fromName: string | null;
|
||||
/** The other side's name in my contacts or the company's (11 §1). */
|
||||
contactName: string | null;
|
||||
toNumber: string | null;
|
||||
talkSeconds: number;
|
||||
answeredBy: string | null;
|
||||
@@ -80,9 +83,10 @@ export function reportText(
|
||||
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
|
||||
];
|
||||
for (const c of list) {
|
||||
const name = c.contactName ?? (c.out ? null : c.fromName);
|
||||
const who = c.out
|
||||
? `Outgoing to ${num(c.toNumber)}`
|
||||
: `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`;
|
||||
? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}`
|
||||
: `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`;
|
||||
const facts = [
|
||||
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
|
||||
duration(c.talkSeconds),
|
||||
@@ -158,12 +162,18 @@ export async function sendCallReports(d: {
|
||||
.set({ callReportSentAt: period.to })
|
||||
.where(eq(extensions.id, e.id));
|
||||
if (!rows.length) continue;
|
||||
const names = await contactNames(
|
||||
d.db,
|
||||
e.userId,
|
||||
rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)),
|
||||
);
|
||||
const list = rows.map((r): ReportCall => ({
|
||||
startedAt: r.c.startedAt,
|
||||
out: r.out,
|
||||
status: r.c.status,
|
||||
fromNumber: r.c.fromNumber,
|
||||
fromName: r.c.fromName,
|
||||
contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null,
|
||||
toNumber: r.c.toNumber,
|
||||
talkSeconds: r.c.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 11 §6 C4 against real Asterisk: a call from outside rings 101's phone with the caller's name
|
||||
// from 101's contacts. A second Asterisk plays the provider, a baresip phone is 101, and a tiny
|
||||
// stand-in for the api's /api/internal/caller-name runs in the PBX's network namespace (so it is
|
||||
// 127.0.0.1 to Asterisk, as in production). What the phone sees is read from the INVITE the PBX
|
||||
// sends it (pjsip logger).
|
||||
// Checks: a contact's name; no contact keeps the provider's name; a ring group's "Sales:" prefix
|
||||
// stays in front; the api not answering still rings with the provider's name; 101's contact name
|
||||
// does not follow a forward to 102.
|
||||
// Usage: node --conditions=source apps/api/test/asterisk/cnam-test.ts
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
|
||||
import { AmiClient } from '../../src/ami/client.ts';
|
||||
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
|
||||
import type { Snapshot } from '../../src/generator/load.ts';
|
||||
import { providerPjsip } from './provider.ts';
|
||||
|
||||
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
|
||||
const NET = 'sp-cnam';
|
||||
const PBX = 'sp-cnam-pbx';
|
||||
const PROVIDER = 'sp-cnam-provider';
|
||||
const PHONES = 'sp-cnam-phones';
|
||||
const API = 'sp-cnam-api';
|
||||
const AMI_PORT = 15041;
|
||||
const AMI_SECRET = 'cnam-test-secret';
|
||||
const TRUNK = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
|
||||
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
|
||||
const TOKEN = 'ab'.repeat(32);
|
||||
const EXT = {
|
||||
'101': '00000000-0000-4000-8000-000000000101',
|
||||
'102': '00000000-0000-4000-8000-000000000102',
|
||||
};
|
||||
const PASS = { '101': 'CnamTest101xxxxxxxxxxxxx', '102': 'CnamTest102xxxxxxxxxxxxx' };
|
||||
const DID_USER = '13125551234'; // rings 101
|
||||
const DID_GROUP = '13125551235'; // "Sales:" ring group 600 → 101
|
||||
const KNOWN = '3125559999'; // Jane Doe in 101's contacts (10 digits, as voip.ms sends)
|
||||
const UNKNOWN = '3125550001';
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const docker = (...args: string[]) => {
|
||||
const r = spawnSync('docker', args, { encoding: 'utf8' });
|
||||
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
|
||||
};
|
||||
const log = (...a: unknown[]) => console.log('[cnam-test]', ...a);
|
||||
|
||||
function snapshot(): Snapshot {
|
||||
const settings = Object.fromEntries(
|
||||
(Object.keys(SettingsSchemas) as SettingKey[])
|
||||
.filter((k) => k !== 'sms.webhookToken')
|
||||
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
|
||||
) as Snapshot['settings'];
|
||||
const ext = (number: '101' | '102', name: string) => ({
|
||||
id: EXT[number],
|
||||
number,
|
||||
name,
|
||||
sipPassword: PASS[number],
|
||||
webrtcEnabled: false,
|
||||
maxContacts: 5,
|
||||
ringSeconds: 6, // the forward check waits for 101's no-answer
|
||||
voicemailEnabled: false,
|
||||
voicemailPin: '4826',
|
||||
outboundClass: 'national' as const,
|
||||
outboundCallerId: null,
|
||||
emergencyCallerId: null,
|
||||
recordMode: 'never' as const,
|
||||
pickupGroup: null,
|
||||
language: 'en',
|
||||
mohClass: 'default',
|
||||
isAdmin: false,
|
||||
});
|
||||
return {
|
||||
today: '2026-10-23',
|
||||
settings,
|
||||
callerNames: { url: 'http://127.0.0.1:8080/api/internal/caller-name', token: TOKEN },
|
||||
dids: [
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
number: DID_USER,
|
||||
trunkId: TRUNK,
|
||||
label: null,
|
||||
destination: { type: 'user', id: EXT['101'] },
|
||||
callerIdNamePrefix: null,
|
||||
recordInbound: false,
|
||||
},
|
||||
{
|
||||
id: '11111111-1111-4111-8111-111111111112',
|
||||
number: DID_GROUP,
|
||||
trunkId: TRUNK,
|
||||
label: null,
|
||||
destination: { type: 'ring_group', id: '22222222-2222-4222-8222-222222222222' },
|
||||
callerIdNamePrefix: null,
|
||||
recordInbound: false,
|
||||
},
|
||||
],
|
||||
extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
|
||||
trunks: [
|
||||
{
|
||||
id: TRUNK,
|
||||
name: 'voip.ms',
|
||||
enabled: true,
|
||||
serverHost: PROVIDER,
|
||||
serverPort: 5060,
|
||||
transport: 'udp',
|
||||
authMode: 'registration',
|
||||
username: SUB.user,
|
||||
password: SUB.pass,
|
||||
fromUser: null,
|
||||
fromDomain: null,
|
||||
codecs: ['ulaw'],
|
||||
srtp: false,
|
||||
maxChannels: null,
|
||||
},
|
||||
],
|
||||
ringGroups: [
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
number: '600',
|
||||
name: 'Sales',
|
||||
strategy: 'ringall',
|
||||
ringSeconds: 20,
|
||||
callerIdNamePrefix: 'Sales:',
|
||||
musicInsteadOfRing: false,
|
||||
members: ['101'],
|
||||
noAnswerDestination: { type: 'hangup' },
|
||||
},
|
||||
],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
pagingGroups: [],
|
||||
mohClasses: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
announcements: [],
|
||||
recordingAnnouncement: null,
|
||||
routes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** The provider: "Mobile" <caller> calls one of our DIDs; ${CALLER} picks who calls. */
|
||||
const providerDialplan = `[provider-out]
|
||||
exten => _X.,1,Set(CALLERID(all)="Mobile" <\${CALLER}>)
|
||||
same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)
|
||||
|
||||
[provider-wait]
|
||||
exten => s,1,Answer()
|
||||
same => n,Wait(30)
|
||||
|
||||
[provider-in]
|
||||
exten => _X.,1,Answer()
|
||||
same => n,Wait(30)
|
||||
`;
|
||||
|
||||
/** baresip phones as 101 and 102: they ring, nobody answers (the INVITE is what we read). */
|
||||
function phoneFiles(dir: string) {
|
||||
for (const n of ['101', '102'] as const) {
|
||||
const d = join(dir, `phone-${n}`);
|
||||
mkdirSync(d, { recursive: true });
|
||||
writeFileSync(
|
||||
join(d, 'config'),
|
||||
[
|
||||
'poll_method epoll',
|
||||
`sip_listen 0.0.0.0:${n === '101' ? 5070 : 5072}`,
|
||||
'module_path /usr/lib/baresip/modules',
|
||||
'module g711.so',
|
||||
'module ausine.so',
|
||||
'module aufile.so',
|
||||
'module menu.so',
|
||||
'module account.so',
|
||||
'audio_source ausine,440',
|
||||
'audio_player aufile,/dev/null',
|
||||
'audio_alert aufile,/dev/null',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
writeFileSync(
|
||||
join(d, 'accounts'),
|
||||
`<sip:${n}@${PBX};transport=udp>;auth_pass=${PASS[n]};answermode=manual;audio_codecs=PCMU;regint=60\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The api's caller-name route, faked: Jane Doe for KNOWN in 101's book, with the right key. */
|
||||
const FAKE_API = `
|
||||
const http = require('http');
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const ok = u.pathname === '/api/internal/caller-name' && u.searchParams.get('token') === '${TOKEN}';
|
||||
const name = ok && u.searchParams.get('ext') === '101' && u.searchParams.get('number') === '1${KNOWN}' ? 'Jane Doe' : '';
|
||||
console.log('lookup ' + u.searchParams.get('ext') + ' ' + u.searchParams.get('number') + ' ' + (ok ? 'key-ok' : 'key-bad') + ' -> ' + JSON.stringify(name));
|
||||
if (process.env.SLOW) return setTimeout(() => res.end(name), 5000);
|
||||
res.end(name);
|
||||
}).listen(8080, '127.0.0.1');
|
||||
`;
|
||||
|
||||
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
|
||||
for (let i = 0; i < seconds; i++) {
|
||||
if (await fn()) return;
|
||||
await sleep(1000);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sp-cnam-'));
|
||||
const pbxConf = join(dir, 'pbx');
|
||||
const provConf = join(dir, 'provider');
|
||||
mkdirSync(pbxConf);
|
||||
mkdirSync(provConf);
|
||||
const files = render(snapshot());
|
||||
for (const f of GENERATED_FILES) {
|
||||
writeFileSync(join(pbxConf, f), files[f]);
|
||||
writeFileSync(
|
||||
join(provConf, f),
|
||||
f === 'pjsip_selfphoneng.conf'
|
||||
? providerPjsip(SUB)
|
||||
: f === 'extensions_selfphoneng.conf'
|
||||
? providerDialplan
|
||||
: '',
|
||||
);
|
||||
}
|
||||
phoneFiles(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'manager.conf.template'),
|
||||
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
|
||||
);
|
||||
const problems: string[] = [];
|
||||
const ami = new AmiClient({
|
||||
host: '127.0.0.1',
|
||||
port: AMI_PORT,
|
||||
username: 'selfphoneng',
|
||||
secret: AMI_SECRET,
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
backoffMs: [500],
|
||||
});
|
||||
const asterisk = (name: string, conf: string, extra: string[]) =>
|
||||
docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
NET,
|
||||
...extra,
|
||||
'-e',
|
||||
`AMI_SECRET=${AMI_SECRET}`,
|
||||
'-v',
|
||||
`${conf}:/etc/asterisk/selfphoneng`,
|
||||
IMAGE,
|
||||
);
|
||||
const booted = (name: string) =>
|
||||
waitFor(
|
||||
`${name} to boot`,
|
||||
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
|
||||
);
|
||||
const startApi = (slow: boolean) => {
|
||||
docker('rm', '-f', API);
|
||||
const r = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
API,
|
||||
'--network',
|
||||
`container:${PBX}`,
|
||||
...(slow ? ['-e', 'SLOW=1'] : []),
|
||||
'node:22-alpine',
|
||||
'node',
|
||||
'-e',
|
||||
FAKE_API,
|
||||
);
|
||||
if (!r.ok) throw new Error(`fake api: ${r.out}`);
|
||||
};
|
||||
|
||||
/** The provider calls `did` as `caller`; returns the From display name of the INVITE to `ext`. */
|
||||
async function ring(did: string, caller: string, ext: '101' | '102'): Promise<string | null> {
|
||||
// channel originate can't set a variable: the provider's dialplan reads a global.
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', `dialplan set global CALLER ${caller}`);
|
||||
const since = new Date().toISOString();
|
||||
await ami.command('pjsip set logger on');
|
||||
const o = docker(
|
||||
'exec',
|
||||
PROVIDER,
|
||||
'asterisk',
|
||||
'-rx',
|
||||
`channel originate Local/${did.slice(1)}@provider-out extension s@provider-wait`,
|
||||
);
|
||||
if (!o.ok) throw new Error(`originate: ${o.out}`);
|
||||
let from: string | null = null;
|
||||
await waitFor(
|
||||
`the INVITE to ${ext}`,
|
||||
async () => {
|
||||
const logs = docker('logs', '--since', since, PBX).out;
|
||||
const invites = logs
|
||||
.split(/<--- Transmitting SIP request/)
|
||||
.slice(1)
|
||||
.filter((b) => new RegExp(`^INVITE sip:${ext}[-@]`, 'm').test(b));
|
||||
const m = invites.map((b) => /^From: (?:"([^"]*)" )?</m.exec(b)).find(Boolean);
|
||||
if (m) from = m[1] ?? '';
|
||||
return m !== undefined;
|
||||
},
|
||||
20,
|
||||
);
|
||||
await ami.command('pjsip set logger off');
|
||||
await ami.command('channel request hangup all');
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'channel request hangup all');
|
||||
await sleep(2000);
|
||||
return from;
|
||||
}
|
||||
|
||||
try {
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
|
||||
docker('network', 'create', NET);
|
||||
if (!asterisk(PROVIDER, provConf, []).ok) throw new Error('provider did not start');
|
||||
await booted(PROVIDER);
|
||||
const pbx = asterisk(PBX, pbxConf, [
|
||||
'-p',
|
||||
`127.0.0.1:${AMI_PORT}:5038`,
|
||||
'-v',
|
||||
`${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
|
||||
]);
|
||||
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
|
||||
await booted(PBX);
|
||||
startApi(false);
|
||||
ami.start();
|
||||
await new Promise((r) => ami.once('connected', r));
|
||||
await waitFor('the trunk to register', async () =>
|
||||
(await ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' })).some(
|
||||
(m) => m.Status === 'Registered',
|
||||
),
|
||||
);
|
||||
|
||||
log('starting baresip phones 101 and 102 (installs baresip on first run)…');
|
||||
const phones = docker(
|
||||
'run',
|
||||
'-d',
|
||||
'--rm',
|
||||
'--name',
|
||||
PHONES,
|
||||
'--network',
|
||||
NET,
|
||||
'-v',
|
||||
`${dir}:/phones`,
|
||||
'debian:trixie-slim',
|
||||
'sh',
|
||||
'-c',
|
||||
'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ' +
|
||||
'(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && (baresip -f /phones/phone-102 > /phones/phone-102/log 2>&1 &) && sleep 900',
|
||||
);
|
||||
if (!phones.ok) throw new Error(`phones: ${phones.out}`);
|
||||
await waitFor(
|
||||
'101 and 102 to register',
|
||||
async () => {
|
||||
const c = await ami.command('pjsip show contacts');
|
||||
return /101\/sip:.*Avail/.test(c) && /102\/sip:.*Avail/.test(c);
|
||||
},
|
||||
240,
|
||||
);
|
||||
|
||||
const expect = (what: string, got: string | null, want: string) => {
|
||||
log(`${what}: the phone shows ${JSON.stringify(got)}`);
|
||||
if (got !== want)
|
||||
problems.push(
|
||||
`${what}: phone showed ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
|
||||
);
|
||||
};
|
||||
expect('a contact calls 101', await ring(DID_USER, KNOWN, '101'), 'Jane Doe');
|
||||
expect('a stranger calls 101', await ring(DID_USER, UNKNOWN, '101'), 'Mobile');
|
||||
expect(
|
||||
'a contact calls the Sales group',
|
||||
await ring(DID_GROUP, KNOWN, '101'),
|
||||
'Sales: Jane Doe',
|
||||
);
|
||||
|
||||
// 101 doesn't answer and forwards to 102 (AstDB SP/CFNA/101, as My settings writes it): 101's
|
||||
// phone showed Jane Doe, and 102 must not see 101's private contact name.
|
||||
await ami.command('database put SP CFNA/101 102');
|
||||
expect('101 unanswered, forwarded to 102', await ring(DID_USER, KNOWN, '102'), 'Mobile');
|
||||
await ami.command('database del SP CFNA/101');
|
||||
|
||||
const lookups = docker('logs', API).out;
|
||||
if (!/lookup 101 13125559999 key-ok -> "Jane Doe"/.test(lookups))
|
||||
problems.push(`no keyed lookup for 101:\n${lookups}`);
|
||||
if (/key-bad/.test(lookups)) problems.push(`a lookup without the key:\n${lookups}`);
|
||||
|
||||
// The api too slow (5 s, the dialplan waits 2): the phone still rings, with the provider's name.
|
||||
startApi(true);
|
||||
await sleep(1000);
|
||||
const t0 = Date.now();
|
||||
expect('the api does not answer in time', await ring(DID_USER, KNOWN, '101'), 'Mobile');
|
||||
log(`rang after ${Math.round((Date.now() - t0) / 100) / 10} s with the api hanging`);
|
||||
} finally {
|
||||
ami.stop();
|
||||
if (process.env.KEEP) log(`KEEP: containers left running, config in ${dir}`);
|
||||
else {
|
||||
docker('rm', '-f', PBX, PROVIDER, PHONES, API);
|
||||
docker('network', 'rm', NET);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
if (problems.length) {
|
||||
console.error(`[cnam-test] FAILED:\n- ${problems.join('\n- ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
log('PASS');
|
||||
}
|
||||
|
||||
main().catch((e: unknown) => {
|
||||
console.error('[cnam-test]', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C1: my contacts, the company's, imports and shares (spec 11).
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { callerNameToken, phoneSafeName } from '../src/contacts/callername.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
|
||||
describe.skipIf(!hasTestDb)('contacts', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: Req;
|
||||
let dan: Req;
|
||||
let sue: Req;
|
||||
let danId: string;
|
||||
const vcf = (name: string, tel: string) =>
|
||||
`BEGIN:VCARD\r\nVERSION:3.0\r\nFN:${name}\r\nTEL;TYPE=CELL:${tel}\r\nEND:VCARD\r\n`;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const as =
|
||||
(cookie: string): Req =>
|
||||
(method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
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(() => t?.close());
|
||||
|
||||
it('my book and the company book: numbers normalized, users only edit their own', async () => {
|
||||
const mine = await dan('POST', '/api/me/contacts', {
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme',
|
||||
email: '',
|
||||
numbers: [{ label: 'mobile', number: '(312) 555-1234' }],
|
||||
});
|
||||
expect(mine.statusCode, mine.body).toBe(201);
|
||||
expect(mine.json()).toMatchObject({
|
||||
book: 'mine',
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '13125551234' }],
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await dan('POST', '/api/me/contacts', { name: 'X', numbers: [{ number: 'call me' }] })
|
||||
).json().error.message,
|
||||
).toMatch(/isn't a phone number/);
|
||||
|
||||
const co = await admin('POST', '/api/contacts', {
|
||||
name: 'Pizza Place',
|
||||
numbers: [{ label: 'main', number: '312-555-0000' }],
|
||||
});
|
||||
expect(co.statusCode, co.body).toBe(201);
|
||||
expect((await dan('GET', '/api/contacts')).statusCode).toBe(403);
|
||||
// Dan sees the company contact but can't change it.
|
||||
const list = (await dan('GET', '/api/me/contacts')).json().items;
|
||||
expect(list.map((c: { name: string; book: string }) => [c.name, c.book])).toEqual([
|
||||
['Jane Doe', 'mine'],
|
||||
['Pizza Place', 'company'],
|
||||
]);
|
||||
expect((await dan('DELETE', `/api/me/contacts/${co.json().id}`)).statusCode).toBe(404);
|
||||
expect(
|
||||
(await sue('PATCH', `/api/me/contacts/${mine.json().id}`, { name: 'Mine now' })).statusCode,
|
||||
).toBe(404);
|
||||
expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(1); // company only
|
||||
});
|
||||
|
||||
it('import: preview first, then save; what is already there is skipped', async () => {
|
||||
const files = [
|
||||
{
|
||||
name: 'phone.vcf',
|
||||
text:
|
||||
vcf('Jane Doe', '+1 312 555 1234') + // already in Dan's book
|
||||
vcf('Bob Smith', '312.555.9999') +
|
||||
'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Nobody Home\r\nEMAIL:n@example.com\r\nEND:VCARD\r\n',
|
||||
},
|
||||
{ name: 'more.csv', text: 'name,number1\nBob Smith,3125559999\nCarl,12ab\n' },
|
||||
];
|
||||
const preview = await dan('POST', '/api/me/contacts/import', { files });
|
||||
expect(preview.json()).toEqual({
|
||||
committed: false,
|
||||
added: 3,
|
||||
duplicates: 2,
|
||||
noNumber: 2,
|
||||
errors: ['Carl: "12ab" isn\'t a phone number, left out'],
|
||||
});
|
||||
expect((await dan('GET', '/api/me/contacts')).json().items).toHaveLength(2);
|
||||
const saved = await dan('POST', '/api/me/contacts/import?commit=1', { files });
|
||||
expect(saved.json()).toMatchObject({ committed: true, added: 3, duplicates: 2 });
|
||||
expect((await dan('POST', '/api/me/contacts/import?commit=1', { files })).json()).toMatchObject(
|
||||
{
|
||||
added: 0,
|
||||
duplicates: 5,
|
||||
},
|
||||
);
|
||||
// An admin imports into the company book.
|
||||
const co = await admin('POST', '/api/contacts/import?commit=1', {
|
||||
files: [{ name: 'co.vcf', text: vcf('Plumber', '3125557777') }],
|
||||
});
|
||||
expect(co.json()).toMatchObject({ added: 1 });
|
||||
expect(
|
||||
(await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
|
||||
).toEqual(['Pizza Place', 'Plumber']);
|
||||
});
|
||||
|
||||
it('sharing offers a copy; nothing lands until Accept, Deny adds nothing', async () => {
|
||||
const book = (await dan('GET', '/api/me/contacts')).json().items as {
|
||||
id: string;
|
||||
name: string;
|
||||
book: string;
|
||||
}[];
|
||||
const jane = book.find((c) => c.name === 'Jane Doe')!;
|
||||
const bob = book.find((c) => c.name === 'Bob Smith')!;
|
||||
expect(
|
||||
(
|
||||
await dan('POST', '/api/me/contacts/share', {
|
||||
contactIds: [book.find((c) => c.book === 'company')!.id],
|
||||
everyone: true,
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(404); // not Dan's to share
|
||||
const r = await dan('POST', '/api/me/contacts/share', {
|
||||
contactIds: [jane.id, bob.id],
|
||||
extensions: ['102'],
|
||||
});
|
||||
expect(r.json()).toEqual({ offers: 2, people: 1 });
|
||||
// Shared again: still one offer per contact.
|
||||
await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id, bob.id], everyone: true });
|
||||
|
||||
const before = (await sue('GET', '/api/me/contacts')).json().items.length;
|
||||
const offers = (await sue('GET', '/api/me/contacts/shares')).json().items as {
|
||||
id: string;
|
||||
from: { name: string };
|
||||
contact: { name: string };
|
||||
}[];
|
||||
expect(offers.map((o) => [o.from.name, o.contact.name]).sort()).toEqual([
|
||||
['dan@example.com', 'Bob Smith'],
|
||||
['dan@example.com', 'Jane Doe'],
|
||||
]);
|
||||
expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(before);
|
||||
|
||||
const janeOffer = offers.find((o) => o.contact.name === 'Jane Doe')!;
|
||||
const bobOffer = offers.find((o) => o.contact.name === 'Bob Smith')!;
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/deny', { ids: [bobOffer.id] })).json(),
|
||||
).toEqual({ denied: 1 });
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [janeOffer.id] })).json(),
|
||||
).toEqual({
|
||||
added: 1,
|
||||
duplicates: 0,
|
||||
});
|
||||
// Decided offers can't be decided again, or by someone else.
|
||||
expect(
|
||||
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [bobOffer.id] })).json(),
|
||||
).toEqual({
|
||||
added: 0,
|
||||
duplicates: 0,
|
||||
});
|
||||
const names = (await sue('GET', '/api/me/contacts'))
|
||||
.json()
|
||||
.items.map((c: { name: string }) => c.name);
|
||||
expect(names).toContain('Jane Doe');
|
||||
expect(names).not.toContain('Bob Smith');
|
||||
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
|
||||
// The copy is Sue's own: Dan editing his doesn't change it.
|
||||
await dan('PATCH', `/api/me/contacts/${jane.id}`, { name: 'Jane D.', numbers: [] });
|
||||
expect(
|
||||
(await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
|
||||
).toContain('Jane Doe');
|
||||
});
|
||||
|
||||
it('looks up names: my book first, then the company', async () => {
|
||||
await admin('POST', '/api/contacts', {
|
||||
name: 'Front desk',
|
||||
numbers: [{ number: '3125551234' }],
|
||||
});
|
||||
await dan('POST', '/api/me/contacts', { name: 'My Jane', numbers: [{ number: '3125551234' }] });
|
||||
const names = await contactNames(t.db, danId, [
|
||||
'13125551234',
|
||||
'13125550000',
|
||||
'19995550000',
|
||||
null,
|
||||
]);
|
||||
expect(Object.fromEntries(names)).toEqual({
|
||||
'13125551234': 'My Jane',
|
||||
'13125550000': 'Pizza Place',
|
||||
});
|
||||
const company = await contactNames(t.db, null, ['13125551234']);
|
||||
expect(company.get('13125551234')).toBe('Front desk');
|
||||
});
|
||||
|
||||
it('the dialplan lookup (C4): only with the key, never through the proxy, phone-safe names', async () => {
|
||||
const token = callerNameToken(t.app.config.APP_SECRET);
|
||||
const look = (q: string, headers: Record<string, string> = {}) =>
|
||||
t.app.inject({ method: 'GET', url: `/api/internal/caller-name?${q}`, headers });
|
||||
const mine = await look(`ext=101&number=13125551234&token=${token}`);
|
||||
expect([mine.statusCode, mine.body]).toEqual([200, 'My Jane']);
|
||||
expect((await look(`ext=102&number=13125551234&token=${token}`)).body).toBe('Jane Doe'); // Sue's own copy, from the accepted share
|
||||
expect((await look(`ext=999&number=13125551234&token=${token}`)).body).toBe('Front desk'); // no user
|
||||
expect((await look(`ext=101&number=19995550000&token=${token}`)).body).toBe('');
|
||||
expect((await look('ext=101&number=13125551234&token=wrong')).statusCode).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await look(`ext=101&number=13125551234&token=${token}`, {
|
||||
'x-forwarded-for': '203.0.113.9',
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(404);
|
||||
expect(phoneSafeName(String.raw`Jane "J" <Doe>, Inc.; (x)^|\ Ünïcode-Smith`)).toBe(
|
||||
'Jane J Doe Inc. x Ünïcode-Smith',
|
||||
);
|
||||
expect(phoneSafeName('A'.repeat(60))).toHaveLength(40);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
// 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 { eq } from 'drizzle-orm';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { logCallsToContrack } from '../src/contacts/timeline.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contacts,
|
||||
extensions,
|
||||
recordings,
|
||||
users,
|
||||
} from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { loadPhonebook } from '../src/provisioning/context.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
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,
|
||||
toCopy: 0,
|
||||
});
|
||||
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 call with Jane lands on her Contrack timeline, with the summary and a follow-up (C5)', async () => {
|
||||
const at = (minAgo: number) => new Date(Date.now() - minAgo * 60_000);
|
||||
await t.db
|
||||
.update(users)
|
||||
.set({ contrackLogSince: at(60) })
|
||||
.where(eq(users.id, danId));
|
||||
await setSetting(t.db, 'ai.callTranscripts', true);
|
||||
await setSetting(t.db, 'ai.callSummaries', true);
|
||||
expect((await dan('PATCH', '/api/me/contrack', { logSummaries: true })).statusCode).toBe(200);
|
||||
const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'live-r1',
|
||||
filePath: 'live-r1.wav',
|
||||
startedAt: at(20),
|
||||
summary:
|
||||
'Jane asked for a <quote> & delivery dates.\nAction items:\n- Dan sends the quote by Friday',
|
||||
aiDoneAt: at(15),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db
|
||||
.insert(calls)
|
||||
.values({
|
||||
linkedid: 'live-c1',
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: at(20),
|
||||
endedAt: at(17),
|
||||
fromNumber: '13125551234',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 185,
|
||||
answeredByExtensionId: ext!.id,
|
||||
recordingId: rec!.id,
|
||||
})
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: c!.id, extensionId: ext!.id, role: 'answered' });
|
||||
expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(1);
|
||||
expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(0);
|
||||
|
||||
const jane = (
|
||||
(await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
|
||||
id: string;
|
||||
name: string;
|
||||
}[]
|
||||
).find((x) => x.name === 'Jane Doe')!;
|
||||
const timeline = (await ctk(`/api/contacts/${jane.id}/timeline`, { token: tok.dan }))
|
||||
.body as unknown as Record<string, unknown>[];
|
||||
expect(timeline).toHaveLength(1);
|
||||
expect(timeline[0]).toMatchObject({
|
||||
type: 'call',
|
||||
title: 'Incoming call',
|
||||
date: c!.startedAt.toISOString(),
|
||||
duration: expect.anything(), // Contrack answers it as "4.0"
|
||||
actionItems: [
|
||||
expect.objectContaining({ title: 'Dan sends the quote by Friday', completedAt: null }),
|
||||
],
|
||||
});
|
||||
expect(Number(timeline[0]!.duration)).toBe(4);
|
||||
const content = String(timeline[0]!.content);
|
||||
expect(content).toContain('From (312) 555-1234 to (312) 555-0000 · 3:05 · recorded');
|
||||
expect(content).toContain('Jane asked for a <quote> & delivery dates.');
|
||||
expect(content).toContain('<li>Dan sends the quote by Friday</li>');
|
||||
expect(content).not.toContain('<quote>');
|
||||
});
|
||||
|
||||
it('contacts from before Contrack copy into the account in one import (C6)', async () => {
|
||||
await t.db.insert(contacts).values([
|
||||
{
|
||||
name: 'Old Friend',
|
||||
ownerUserId: danId,
|
||||
numbers: [{ label: 'home', number: '13125550202' }],
|
||||
},
|
||||
{ name: 'Jane Doe', ownerUserId: danId, numbers: [{ label: '', number: '13125551234' }] },
|
||||
]);
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
|
||||
const r = await dan('POST', '/api/me/contacts/copy-to-contrack');
|
||||
expect(r.json(), r.body).toEqual({ added: 1, duplicates: 1, failed: 0 });
|
||||
const rows = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
|
||||
name: string;
|
||||
phones: { phone: string }[];
|
||||
}[];
|
||||
expect(rows.find((x) => x.name === 'Old Friend')?.phones).toEqual([{ phone: '13125550202' }]);
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
|
||||
});
|
||||
|
||||
it('a share is accepted into the recipient’s own Contrack account', async () => {
|
||||
const jane = (await dan('GET', '/api/me/contacts'))
|
||||
.json()
|
||||
.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', 'Old Friend']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
// 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 { eq } from 'drizzle-orm';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { forgetBook } from '../src/contacts/contrack.ts';
|
||||
import { logCallsToContrack } from '../src/contacts/timeline.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contacts,
|
||||
contrackCallLog,
|
||||
extensions,
|
||||
recordings,
|
||||
users,
|
||||
} from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { loadPhonebook } from '../src/provisioning/context.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
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;
|
||||
/** Timeline entries by contact id; `down` makes every API call fail with 503. */
|
||||
const timeline: Record<string, Record<string, unknown>[]> = {};
|
||||
const state = { down: false, readOnly: new Set<string>() };
|
||||
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 });
|
||||
if (state.down) return send(503, { error: { code: 'DB_BUSY', message: 'Busy' } });
|
||||
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
|
||||
const book = books[token];
|
||||
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
|
||||
if (req.method === 'POST' && req.url === '/api/contacts/bulk') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(String(req.headers['x-import-id'])))
|
||||
return send(400, { error: { code: 'VALIDATION_ERROR', message: 'X-Import-Id' } });
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const rows = JSON.parse(raw) as { name: string; phones: { phone: string }[] }[];
|
||||
for (const r of rows)
|
||||
book.push({
|
||||
id: `bulk-${++n}`,
|
||||
name: r.name,
|
||||
company: null,
|
||||
emails: [],
|
||||
phones: r.phones,
|
||||
});
|
||||
send(201, {
|
||||
success: true,
|
||||
count: rows.length,
|
||||
failed: 0,
|
||||
importId: req.headers['x-import-id'],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
|
||||
if (req.method === 'POST' && into) {
|
||||
if (state.readOnly.has(token))
|
||||
return send(403, { error: { code: 'TOKEN_READ_ONLY', message: 'Read-only token' } });
|
||||
if (!book.some((c) => c.id === into[1]))
|
||||
return send(404, { error: { code: 'NOT_FOUND', message: 'Contact not found' } });
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const id = `int-${++n}`;
|
||||
(timeline[into[1]!] ??= []).push({ id, ...(JSON.parse(raw) as object) });
|
||||
send(201, { id });
|
||||
});
|
||||
return;
|
||||
}
|
||||
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, timeline, state };
|
||||
}
|
||||
|
||||
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',
|
||||
logCalls: true,
|
||||
logSummaries: false,
|
||||
summariesAvailable: false,
|
||||
});
|
||||
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,
|
||||
toCopy: 0,
|
||||
});
|
||||
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("calls go on the contact's timeline once, the summary when asked for (C5)", async () => {
|
||||
const now = Date.now();
|
||||
const at = (minAgo: number) => new Date(now - minAgo * 60_000);
|
||||
// Dan connected a moment ago: his calls from an hour back count for this test.
|
||||
await t.db
|
||||
.update(users)
|
||||
.set({ contrackLogSince: at(60) })
|
||||
.where(eq(users.id, danId));
|
||||
await setSetting(t.db, 'ai.callTranscripts', true);
|
||||
await setSetting(t.db, 'ai.callSummaries', true);
|
||||
const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
|
||||
const [done, pending] = await t.db
|
||||
.insert(recordings)
|
||||
.values([
|
||||
{
|
||||
linkedid: 'r1',
|
||||
filePath: 'r1.wav',
|
||||
startedAt: at(30),
|
||||
summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote',
|
||||
aiDoneAt: at(25),
|
||||
},
|
||||
{ linkedid: 'r2', filePath: 'r2.wav', startedAt: at(10) },
|
||||
])
|
||||
.returning();
|
||||
const call = (linkedid: string, minAgo: number, v: Partial<typeof calls.$inferInsert>) => ({
|
||||
linkedid,
|
||||
direction: 'inbound' as const,
|
||||
status: 'answered' as const,
|
||||
startedAt: at(minAgo + 3),
|
||||
endedAt: at(minAgo),
|
||||
fromNumber: '13125551234', // Jane Doe
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 125,
|
||||
...v,
|
||||
});
|
||||
const rows = await t.db
|
||||
.insert(calls)
|
||||
.values([
|
||||
call('k1', 30, { recordingId: done!.id }), // answered, recorded, summarized
|
||||
call('k2', 20, { status: 'missed', talkSeconds: 0 }),
|
||||
call('k3', 15, { direction: 'outbound', fromNumber: '101', toNumber: '13125551234' }),
|
||||
call('k4', 12, { fromNumber: '13125550101' }), // not a contact
|
||||
call('k5', 10, { recordingId: pending!.id }), // not summarized yet
|
||||
call('k6', 90, {}), // before Dan connected
|
||||
call('k7', 0.2, {}), // still being written (under a minute ago)
|
||||
])
|
||||
.returning();
|
||||
const role = (i: number) => (rows[i]!.direction === 'outbound' ? 'caller' : 'answered');
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values(rows.map((r, i) => ({ callId: r.id, extensionId: ext!.id, role: role(i) })));
|
||||
const run = (min = 0) =>
|
||||
logCallsToContrack({ db: t.db, log: t.app.log, now: new Date(now + min * 60_000) });
|
||||
|
||||
// Summaries off (the default): no summary, and nothing waits for one.
|
||||
expect(await run()).toBe(4); // k1, k2, k3, k5 (k4 a stranger, k6 too early, k7 too fresh)
|
||||
const jane = fake.timeline['jane-doe-00000001']!;
|
||||
expect(jane.map((e) => e.title)).toEqual([
|
||||
'Incoming call',
|
||||
'Missed call',
|
||||
'Outgoing call',
|
||||
'Incoming call',
|
||||
]);
|
||||
expect(jane[0]).toMatchObject({
|
||||
type: 'call',
|
||||
source: 'selfphoneng',
|
||||
date: rows[0]!.startedAt.toISOString(),
|
||||
duration: 3,
|
||||
content: '<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p>',
|
||||
});
|
||||
expect(jane[0]).not.toHaveProperty('actionItem');
|
||||
expect(await run()).toBe(0); // once
|
||||
|
||||
// With summaries: the next recorded call waits for its summary, then carries it.
|
||||
await dan('PATCH', '/api/me/contrack', { logSummaries: true });
|
||||
const [k8] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k8', 5, { recordingId: done!.id }))
|
||||
.returning();
|
||||
const [k9] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k9', 4, { recordingId: pending!.id }))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values([k8!, k9!].map((r) => ({ callId: r.id, extensionId: ext!.id, role: 'answered' })));
|
||||
expect(await run()).toBe(1); // k8; k9 waits
|
||||
expect(jane.at(-1)).toMatchObject({
|
||||
content:
|
||||
'<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p><p>Acme wants a quote.</p><p>Action items:</p><ul><li>Dan sends the quote</li></ul>',
|
||||
actionItem: {
|
||||
title: 'Dan sends the quote',
|
||||
dueAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
},
|
||||
});
|
||||
// 40 minutes on: k9 without a summary (it waited 30), and k7 is old enough now.
|
||||
expect(await run(40)).toBe(2);
|
||||
expect(jane.find((e) => e.date === k9!.startedAt.toISOString())!.content).toBe(
|
||||
'<p>From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded</p>',
|
||||
);
|
||||
expect(jane).toHaveLength(7);
|
||||
|
||||
// Contrack down: nothing is lost, the call goes in on a later run.
|
||||
const [k10] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k10', 3, {}))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: k10!.id, extensionId: ext!.id, role: 'answered' });
|
||||
forgetBook(danId);
|
||||
fake.state.down = true;
|
||||
expect(await run()).toBe(0);
|
||||
fake.state.down = false;
|
||||
forgetBook(danId);
|
||||
expect(await run()).toBe(1);
|
||||
// A read-only token: noted once, not retried every minute.
|
||||
fake.state.readOnly.add('ctk_dan_0001');
|
||||
const [k11] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k11', 2, {}))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: k11!.id, extensionId: ext!.id, role: 'answered' });
|
||||
expect(await run()).toBe(0);
|
||||
const [noted] = await t.db
|
||||
.select()
|
||||
.from(contrackCallLog)
|
||||
.where(eq(contrackCallLog.callId, k11!.id));
|
||||
expect(noted).toMatchObject({ interactionId: null, error: 'Contrack: Read-only token' });
|
||||
fake.state.readOnly.clear();
|
||||
expect(await run()).toBe(0);
|
||||
expect(jane).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
|
||||
await t.db.insert(contacts).values([
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
ownerUserId: danId,
|
||||
numbers: [{ label: 'mobile', number: '13125551234' }],
|
||||
}, // in Contrack
|
||||
{
|
||||
name: 'Old Friend',
|
||||
ownerUserId: danId,
|
||||
numbers: [{ label: 'home', number: '13125550202' }],
|
||||
},
|
||||
{ name: 'Plumber', numbers: [{ label: '', number: '13125557777' }] }, // in the company account
|
||||
{ name: 'Locksmith', numbers: [{ label: 'work', number: '13125550303' }] },
|
||||
]);
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
|
||||
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
|
||||
added: 1,
|
||||
duplicates: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
|
||||
name: 'Old Friend',
|
||||
phones: [{ phone: '13125550202', label: 'home' }],
|
||||
});
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
|
||||
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
|
||||
added: 0,
|
||||
duplicates: 2,
|
||||
failed: 0,
|
||||
});
|
||||
// The company book, by an admin only.
|
||||
expect((await dan('POST', '/api/contacts/copy-to-contrack')).statusCode).toBe(403);
|
||||
expect((await admin('GET', '/api/contacts')).json().contrack.toCopy).toBe(1);
|
||||
expect((await admin('POST', '/api/contacts/copy-to-contrack')).json()).toEqual({
|
||||
added: 1,
|
||||
duplicates: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(fake.books.ctk_company!.map((c) => c.name)).toEqual(['Plumber', 'Locksmith']);
|
||||
// Dan's desk phone: his Contrack contacts, then the company's.
|
||||
const names = (await loadPhonebook(t.db, danId)).map((e) => e.name);
|
||||
expect(names.indexOf('Old Friend')).toBeGreaterThan(-1);
|
||||
expect(names.indexOf('Old Friend')).toBeLessThan(names.indexOf('Locksmith'));
|
||||
expect((await loadPhonebook(t.db, null)).map((e) => e.name)).not.toContain('Old Friend');
|
||||
});
|
||||
|
||||
it('back to builtin: the table again, tokens kept for later', async () => {
|
||||
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
|
||||
const book = (await dan('GET', '/api/me/contacts')).json();
|
||||
expect([book.provider, book.contrack]).toEqual(['builtin', null]);
|
||||
expect(book.items.map((c: { name: string }) => c.name)).toEqual([
|
||||
'Jane Doe',
|
||||
'Old Friend',
|
||||
'Locksmith',
|
||||
'Plumber',
|
||||
]);
|
||||
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -245,6 +245,32 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
|
||||
await setSetting(t.db, 'general.timezone', 'America/Chicago');
|
||||
});
|
||||
|
||||
it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => {
|
||||
const owner = await t.addUser('dan@example.com', 'dan-password-123');
|
||||
const other = await t.addUser('sue@example.com', 'sue-password-123');
|
||||
await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id });
|
||||
await t.db.insert(contacts).values([
|
||||
{ name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] },
|
||||
{
|
||||
name: 'Sue Secret',
|
||||
ownerUserId: other.id,
|
||||
numbers: [{ label: '', number: '13125550102' }],
|
||||
},
|
||||
]);
|
||||
const book = (
|
||||
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
|
||||
).body;
|
||||
expect(book).toContain('<Telephone label="mobile">13125550101</Telephone>');
|
||||
expect(book).not.toContain('Sue Secret');
|
||||
expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom'));
|
||||
expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's
|
||||
await req('PATCH', `/api/extensions/${extId}`, { userId: null });
|
||||
const unowned = (
|
||||
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
|
||||
).body;
|
||||
expect(unowned).not.toContain('Mom');
|
||||
});
|
||||
|
||||
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
|
||||
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
|
||||
@@ -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,10 @@ 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|',
|
||||
contrack_log_calls: 'boolean|NO|true',
|
||||
contrack_log_summaries: 'boolean|NO|false',
|
||||
contrack_log_since: 'timestamp with time zone|YES|',
|
||||
...ts,
|
||||
},
|
||||
extensions: {
|
||||
@@ -111,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('creates all 41 tables', async () => {
|
||||
it('creates all 43 tables', async () => {
|
||||
const r = await db.execute(
|
||||
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ n: 41 });
|
||||
expect(r.rows[0]).toEqual({ n: 43 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
|
||||
|
||||
@@ -22,6 +22,14 @@ import type {
|
||||
CallDetailDto,
|
||||
CallDto,
|
||||
CallFilter,
|
||||
ContactBookDto,
|
||||
ContactCopyResult,
|
||||
ContrackOptions,
|
||||
ContactDto,
|
||||
ContactImportResult,
|
||||
ContactInput,
|
||||
ContactShare,
|
||||
ContactShareDto,
|
||||
MyCallDto,
|
||||
RecordingDto,
|
||||
AuthUser,
|
||||
@@ -265,6 +273,9 @@ 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'),
|
||||
contrackOptions: (o: ContrackOptions) => api<MeDto>('PATCH', '/api/me/contrack', o),
|
||||
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) =>
|
||||
@@ -284,6 +295,36 @@ export const meApi = {
|
||||
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
|
||||
};
|
||||
|
||||
/** My book is /api/me/contacts (it lists the company's too); the company's is /api/contacts. */
|
||||
export type ContactBase = '/api/me/contacts' | '/api/contacts';
|
||||
|
||||
export const contactsApi = {
|
||||
list: (base: ContactBase) => api<ContactBookDto>('GET', base),
|
||||
copyToContrack: (base: ContactBase) => api<ContactCopyResult>('POST', `${base}/copy-to-contrack`),
|
||||
testContrack: (url: string, companyToken?: string) =>
|
||||
api<{ ok: true; detail: string } | { ok: false; error: string }>(
|
||||
'POST',
|
||||
'/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),
|
||||
remove: (base: ContactBase, id: string) => api<{ ok: true }>('DELETE', `${base}/${id}`),
|
||||
import: (base: ContactBase, files: { name: string; text: string }[], commit: boolean) =>
|
||||
api<ContactImportResult>('POST', `${base}/import?commit=${commit ? 1 : 0}`, { files }),
|
||||
share: (s: ContactShare) =>
|
||||
api<{ offers: number; people: number }>('POST', '/api/me/contacts/share', s),
|
||||
shares: () =>
|
||||
api<ListResponse<ContactShareDto>>('GET', '/api/me/contacts/shares').then((r) => r.items),
|
||||
decide: (decision: 'accept' | 'deny', ids: string[]) =>
|
||||
api<{ added?: number; duplicates?: number; denied?: number }>(
|
||||
'POST',
|
||||
`/api/me/contacts/shares/${decision}`,
|
||||
{ ids },
|
||||
),
|
||||
};
|
||||
|
||||
export const soundsApi = {
|
||||
list: () => api<ListResponse<SoundDto>>('GET', '/api/sounds').then((r) => r.items),
|
||||
audioUrl: (id: string) => `/api/sounds/${id}/audio`,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
|
||||
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
|
||||
import { CallsPage } from './pages/admin/CallsPage.tsx';
|
||||
import { RecordingsPage } from './pages/admin/RecordingsPage.tsx';
|
||||
import { CompanyContactsPage } from './pages/user/ContactsPage.tsx';
|
||||
import { ReportsPage } from './pages/admin/ReportsPage.tsx';
|
||||
import { SystemPage } from './pages/admin/SystemPage.tsx';
|
||||
import { MePage } from './pages/user/MePage.tsx';
|
||||
@@ -75,6 +76,8 @@ const router = createBrowserRouter([
|
||||
<OfficeHoursPage />
|
||||
) : p.path === 'blocked' ? (
|
||||
<BlockedPage />
|
||||
) : p.path === 'contacts' ? (
|
||||
<CompanyContactsPage />
|
||||
) : p.path === 'calls' ? (
|
||||
<CallsPage />
|
||||
) : p.path === 'recordings' ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ADMIN_PAGES = [
|
||||
{ path: 'paging', label: 'Paging', milestone: null },
|
||||
{ path: 'sounds', label: 'Sounds & music', milestone: null },
|
||||
{ path: 'devices', label: 'Phones', milestone: null },
|
||||
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
|
||||
{ path: 'contacts', label: 'Contacts', milestone: null },
|
||||
{ path: 'calls', label: 'Call history', milestone: null },
|
||||
{ path: 'recordings', label: 'Recordings', milestone: null },
|
||||
{ path: 'reports', label: 'Reports', milestone: null },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
|
||||
// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
|
||||
// denies), click to call. useContactName() gives every page names for numbers.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
formatNumber,
|
||||
normalizeNumber,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
type ContactInput,
|
||||
} from '@selfphoneng/shared';
|
||||
import { contactsApi, type ContactBase } from '../../api/client.ts';
|
||||
import { Dialog, ConfirmDialog } from '../../components/Dialog.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
import { useLive } from '../../live/store.ts';
|
||||
import { useMe } from './MeSettingsPage.tsx';
|
||||
import { useCallBack } from './MyCallsPage.tsx';
|
||||
|
||||
const MINE: ContactBase = '/api/me/contacts';
|
||||
const COMPANY: ContactBase = '/api/contacts';
|
||||
const key = (base: ContactBase) => ['contacts', base];
|
||||
const show = (n: string) => (n.length > 6 ? formatNumber(n) : n);
|
||||
|
||||
/** Name for a number from my book, then the company's (11 §1); undefined when there's none. */
|
||||
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?.items ?? [])
|
||||
for (const n of c.numbers) if (!m.has(n.number)) m.set(n.number, c.name);
|
||||
return m;
|
||||
}, [list.data]);
|
||||
return (number: string | null | undefined) => {
|
||||
if (!number) return undefined;
|
||||
try {
|
||||
return names.get(normalizeNumber(number)) ?? names.get(number);
|
||||
} catch {
|
||||
return names.get(number);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** "Add to contacts" from a call or a voicemail: the drawer, filled with the number. */
|
||||
export function AddToContacts({ number, name }: { number: string; name?: string | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setOpen(true)}>
|
||||
Add to contacts
|
||||
</Button>
|
||||
{open && (
|
||||
<ContactDrawer
|
||||
base={MINE}
|
||||
contact={{ name: name ?? '', numbers: [{ label: 'mobile', number: show(number) }] }}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<SharedWithYou />
|
||||
<ContactBook base={MINE} title="Contacts" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanyContactsPage() {
|
||||
return <ContactBook base={COMPANY} title="Company contacts" />;
|
||||
}
|
||||
|
||||
function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: key(base), queryFn: () => contactsApi.list(base) });
|
||||
const { call, note } = useCallBack();
|
||||
const [q, setQ] = useState('');
|
||||
const [editing, setEditing] = useState<(ContactInput & { id?: string }) | null>(null);
|
||||
const [deleting, setDeleting] = useState<ContactDto | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => contactsApi.remove(base, id),
|
||||
onSuccess: async () => {
|
||||
setDeleting(null);
|
||||
await qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
},
|
||||
});
|
||||
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 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;
|
||||
// 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">
|
||||
<h2 id="contacts-h" className="text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search contacts"
|
||||
placeholder="Search names, companies, numbers"
|
||||
className={`${inputClass} max-w-72`}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setEditing({ name: '', numbers: [{ label: 'mobile', number: '' }] })}
|
||||
>
|
||||
Add contact
|
||||
</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})` : ''}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{note && (
|
||||
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{note}
|
||||
</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>
|
||||
)}
|
||||
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
|
||||
{list.data && shown.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
{q ? 'No contact matches that.' : 'No contacts yet.'}
|
||||
</p>
|
||||
)}
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{shown.map((c) => (
|
||||
<li key={c.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
|
||||
{canShare && (
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Pick ${c.name} to share`}
|
||||
disabled={c.book !== 'mine'}
|
||||
checked={picked.has(c.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(picked);
|
||||
if (e.target.checked) next.add(c.id);
|
||||
else next.delete(c.id);
|
||||
setPicked(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-40 flex-1">
|
||||
<span className="font-medium">{c.name}</span>
|
||||
{c.company && <span className="text-zinc-500"> · {c.company}</span>}
|
||||
{base === MINE && c.book === 'company' && (
|
||||
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
Company
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-x-3">
|
||||
{c.numbers.map((n) => (
|
||||
<button
|
||||
key={n.number}
|
||||
type="button"
|
||||
className="text-sky-700 underline dark:text-sky-400"
|
||||
aria-label={`Call ${c.name} ${n.label} ${show(n.number)}`}
|
||||
onClick={() => call(n.number)}
|
||||
>
|
||||
{n.label && <span className="text-zinc-500 no-underline">{n.label} </span>}
|
||||
{show(n.number)}
|
||||
</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)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" className="px-2 py-1" onClick={() => setDeleting(c)}>
|
||||
Delete
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{editing && (
|
||||
<ContactDrawer
|
||||
base={base}
|
||||
contact={{
|
||||
...editing,
|
||||
numbers: editing.numbers?.map((n) => ({ ...n, number: show(n.number) })),
|
||||
}}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{importing && <ImportDialog base={base} onClose={() => setImporting(false)} />}
|
||||
{sharing && (
|
||||
<ShareDialog
|
||||
ids={[...picked]}
|
||||
onClose={(done) => {
|
||||
setSharing(false);
|
||||
if (done) setPicked(new Set());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete contact"
|
||||
message={<p>Delete {deleting?.name}? This can't be undone.</p>}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => deleting && remove.mutate(deleting.id)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Contacts from before Contrack (11 §5, C6): copied once, kept here for switching back. */
|
||||
function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
|
||||
const qc = useQueryClient();
|
||||
const copy = useMutation({
|
||||
mutationFn: () => contactsApi.copyToContrack(base),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-wrap items-center gap-2 rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950"
|
||||
>
|
||||
<span className="flex-1">
|
||||
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
|
||||
{count === 1 ? "isn't" : "aren't"} in {base === COMPANY ? "the company's" : 'your'} Contrack
|
||||
yet.
|
||||
</span>
|
||||
<Button variant="primary" disabled={copy.isPending} onClick={() => copy.mutate()}>
|
||||
Copy {count === 1 ? 'it' : 'them'} to Contrack
|
||||
</Button>
|
||||
{copy.error && <Alert>{copy.error.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LABELS = ['mobile', 'work', 'home', 'main', 'fax', 'other'];
|
||||
|
||||
function ContactDrawer(props: {
|
||||
base: ContactBase;
|
||||
contact: ContactInput & { id?: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { id, ...start } = props.contact;
|
||||
const [form, setForm] = useState({
|
||||
name: start.name,
|
||||
company: start.company ?? '',
|
||||
email: start.email ?? '',
|
||||
notes: start.notes ?? '',
|
||||
numbers: start.numbers?.length
|
||||
? start.numbers.map((n) => ({ label: n.label ?? '', number: n.number }))
|
||||
: [{ label: 'mobile', number: '' }],
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const c = { ...form, numbers: form.numbers.filter((n) => n.number.trim()) };
|
||||
return id ? contactsApi.update(props.base, id, c) : contactsApi.create(props.base, c);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
props.onClose();
|
||||
},
|
||||
});
|
||||
const setNumber = (i: number, patch: Partial<{ label: string; number: string }>) =>
|
||||
setForm({ ...form, numbers: form.numbers.map((n, j) => (j === i ? { ...n, ...patch } : n)) });
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
drawer
|
||||
title={id ? 'Edit contact' : 'Add contact'}
|
||||
onClose={props.onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={props.onClose}>Cancel</Button>
|
||||
<Button variant="primary" form="contact-form" type="submit" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="contact-form"
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{(['name', 'company', 'email'] as const).map((k) => (
|
||||
<Field key={k} label={k[0]!.toUpperCase() + k.slice(1)}>
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
className={inputClass}
|
||||
required={k === 'name'}
|
||||
type={k === 'email' ? 'email' : 'text'}
|
||||
value={form[k]}
|
||||
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-medium">Numbers</legend>
|
||||
{form.numbers.map((n, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<select
|
||||
aria-label={`Number ${i + 1} label`}
|
||||
className={`${inputClass} w-28`}
|
||||
value={n.label || 'other'}
|
||||
onChange={(e) => setNumber(i, { label: e.target.value })}
|
||||
>
|
||||
{[...new Set([...LABELS, n.label].filter(Boolean))].map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={`Number ${i + 1}`}
|
||||
inputMode="tel"
|
||||
className={inputClass}
|
||||
placeholder="(312) 555-1234"
|
||||
value={n.number}
|
||||
onChange={(e) => setNumber(i, { number: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Remove number ${i + 1}`}
|
||||
onClick={() =>
|
||||
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setForm({ ...form, numbers: [...form.numbers, { label: 'work', number: '' }] })
|
||||
}
|
||||
>
|
||||
Add a number
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<Field label="Notes">
|
||||
{(p) => (
|
||||
<textarea
|
||||
{...p}
|
||||
rows={3}
|
||||
className={inputClass}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [files, setFiles] = useState<{ name: string; text: string }[]>([]);
|
||||
const [preview, setPreview] = useState<ContactImportResult | null>(null);
|
||||
const run = useMutation({
|
||||
mutationFn: (commit: boolean) => contactsApi.import(base, files, commit),
|
||||
onSuccess: async (r) => {
|
||||
setPreview(r);
|
||||
if (r.committed) await qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
},
|
||||
});
|
||||
const done = preview?.committed;
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={base === COMPANY ? 'Import company contacts' : 'Import contacts'}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
done ? (
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!preview || !preview.added || run.isPending}
|
||||
onClick={() => run.mutate(true)}
|
||||
>
|
||||
Import {preview?.added ?? ''}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
vCard files (.vcf) from a phone, Google Contacts, Outlook or Apple Contacts, or a CSV with
|
||||
a header row: name, company, email, label1, number1, label2, number2…
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".vcf,.csv,text/vcard,text/csv"
|
||||
aria-label="Contact files"
|
||||
disabled={done}
|
||||
onChange={async (e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
const read = await Promise.all(
|
||||
picked.map(async (f) => ({ name: f.name, text: await f.text() })),
|
||||
);
|
||||
setFiles(read);
|
||||
setPreview(null);
|
||||
if (read.length) run.mutate(false);
|
||||
}}
|
||||
/>
|
||||
{run.isPending && <p className="text-zinc-500">Reading…</p>}
|
||||
{preview && (
|
||||
<div role="status" className="flex flex-col gap-1">
|
||||
<p>
|
||||
{done ? 'Imported' : 'New'}: <strong>{preview.added}</strong>
|
||||
{preview.duplicates > 0 && <> · already in the book, skipped: {preview.duplicates}</>}
|
||||
{preview.noNumber > 0 && <> · without a phone number: {preview.noNumber}</>}
|
||||
</p>
|
||||
{preview.errors.length > 0 && (
|
||||
<ul className="list-disc pl-5 text-amber-800 dark:text-amber-300">
|
||||
{preview.errors.map((e) => (
|
||||
<li key={e}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{run.error && <Alert>{run.error.message}</Alert>}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean) => void }) {
|
||||
const people = Object.values(useLive((s) => s.extensions)).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
const myNumber = useMe().data?.extension.number;
|
||||
const [everyone, setEveryone] = useState(false);
|
||||
const [to, setTo] = useState<Set<string>>(new Set());
|
||||
const share = useMutation({
|
||||
mutationFn: () => contactsApi.share({ contactIds: ids, everyone, extensions: [...to] }),
|
||||
});
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={`Share ${ids.length} contact${ids.length === 1 ? '' : 's'}`}
|
||||
onClose={() => onClose(share.isSuccess)}
|
||||
footer={
|
||||
share.isSuccess ? (
|
||||
<Button variant="primary" onClick={() => onClose(true)}>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={() => onClose(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={(!everyone && !to.size) || share.isPending}
|
||||
onClick={() => share.mutate()}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{share.isSuccess ? (
|
||||
<p role="status" className="text-sm">
|
||||
Sent to {share.data.people} {share.data.people === 1 ? 'person' : 'people'}. They'll see
|
||||
it under "Shared with you" and choose to accept or deny it.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">
|
||||
Each person gets a copy to accept into their own contacts, or deny.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={everyone}
|
||||
onChange={(e) => setEveryone(e.target.checked)}
|
||||
/>
|
||||
Everyone in the company
|
||||
</label>
|
||||
{!everyone &&
|
||||
people
|
||||
.filter((p) => p.number !== myNumber)
|
||||
.map((p) => (
|
||||
<label key={p.number} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={to.has(p.number)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(to);
|
||||
if (e.target.checked) next.add(p.number);
|
||||
else next.delete(p.number);
|
||||
setTo(next);
|
||||
}}
|
||||
/>
|
||||
{p.name} <span className="text-zinc-500">({p.number})</span>
|
||||
</label>
|
||||
))}
|
||||
{share.error && <Alert>{share.error.message}</Alert>}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pending offers from colleagues: nothing is added until Accept (11 §2.1). */
|
||||
export function useContactShares() {
|
||||
return useQuery({ queryKey: ['contacts', 'shares'], queryFn: contactsApi.shares });
|
||||
}
|
||||
|
||||
function SharedWithYou() {
|
||||
const qc = useQueryClient();
|
||||
const shares = useContactShares();
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ decision, ids }: { decision: 'accept' | 'deny'; ids: string[] }) =>
|
||||
contactsApi.decide(decision, ids),
|
||||
onSuccess: async (r) => {
|
||||
setResult(
|
||||
r.added !== undefined
|
||||
? `Added ${r.added}${r.duplicates ? `; ${r.duplicates} you already had` : ''}.`
|
||||
: `Denied ${r.denied}.`,
|
||||
);
|
||||
await qc.invalidateQueries({ queryKey: ['contacts'] });
|
||||
},
|
||||
});
|
||||
if (!shares.data?.length && !result) return null;
|
||||
const bySender = new Map<string, typeof shares.data>();
|
||||
for (const s of shares.data ?? []) {
|
||||
const k = s.from?.name ?? 'Someone who left';
|
||||
bySender.set(k, [...(bySender.get(k) ?? []), s]);
|
||||
}
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="shared-h"
|
||||
className="flex flex-col gap-3 rounded-lg border border-sky-200 bg-sky-50 p-3 dark:border-sky-900 dark:bg-sky-950"
|
||||
>
|
||||
<h2 id="shared-h" className="text-lg font-semibold">
|
||||
Shared with you
|
||||
</h2>
|
||||
{result && (
|
||||
<p role="status" className="text-sm">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
{[...bySender].map(([from, list]) => (
|
||||
<div key={from} className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">From {from}</span>
|
||||
{list!.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
className="px-2 py-1"
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ decision: 'accept', ids: list!.map((s) => s.id) })}
|
||||
>
|
||||
Accept all {list!.length}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1"
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ decision: 'deny', ids: list!.map((s) => s.id) })}
|
||||
>
|
||||
Deny all
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{list!.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="flex-1">
|
||||
{s.contact.name}
|
||||
{s.contact.company && (
|
||||
<span className="text-zinc-500"> · {s.contact.company}</span>
|
||||
)}
|
||||
{s.contact.numbers.map((n) => (
|
||||
<span key={n.number} className="text-zinc-500">
|
||||
{' '}
|
||||
· {show(n.number)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="px-2 py-1"
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ decision: 'accept', ids: [s.id] })}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
className="px-2 py-1"
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ decision: 'deny', ids: [s.id] })}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{decide.error && <Alert>{decide.error.message}</Alert>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow
|
||||
// with their milestones (M5–M7).
|
||||
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
|
||||
@@ -16,6 +15,7 @@ import { MyCallsPage, otherParty, useCallBack } from './MyCallsPage.tsx';
|
||||
import { fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
||||
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
||||
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
||||
|
||||
const link = 'text-sky-700 underline dark:text-sky-400';
|
||||
|
||||
@@ -34,6 +34,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'settings'] });
|
||||
}, [myDnd, qc]);
|
||||
const toasts = useLive((s) => s.toasts);
|
||||
const offers = useContactShares().data?.length ?? 0;
|
||||
|
||||
const tab = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
||||
@@ -78,6 +79,15 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<NavLink to="/me/voicemail" className={tab}>
|
||||
Voicemail
|
||||
</NavLink>
|
||||
<NavLink to="/me/contacts" className={tab}>
|
||||
Contacts
|
||||
{offers > 0 && (
|
||||
<span className="ml-1 rounded-full bg-sky-600 px-1.5 text-xs text-white">
|
||||
<span className="sr-only">, shared with you: </span>
|
||||
{offers}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
<NavLink to="/me/settings" className={tab}>
|
||||
Settings
|
||||
</NavLink>
|
||||
@@ -111,6 +121,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<Route index element={<Home user={user} />} />
|
||||
<Route path="calls" element={<MyCallsPage />} />
|
||||
<Route path="voicemail" element={<VoicemailPage />} />
|
||||
<Route path="contacts" element={<ContactsPage />} />
|
||||
<Route path="settings" element={<MeSettingsPage />} />
|
||||
<Route path="people" element={<PresencePanel me={me} />} />
|
||||
</Routes>
|
||||
@@ -127,9 +138,11 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
function Home({ user }: { user: AuthUser }) {
|
||||
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
||||
const fresh = list.data?.filter(isNew) ?? [];
|
||||
const nameOf = useContactName();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p>Hi {user.displayName}.</p>
|
||||
<SharedNotice />
|
||||
{user.extension && <StatusCard ext={user.extension.number} />}
|
||||
{user.extension && <MissedCalls />}
|
||||
{user.extension && <ParkedOnHome />}
|
||||
@@ -142,7 +155,7 @@ function Home({ user }: { user: AuthUser }) {
|
||||
<ul className="flex flex-col gap-1">
|
||||
{fresh.slice(0, 5).map((m) => (
|
||||
<li key={m.id} className="text-sm">
|
||||
<span className="font-semibold">{callerOf(m)}</span>{' '}
|
||||
<span className="font-semibold">{nameOf(m.callerNumber) ?? callerOf(m)}</span>{' '}
|
||||
<span className="text-zinc-500">
|
||||
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
|
||||
</span>
|
||||
@@ -164,6 +177,21 @@ function Home({ user }: { user: AuthUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
|
||||
function SharedNotice() {
|
||||
const offers = useContactShares().data ?? [];
|
||||
if (!offers.length) return null;
|
||||
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
|
||||
return (
|
||||
<p role="status" className="rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950">
|
||||
{from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
|
||||
<Link className={link} to="/me/contacts">
|
||||
Accept or deny
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Do not disturb and forwarding at a glance (05 §4 Home). */
|
||||
function StatusCard({ ext }: { ext: string }) {
|
||||
const me = useMe();
|
||||
@@ -243,6 +271,7 @@ function MissedCalls() {
|
||||
const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() });
|
||||
const { call, note } = useCallBack();
|
||||
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
|
||||
const nameOf = useContactName();
|
||||
return (
|
||||
<section aria-labelledby="missed-h" className="flex flex-col gap-2">
|
||||
<h2 id="missed-h" className="text-lg font-semibold">
|
||||
@@ -259,7 +288,9 @@ function MissedCalls() {
|
||||
const o = otherParty(c);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<span className="font-semibold">{o.name ?? fmtNumber(o.number)}</span>{' '}
|
||||
<span className="font-semibold">
|
||||
{nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
|
||||
</span>{' '}
|
||||
<span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '}
|
||||
{o.number && (
|
||||
<button
|
||||
|
||||
@@ -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,116 @@ 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 options = useMutation({ mutationFn: meApi.contrackOptions, 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>
|
||||
)}
|
||||
{contrack.tokenPrefix && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contrack.logCalls}
|
||||
disabled={options.isPending}
|
||||
onChange={(e) => options.mutate({ logCalls: e.target.checked })}
|
||||
/>
|
||||
Put my calls on my contacts' timelines in Contrack
|
||||
</label>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={contrack.logSummaries && contrack.summariesAvailable}
|
||||
disabled={!contrack.logCalls || !contrack.summariesAvailable || options.isPending}
|
||||
onChange={(e) => options.mutate({ logSummaries: e.target.checked })}
|
||||
/>
|
||||
<span>
|
||||
With the summary and action items of each recorded call
|
||||
{!contrack.summariesAvailable && (
|
||||
<span className="text-zinc-500"> (when your admin turns on call summaries)</span>
|
||||
)}
|
||||
<span className="block text-xs text-zinc-500">
|
||||
What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
|
||||
reach that provider.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{!contrack.tokenPrefix && (
|
||||
<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 ?? options.error) && (
|
||||
<Alert>{(connect.error ?? disconnect.error)!.message}</Alert>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My calls (05 §4): my call history; click the other number to call back (the softphone when it's
|
||||
// on, else my desk phone rings first). "Add to contacts" arrives with contacts (M10).
|
||||
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts".
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { MyCallDto } from '@selfphoneng/shared';
|
||||
@@ -8,6 +8,7 @@ import { meApi, recordingsApi } from '../../api/client.ts';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
import { softphone } from '../../softphone/SoftphoneEngine.ts';
|
||||
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { AddToContacts, useContactName } from './ContactsPage.tsx';
|
||||
|
||||
/** The other side of the call, from where I sit. */
|
||||
export const otherParty = (c: MyCallDto) =>
|
||||
@@ -36,6 +37,7 @@ export function MyCallsPage() {
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
});
|
||||
const { call, note } = useCallBack();
|
||||
const nameOf = useContactName();
|
||||
const calls = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
return (
|
||||
<section aria-labelledby="calls-h" className="flex flex-col gap-3">
|
||||
@@ -52,6 +54,7 @@ export function MyCallsPage() {
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{calls.map((c) => {
|
||||
const other = otherParty(c);
|
||||
const known = nameOf(other.number);
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
@@ -62,7 +65,7 @@ export function MyCallsPage() {
|
||||
<span
|
||||
className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`}
|
||||
>
|
||||
{other.name && <span>{other.name} </span>}
|
||||
{(known ?? other.name) && <span>{known ?? other.name} </span>}
|
||||
{other.number ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -78,6 +81,9 @@ export function MyCallsPage() {
|
||||
{c.direction !== 'internal' && (
|
||||
<span className="text-zinc-500"> · {DIRECTION_TEXT[c.direction]}</span>
|
||||
)}
|
||||
{!known && other.number && c.direction !== 'internal' && (
|
||||
<AddToContacts number={other.number} name={other.name} />
|
||||
)}
|
||||
</span>
|
||||
{c.recordingId && (
|
||||
<audio
|
||||
|
||||
@@ -5,15 +5,16 @@ import { useRef, useState } from 'react';
|
||||
import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared';
|
||||
import { meApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog } from '../../components/Dialog.tsx';
|
||||
import { AddToContacts, useContactName } from './ContactsPage.tsx';
|
||||
import { Alert, Button, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
export const callerOf = (m: VoicemailDto) =>
|
||||
m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller');
|
||||
export const isNew = (m: VoicemailDto) => m.folder !== 'Old';
|
||||
/** Words in the transcript, caller name or number, ignoring case. */
|
||||
export const matches = (m: VoicemailDto, q: string) =>
|
||||
[m.transcript, m.callerName, m.callerNumber].some((f) =>
|
||||
/** Words in the transcript, caller name (or contact name) or number, ignoring case. */
|
||||
export const matches = (m: VoicemailDto, q: string, contact?: string) =>
|
||||
[m.transcript, m.callerName, m.callerNumber, contact].some((f) =>
|
||||
f?.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
@@ -33,7 +34,11 @@ function Messages() {
|
||||
const [q, setQ] = useState('');
|
||||
// ponytail: the whole mailbox is already loaded, so search filters it here; move to the API if
|
||||
// mailboxes ever get paged.
|
||||
const shown = q.trim() ? list.data?.filter((m) => matches(m, q)) : list.data;
|
||||
const nameOf = useContactName();
|
||||
const who = (m: VoicemailDto) => nameOf(m.callerNumber) ?? callerOf(m);
|
||||
const shown = q.trim()
|
||||
? list.data?.filter((m) => matches(m, q, nameOf(m.callerNumber)))
|
||||
: list.data;
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
|
||||
const markRead = useMutation({
|
||||
mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'),
|
||||
@@ -75,7 +80,7 @@ function Messages() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={isNew(m) ? 'font-semibold' : ''}>
|
||||
{callerOf(m)}
|
||||
{who(m)}
|
||||
{isNew(m) && (
|
||||
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
|
||||
New
|
||||
@@ -96,7 +101,7 @@ function Messages() {
|
||||
controls
|
||||
preload="none"
|
||||
src={meApi.audioUrl(m.id)}
|
||||
aria-label={`Voicemail from ${callerOf(m)}`}
|
||||
aria-label={`Voicemail from ${who(m)}`}
|
||||
className="w-full sm:w-64"
|
||||
onPlay={() => isNew(m) && !markRead.isPending && markRead.mutate(m.id)}
|
||||
/>
|
||||
@@ -110,6 +115,9 @@ function Messages() {
|
||||
<Button variant="ghost" onClick={() => setDeleting(m)}>
|
||||
Delete
|
||||
</Button>
|
||||
{m.callerNumber && !nameOf(m.callerNumber) && (
|
||||
<AddToContacts number={m.callerNumber} name={m.callerName} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -117,9 +125,7 @@ function Messages() {
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete voicemail"
|
||||
message={
|
||||
<p>Delete the message from {deleting && callerOf(deleting)}? This can't be undone.</p>
|
||||
}
|
||||
message={<p>Delete the message from {deleting && who(deleting)}? This can't be undone.</p>}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
|
||||
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
|
||||
import { useSoftphone } from './useSoftphone.ts';
|
||||
import { startRinging, stopRinging } from './sounds.ts';
|
||||
import { useContactName } from '../pages/user/ContactsPage.tsx';
|
||||
|
||||
const KEY = 'softphone.enabled';
|
||||
const remembered = () => {
|
||||
@@ -52,11 +53,14 @@ const STATUS = {
|
||||
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
|
||||
|
||||
/** Caller name: extensions (live presence) → the name the caller sent → the number. */
|
||||
/** Caller name: extensions (live presence) → my contacts and the company's → the name the caller
|
||||
* sent → the number (11 §1). */
|
||||
function useNameOf() {
|
||||
const exts = useLive((s) => s.extensions);
|
||||
const contact = useContactName();
|
||||
return (c: { remote: string; remoteName: string | null }) =>
|
||||
exts[c.remote]?.name ??
|
||||
contact(c.remote) ??
|
||||
c.remoteName ??
|
||||
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
|
||||
}
|
||||
|
||||
@@ -8,3 +8,10 @@
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
}
|
||||
|
||||
# Contrack (spec 11 §4), when you run deploy/contrack: uncomment, and set CONTRACK_HOSTNAME in .env
|
||||
# (its own DNS name; Contrack can't live under a path).
|
||||
# {$CONTRACK_HOSTNAME} {
|
||||
# encode zstd gzip
|
||||
# reverse_proxy 127.0.0.1:3210
|
||||
# }
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Contrack (spec 11 §4, admin guide: Contacts): the "Robust" contacts, its own stack beside the PBX
|
||||
# on the same server. Contrack is a separate open source program (AGPL-3.0) we talk to over HTTP.
|
||||
#
|
||||
# docker compose --env-file .env -f deploy/contrack/compose.yml up -d
|
||||
#
|
||||
# It listens on 127.0.0.1 only: the PBX (host networking) reaches it there, and people reach it
|
||||
# through the reverse proxy at CONTRACK_HOSTNAME (deploy/caddy/Caddyfile, deploy/nginx).
|
||||
# Then Settings → Contacts → Contrack, address http://127.0.0.1:3210.
|
||||
services:
|
||||
contrack:
|
||||
# Pinned: SelfPhoneNG is tested against this version's API (decision 0003).
|
||||
image: ghcr.io/arvarik/contrack:2.0.2
|
||||
restart: unless-stopped
|
||||
ports: ['127.0.0.1:3210:3210']
|
||||
environment:
|
||||
AUTH_REQUIRED: 'true' # everyone signs in; tokens need it
|
||||
TRUST_PROXY_HOPS: '1'
|
||||
PUBLIC_URL: https://${CONTRACK_HOSTNAME:?set CONTRACK_HOSTNAME in .env}
|
||||
# Contrack's own AI and address lookups are outside SelfPhoneNG's "nothing leaves the
|
||||
# server" rule. Uncomment to keep everything here (or point its AI at your local Ollama in
|
||||
# Contrack's Settings → AI):
|
||||
# AI_DISABLED: 'true'
|
||||
# GEOCODING_DISABLED: 'true'
|
||||
volumes:
|
||||
- contrack-data:/app/data # its database, uploads and its own backups: back this up too
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: 10m, max-file: '3' }
|
||||
|
||||
volumes:
|
||||
contrack-data:
|
||||
@@ -45,3 +45,19 @@ server {
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
|
||||
# Contrack (spec 11 §4), when you run deploy/contrack: its own name (it can't live under a path).
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# http2 on;
|
||||
# server_name contacts.example.com;
|
||||
# ssl_certificate /etc/letsencrypt/live/contacts.example.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/contacts.example.com/privkey.pem;
|
||||
# client_max_body_size 50m; # contact imports and attachments
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:3210;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
@@ -87,6 +87,7 @@ services:
|
||||
network_mode: host
|
||||
environment:
|
||||
PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME:?set in .env}
|
||||
CONTRACK_HOSTNAME: ${CONTRACK_HOSTNAME:-} # deploy/caddy/Caddyfile, when Contrack runs
|
||||
volumes:
|
||||
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./data/caddy:/data
|
||||
@@ -11,6 +11,7 @@ keeping the system safe, legal and up to date.
|
||||
6. [Updating, backups and monitoring](maintenance.md)
|
||||
7. [Analog phones (ATAs)](ata.md)
|
||||
8. [Local AI](ai.md): voicemail transcripts
|
||||
9. [Contacts](contacts.md): the builtin phonebook, or Contrack
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Contacts
|
||||
|
||||
Contacts put names on numbers: in call history, voicemail, the browser phone and desk phones
|
||||
while they ring, the call report email, and the directory on desk phones (each phone has its
|
||||
owner's contacts and the company's). Pick where they live
|
||||
in the setup wizard or in **Settings → Contacts**:
|
||||
|
||||
- **Simple**: a phonebook inside SelfPhoneNG. Nothing else to run.
|
||||
- **Contrack**: [Contrack](https://github.com/arvarik/contrack), a full contact and relationship
|
||||
manager, running next to the phone system as its own container.
|
||||
|
||||
Everyone has their own contacts, and there are **company contacts** that everyone sees (only
|
||||
admins change them, in **Admin → Contacts**).
|
||||
|
||||
## Simple
|
||||
|
||||
- **My Phone → Contacts**: add, edit, search, click a number to call. Calls and voicemails from a
|
||||
number that isn't a contact have **Add to contacts**.
|
||||
- **Import**: one or more `.vcf` files (exported from an iPhone, Android, Google Contacts,
|
||||
Outlook or Apple Contacts) or a CSV with a header row (`name, company, email, label1, number1,
|
||||
label2, number2, …`). You see what will be added first; contacts already there (same name and
|
||||
number) are skipped. Up to 5,000 contacts at a time.
|
||||
- **Share**: tick contacts, **Share**, pick colleagues or everyone. They get "Shared with you" at
|
||||
the top of their Contacts page and on Home, and choose **Accept** (a copy goes into their
|
||||
contacts) or **Deny**. Nothing is added to anyone's contacts without their click.
|
||||
|
||||
## Contrack
|
||||
|
||||
Contrack keeps each person's contacts in their own private Contrack account, and the company's in
|
||||
a Contrack account of its own. SelfPhoneNG reads them (names on calls, the directory on desk
|
||||
phones), adds to them ("Add to contacts", accepted shares), and links to them. Editing, importing,
|
||||
notes and the rest happen in Contrack.
|
||||
|
||||
### Set it up
|
||||
|
||||
1. Give it a DNS name of its own, such as `contacts.example.com` (Contrack can't live under a
|
||||
path of the phone system's name), pointing at this server. Set it in `.env`:
|
||||
|
||||
```bash
|
||||
CONTRACK_HOSTNAME=contacts.example.com
|
||||
```
|
||||
|
||||
2. Start it:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f deploy/contrack/compose.yml up -d
|
||||
```
|
||||
|
||||
It listens on `127.0.0.1:3210` only.
|
||||
3. Put it behind your reverse proxy: with the bundled Caddy, uncomment the Contrack block at the
|
||||
end of `deploy/caddy/Caddyfile` and restart Caddy; with your own nginx, see the end of
|
||||
`deploy/nginx/selfphoneng.conf.example`.
|
||||
4. Open `https://contacts.example.com`. The first visit creates the first (admin) account. Then,
|
||||
in Contrack's **Settings → Administration → Invitations**, invite each person, and make one
|
||||
account for the company, such as `company@example.com`.
|
||||
5. Signed in to Contrack as the company account: **Settings → Account → API tokens → Create
|
||||
token**. Under **Expires** choose **Never** (or 1 year, and replace it before then): the
|
||||
default is 90 days, and names stop showing when a token expires. **Read only** is enough;
|
||||
**Read and write** lets admins add company contacts from SelfPhoneNG.
|
||||
6. In SelfPhoneNG, **Settings → Contacts → Contrack**: address `http://127.0.0.1:3210`, the
|
||||
address people open (`https://contacts.example.com`), the company token, **Test**, **Save**.
|
||||
7. Each person: in Contrack, **Settings → Account → API tokens → Create token**, **Read and
|
||||
write**, expiring **Never** (or 1 year), then in SelfPhoneNG **My settings → Contacts**, paste
|
||||
it, **Connect**. My settings says so when a token stops working.
|
||||
|
||||
Names refresh from Contrack every 5 minutes (at once after SelfPhoneNG adds a contact). If
|
||||
Contrack is down, the last names it gave keep working.
|
||||
|
||||
### Calls on the timeline
|
||||
|
||||
Once connected, each person's calls with their Contrack contacts go on that contact's timeline in
|
||||
Contrack ("Incoming call", "Missed call, voicemail left", …, with the time on the phone and whether
|
||||
it was recorded), a minute or so after the call. In **My settings → Contacts** they can turn this
|
||||
off, or add each recorded call's summary and action items (when call summaries are on in
|
||||
**Settings → AI**); the first action item becomes a Contrack follow-up, due the next day. Calls
|
||||
with numbers that aren't in their Contrack aren't added; **Add to contacts** first. Only calls
|
||||
from the moment they connected are logged.
|
||||
|
||||
### Privacy
|
||||
|
||||
SelfPhoneNG only talks to Contrack on this server or the local network, and keeps the tokens
|
||||
encrypted. Contrack itself can use AI (cloud providers, or your local Ollama) and look addresses
|
||||
up on OpenStreetMap: those are Contrack's settings, outside SelfPhoneNG's "nothing leaves the
|
||||
server" rule. To keep everything here, set `AI_DISABLED` and `GEOCODING_DISABLED` in
|
||||
`deploy/contrack/compose.yml`, or point Contrack's AI at your local AI in its **Settings →
|
||||
Administration → AI**.
|
||||
|
||||
### Backups
|
||||
|
||||
SelfPhoneNG's backup doesn't include Contrack. Contrack makes its own snapshots in its
|
||||
`contrack-data` volume (its **Settings → Backups**); back up that volume too.
|
||||
|
||||
### Moving to Contrack with contacts already in SelfPhoneNG
|
||||
|
||||
Switching from Simple keeps everyone's contacts. Each person (and an admin, for the company
|
||||
contacts) sees **Copy them to Contrack** on their Contacts page until they're copied; contacts
|
||||
Contrack already has are skipped.
|
||||
|
||||
### Going back to Simple
|
||||
|
||||
**Settings → Contacts → Simple**. The builtin contacts are still there, and the Contrack tokens
|
||||
are kept in case you switch back.
|
||||
@@ -0,0 +1,15 @@
|
||||
# 0003 — Contrack for "Robust" contacts
|
||||
|
||||
Owner, 2026-10-23: contacts come in two kinds (spec 11), and the robust one is
|
||||
[Contrack](https://github.com/arvarik/contrack), a self-hosted contact and relationship manager.
|
||||
It already does what a phonebook would take years to grow into: notes, timelines, reminders,
|
||||
relationship tracking, duplicate merging, search, imports from Google, Apple and LinkedIn, and
|
||||
CardDAV sync. It's allowed because it is open source (AGPL-3.0-only) and runs as **its own
|
||||
program** (`deploy/contrack/compose.yml`, image pinned to 2.0.2) that SelfPhoneNG talks to over
|
||||
HTTP on 127.0.0.1: none of its code is in our dependency tree, and there is no license mixing.
|
||||
The risk is a young project with one maintainer: we use seven of its routes (`/healthz`,
|
||||
`/api/contacts` list and create, and in C5 the timeline routes), pin the version, and
|
||||
`apps/api/test/contrack.test.ts` keeps a fake of exactly those routes, and
|
||||
`apps/api/test/contrack-live.test.ts` runs the same flows against a real Contrack
|
||||
(`CONTRACK_LIVE_URL`, a fresh container; passed against 2.0.2 on 2026-10-23). A new Contrack
|
||||
version is a deliberate update: read its CHANGELOG, run the live test against it, bump the tag.
|
||||
@@ -76,7 +76,11 @@ create table users (
|
||||
totp_recovery_hashes text[] not null default '{}', -- sha256 of unused recovery codes (M11)
|
||||
totp_last_step bigint, -- last 30 s step signed in with: no replay (M11)
|
||||
must_change_password boolean not null default false,
|
||||
last_login_at timestamptz
|
||||
last_login_at timestamptz,
|
||||
contrack_token_enc text, -- their Contrack personal token (11 §4.3)
|
||||
contrack_log_calls boolean not null default true, -- calls on their Contrack timelines (11 §4.4)
|
||||
contrack_log_summaries boolean not null default false, -- …with recorded calls' summaries
|
||||
contrack_log_since timestamptz -- calls that ended after this
|
||||
);
|
||||
|
||||
create table sessions (
|
||||
@@ -449,6 +453,26 @@ create table voicemail_messages ( -- index of files Asterisk
|
||||
transcript text -- speech to text (10), when it's on
|
||||
);
|
||||
|
||||
create table contrack_call_log ( -- a call put on a Contrack timeline (11 §4.4)
|
||||
call_id uuid not null references calls(id) on delete cascade,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
interaction_id text, -- null: no contact has the number, or refused
|
||||
error text,
|
||||
logged_at timestamptz not null default now(),
|
||||
primary key (call_id, user_id)
|
||||
);
|
||||
|
||||
create table contact_shares ( -- a contact offered to a colleague (11 §2.1)
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
from_user_id uuid references users(id) on delete set null,
|
||||
to_user_id uuid not null references users(id) on delete cascade,
|
||||
source_id text not null, -- the contact's id in the sender's book
|
||||
contact jsonb not null, -- a copy: { name, company, email, numbers, notes }
|
||||
status text not null default 'pending' check (status in ('pending', 'accepted', 'denied')),
|
||||
decided_at timestamptz
|
||||
);
|
||||
create index on contact_shares (to_user_id) where status = 'pending';
|
||||
|
||||
create table contacts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
owner_user_id uuid references users(id) on delete cascade, -- null = company phonebook
|
||||
|
||||
@@ -389,16 +389,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
|
||||
|
||||
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
|
||||
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
|
||||
; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
|
||||
; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
|
||||
; never sees someone else's contact name.
|
||||
[sp-sub-dial-user]
|
||||
exten => s,1,Set(DS=)
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
|
||||
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
|
||||
same => n,GotoIf($["${DS}" = ""]?unavail)
|
||||
same => n,Dial(${DS},${ARG2},tx)
|
||||
same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
|
||||
same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
|
||||
same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
|
||||
same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
|
||||
same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
|
||||
same => n,Set(_SP_CNAM=)
|
||||
same => n,Return()
|
||||
same => n(plain),Dial(${DS},${ARG2},tx)
|
||||
same => n,Return()
|
||||
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
|
||||
same => n,Return()
|
||||
|
||||
; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
|
||||
; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
|
||||
[sp-sub-cnam]
|
||||
exten => s,1,Set(CURLOPT(conntimeout)=1)
|
||||
same => n,Set(CURLOPT(httptimeout)=2)
|
||||
same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
|
||||
|
||||
; ─── Pre-dial handler on each ringing leg: the name its phone shows.
|
||||
[sp-predial-cnam]
|
||||
exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
|
||||
same => n,Return()
|
||||
|
||||
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP/<ARG1>/<uri>. Not PJSIP_DIAL_CONTACTS: iOS
|
||||
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
|
||||
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
|
||||
|
||||
@@ -377,7 +377,7 @@ Auth & self:
|
||||
| GET | `/api/me/greetings/:kind/audio` | user | the wav |
|
||||
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
|
||||
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
|
||||
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | personal contacts |
|
||||
| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) |
|
||||
| POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45<queue>` |
|
||||
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/<ext>, Context: sp-from-ext, Exten: <normalized>, Priority: 1, CallerID: "<number>" <<ext>>, Async: true`. The user's phone rings first, then the call goes out |
|
||||
|
||||
@@ -401,7 +401,7 @@ Admin resources (all `requireAdmin`; standard CRUD = `GET /x`, `POST /x`, `GET /
|
||||
| `/api/moh-classes` | `POST {name}` (slug, not `default`), `DELETE /:id` (409 with `usedBy` while an extension or queue uses it), `POST /:id/files?name=&filename=` (raw file body, like sounds), `GET /:id/files/:fileId/audio`, `DELETE /:id/files/:fileId`. No rename: the name is the folder and is stored on extensions and queues. Extensions and queues may only pick `default` or an existing class (400) |
|
||||
| `/api/devices` | `GET /:id/config-preview` (rendered provisioning file, passwords masked). As built (M10-T1): body `{mac (any format), vendor, model, label, extensionId}`; the DTO carries `provisioningUrl` and the `provisioningPassword` (admins type it into the phone); MAC taken → 409; preview of a generic phone → 400 |
|
||||
| `/api/blocked-numbers` | |
|
||||
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) |
|
||||
| `/api/contacts` | company phonebook; `POST /import` CSV (`name,company,email,label1,number1,...`) or vCard (11 §3.1) |
|
||||
| `/api/webhooks` | `POST /:id/test`, `GET /:id/deliveries` |
|
||||
| `/api/api-tokens` | POST returns the token once |
|
||||
|
||||
@@ -457,6 +457,8 @@ Provisioning and inbound hooks (outside `/api`, no session):
|
||||
| `webhookDelivery` | every 10 s | send pending deliveries (§9) |
|
||||
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
|
||||
| `diskWatch` | hourly | if free disk < 10% → email admins once a day |
|
||||
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
|
||||
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
|
||||
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
|
||||
|
||||
Jobs must never overlap with themselves (use a running flag).
|
||||
|
||||
@@ -37,7 +37,7 @@ type ProvisionContext = {
|
||||
| `GET /prov/<mac>.cfg` | Yealink (also Polycom master, see §3.3) | MAC lowercase. Decide by `devices.vendor` |
|
||||
| `GET /prov/y0000000000<xx>.cfg` | Yealink common file | Return an empty valid file `#!version:1.0.0.1\n` |
|
||||
| `GET /prov/cfg<mac>.xml` | Grandstream | |
|
||||
| `GET /prov/phonebook/<mac>.xml` | both | company phonebook + extensions, vendor-specific XML |
|
||||
| `GET /prov/phonebook/<mac>.xml` | both | extensions + the phone owner's contacts + company phonebook (11 §6.2), vendor-specific XML |
|
||||
| `GET /prov/linphone/<token>.xml` | Linphone (§5) | token auth instead of Basic |
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -9,6 +9,9 @@ Legend: 📖 read · 🔨 build · ✅ done when
|
||||
---
|
||||
|
||||
## Backlog (owner's order, 2026-10-21)
|
||||
0. **Contacts** (owner, 2026-10-23: needed next), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work):
|
||||
Simple (builtin phonebook) or Robust (Contrack), picked in the setup wizard. The owner uses
|
||||
Contrack.
|
||||
1. **Grandstream HT802 V2 set up automatically** (step 3 of
|
||||
[Analog phone adapters](#analog-phone-adapters-owner-2026-10-21-wanted-the-owner-is-buying-an-ht802-v2)):
|
||||
several lines on one device and the `grandstream-ata.ts` template with the dial plan. Starts
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
# 11 — Contacts
|
||||
|
||||
Owner, 2026-10-23: contacts come in two kinds, picked in the setup wizard and changeable later in
|
||||
Settings → Contacts (`contacts.provider`):
|
||||
|
||||
- **Simple** (`builtin`): the `contacts` table (02), a phonebook inside SelfPhoneNG.
|
||||
- **Robust** (`contrack`): [Contrack](https://github.com/arvarik/contrack), a self-hosted contact
|
||||
and relationship manager, running beside the PBX. SelfPhoneNG reads names from it and writes each
|
||||
call to the contact's timeline. The owner's own install uses this.
|
||||
|
||||
Both kinds feed the same features, so the rest of SelfPhoneNG asks one question, "who is
|
||||
13125551234 for this user?", and doesn't care which one answers.
|
||||
|
||||
## 1. What contacts are used for (both kinds)
|
||||
|
||||
| Feature | Where | Today |
|
||||
|---|---|---|
|
||||
| My contacts: list, search, click to call, add/edit | `/me/contacts` (05 §4) | not built |
|
||||
| Company phonebook, CSV and vCard (.vcf) import | `/admin/contacts` (05 §3) | not built |
|
||||
| My contacts: CSV and vCard (.vcf) import | `/me/contacts` | not built |
|
||||
| Caller name: softphone ringing, call history, voicemail list, call report email, transcripts | 05 §5, 04 §7.3, 10 §3 | extensions → caller ID name → number |
|
||||
| "Add to contacts" from a call or voicemail | `/me/calls`, `/me/voicemail` | not built |
|
||||
| Transfer search (contacts + extensions) | softphone (05 §5) | extensions only |
|
||||
| Desk phone phonebook | provisioning (07) | extensions only |
|
||||
|
||||
Lookup order everywhere: **extensions → my contacts → company phonebook → caller ID name → number**.
|
||||
|
||||
## 2. The provider seam (`apps/api/src/contacts/`)
|
||||
|
||||
```ts
|
||||
type ContactHit = {
|
||||
name: string;
|
||||
company: string | null;
|
||||
url: string | null; // Contrack: the contact's page
|
||||
book: 'mine' | 'company';
|
||||
};
|
||||
|
||||
interface ContactsProvider {
|
||||
lookup(userId: string, number: string): Promise<ContactHit | null>; // normalized E.164-ish digits
|
||||
search(userId: string, q: string, limit: number): Promise<ContactDto[]>;
|
||||
list(userId: string, cursor?: string): Promise<ListResponse<ContactDto>>;
|
||||
create(userId: string, c: ContactInput): Promise<ContactDto>;
|
||||
update?(userId: string, id: string, c: ContactInput): Promise<ContactDto>; // builtin only
|
||||
remove?(userId: string, id: string): Promise<void>; // builtin only
|
||||
logCall?(userId: string, call: CallForLog): Promise<void>; // contrack only (§4.4)
|
||||
}
|
||||
```
|
||||
|
||||
- `builtin.ts` and `contrack.ts` implement it; `index.ts` picks one from `contacts.provider`.
|
||||
- `lookup` is **never a network call in the call path**. Contrack answers from a per-user cache
|
||||
(§4.3); builtin answers from one indexed query.
|
||||
- Numbers are compared after `normalizeNumber` (00 §8) on both sides, so "+1 (312) 555-1234",
|
||||
"312-555-1234" and "13125551234" match.
|
||||
- Owner, 2026-10-23: **every address book belongs to one owner**, in both modes, and the company is
|
||||
one more owner. Simple: the builtin table, `owner_user_id` = the user, or null for the company.
|
||||
Contrack: every Contrack account is private, so the company gets **its own Contrack account**
|
||||
(the admin sets its token, §4.3) and each user connects **their own** account. SelfPhoneNG does
|
||||
the sharing:
|
||||
- the company book is shown read-only to everyone (lookup, search, desk phones);
|
||||
- a personal book is private; its owner can share contacts from it (§2.1).
|
||||
|
||||
### 2.1 Sharing contacts (owner, 2026-10-23)
|
||||
|
||||
Sharing **offers a copy**: the recipient **accepts or denies** each shared contact. Accepted, it is
|
||||
imported into their own book, and from then on the two copies are separate (editing one doesn't
|
||||
change the other). Denied, nothing is added. Nothing lands in anyone's book without their click.
|
||||
Both modes.
|
||||
|
||||
- `/me/contacts`: select one or more contacts → **Share** → pick colleagues, or **Everyone in the
|
||||
company**. `POST /api/me/contacts/share { contactIds, userIds | everyone: true }` makes one
|
||||
pending offer per contact per recipient. The answer says how many offers went to how many people.
|
||||
- The offer holds a **copy taken when it was shared** (name, company, email, numbers; not notes or
|
||||
a Contrack timeline), so the sender editing or deleting the contact later doesn't change it:
|
||||
|
||||
```sql
|
||||
create table contact_shares (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
from_user_id uuid references users(id) on delete set null,
|
||||
to_user_id uuid not null references users(id) on delete cascade,
|
||||
contact jsonb not null, -- { name, company, email, numbers: [{ label, number }] }
|
||||
status text not null default 'pending' check (status in ('pending', 'accepted', 'denied')),
|
||||
decided_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index on contact_shares (to_user_id) where status = 'pending';
|
||||
```
|
||||
|
||||
- The recipient sees **Shared with you** at the top of `/me/contacts` (a count on the Contacts
|
||||
link and a line on Home): "Dan Doe shared Acme Corp (312) 555-1234", with **Accept** and
|
||||
**Deny** on each, and **Accept all** / **Deny all** for one sender's batch.
|
||||
`GET /api/me/contacts/shares`, `POST /api/me/contacts/shares/accept|deny { ids }` (one id or
|
||||
a sender's batch).
|
||||
- Accept imports with the duplicate rule (§3.1: same name and same normalized number already in
|
||||
the book → skipped, and the answer says so); the offer is `accepted` either way. Deny marks it
|
||||
`denied`. Decided offers are deleted after 30 days; pending ones stay until decided.
|
||||
- The same contact offered again to someone with a pending offer of it replaces that offer, so a
|
||||
recipient never sees two of one contact.
|
||||
- Contrack mode: the copy is read with the sender's token when sharing; Accept creates it with the
|
||||
recipient's token. A recipient without a connected Contrack account, or with a read-only token,
|
||||
can't accept yet: the offer says "Connect Contrack to accept" and waits.
|
||||
- Only users, not the company book: an admin adds to the company book directly.
|
||||
|
||||
## 3. Simple (builtin)
|
||||
|
||||
The `contacts` table already in 02. `numbers[].number` is stored normalized (as forwards are,
|
||||
04 §5.5) and shown with `formatNumber`. Lookup (`contacts/book.ts` `contactNames`) scans the two
|
||||
books' numbers with `jsonb_array_elements`; a GIN index when books reach tens of thousands.
|
||||
|
||||
As built (C1): `GET /api/me/contacts` answers my whole book and the company's in one list (no
|
||||
paging); the page searches it in the browser, and the browser looks up names from it (call
|
||||
history, voicemail, Home, the ringing softphone). The call report email looks up on the server.
|
||||
|
||||
API (04 §7.3, §7.4, as already listed there):
|
||||
|
||||
| Route | Who | Does |
|
||||
|---|---|---|
|
||||
| `GET/POST/PATCH/DELETE /api/me/contacts[/:id]` | user | my contacts; `GET ?q=` searches name, company, number digits |
|
||||
| `GET/POST/PATCH/DELETE /api/contacts[/:id]` | admin | company phonebook |
|
||||
| `POST /api/contacts/import` | admin | CSV `name,company,email,label1,number1,...` (04 §7.4); preview, then commit |
|
||||
|
||||
UI: `/me/contacts` (mine + company read-only, search, click to call, add/edit drawer, import,
|
||||
share), `/admin/contacts` (table, drawer, import), "Add to contacts" on calls and voicemail.
|
||||
|
||||
### 3.1 Import: CSV and vCard (owner, 2026-10-23)
|
||||
|
||||
Admins import into the company book, users into their own; same code, same screen.
|
||||
|
||||
- `POST /api/contacts/import` (admin) and `POST /api/me/contacts/import` (user): `{ files: [{ name,
|
||||
text }] }`, up to 20 files read as text in the browser (no multipart), `.csv` (04 §7.4 columns)
|
||||
or `.vcf` (any number of cards per file). 6 MB and 5,000 contacts per import. `?commit=0`
|
||||
answers a preview, `?commit=1` saves.
|
||||
- vCard 2.1, 3.0 and 4.0, read by our own parser (`contacts/vcard.ts`, no new dependency): line
|
||||
unfolding, `QUOTED-PRINTABLE` and `CHARSET` (2.1), escaped `\,` `\;` `\n`, `item1.` groups.
|
||||
Fields: `FN` (or `N` when `FN` is empty), `ORG`, first `EMAIL`, every `TEL` with its `TYPE` as
|
||||
the label (cell → mobile, work, home, fax, other), `NOTE`. Everything else (photos, addresses,
|
||||
birthdays) is ignored.
|
||||
- Preview: how many are new, how many are already in that book (same name and same normalized
|
||||
number: skipped), how many have no phone number (imported anyway: they still have a name and
|
||||
email), and the first rows that failed and why. Nothing is saved until **Import**.
|
||||
- Contrack mode: SelfPhoneNG's import buttons open Contrack's own import (it reads Apple, Google,
|
||||
LinkedIn and vCard exports), for the company account too.
|
||||
|
||||
## 4. Robust (Contrack)
|
||||
|
||||
### 4.1 What Contrack is (checked 2026-10-23, v2.0.2 of 2026-10-18)
|
||||
|
||||
- TypeScript (Node ≥ 26.10), React, SQLite with sqlite-vec, **AGPL-3.0-only**. One Docker image,
|
||||
`ghcr.io/arvarik/contrack`, port 3210, data in `/app/data`.
|
||||
- REST API under `/api` (OpenAPI 3.1 in its `docs/openapi.json`), plus MCP.
|
||||
- Multi-user: every account has its own private address book. Credentials: the session cookie,
|
||||
or a **personal token** `Authorization: Bearer ctk_…` (read-only or read-write, made in
|
||||
Settings → Account). Tokens need sign-in on (`AUTH_REQUIRED=true`).
|
||||
- Contacts carry `phones: [{ phone, label, isPrimary }]` (free text such as "+49 30 1234567").
|
||||
- **Timeline**: `POST /api/contacts/:id/interactions` `{ type: 'call', title, content (HTML), date,
|
||||
duration (minutes), actionItem? }`; `PATCH /api/interactions/:id` changes `title`/`content`.
|
||||
A future `date` is refused.
|
||||
- Its own AI (Gemini/OpenAI/Anthropic, or an OpenAI-compatible server such as our Ollama), web
|
||||
research (SearXNG), connectors (CardDAV, IMAP, calendar, Google), backups, CSV/vCard-style
|
||||
imports.
|
||||
|
||||
Endpoints SelfPhoneNG uses, and nothing else: `GET /healthz`, `GET /api/contacts?view=slim`,
|
||||
`GET /api/contacts/:id`, `POST /api/contacts`, `GET /api/search?q=`,
|
||||
`POST /api/contacts/:id/interactions`, `PATCH /api/interactions/:id`. A contract test checks them
|
||||
against the pinned version's `openapi.json`.
|
||||
|
||||
### 4.2 Running it
|
||||
|
||||
- `deploy/contrack/compose.yml`, its own stack like `deploy/ai/compose.yml`: the image **pinned to
|
||||
a version tag** (not `latest`, decision 0001), `127.0.0.1:3210:3210`, a `contrack-data`
|
||||
volume, and `AUTH_REQUIRED=true`, `TRUST_PROXY_HOPS=1`, `PUBLIC_URL=https://${CONTRACK_HOSTNAME}`.
|
||||
- People open Contrack on **its own host name** (`PUBLIC_URL` takes no path), e.g.
|
||||
`contacts.example.com`. The Caddyfile gets a second site block when `CONTRACK_HOSTNAME` is set;
|
||||
the nginx example gets a second server block. As built: both are commented blocks to uncomment
|
||||
(a Caddy site with an empty name would fail), and `docker compose --env-file .env -f
|
||||
deploy/contrack/compose.yml up -d` (a compose file in a subfolder reads no `.env` by itself).
|
||||
- The PBX reaches it at `http://127.0.0.1:3210`. `contacts.contrack.url` must pass the same
|
||||
local-address check as the AI endpoints (10 §1), so contact data and tokens stay on the network.
|
||||
- Backups: Contrack keeps its own snapshots in its volume. The SelfPhoneNG backup (pg_dump) does
|
||||
not include it; the admin guide says to back up the volume too.
|
||||
- `docs/decisions/0003-contrack.md`: why an outside service (it already does relationships,
|
||||
timelines, imports, CardDAV sync, dedupe and search far beyond a phonebook), why it's allowed
|
||||
(AGPL, open source, a separate program over HTTP, so no license mixing and no code in our
|
||||
dependency tree), and the risk (one maintainer, young project: pinned version + contract test).
|
||||
|
||||
### 4.3 Connecting users
|
||||
|
||||
- Admin, Settings → Contacts (`contacts.provider`, `contacts.contrack` { url, publicUrl,
|
||||
companyTokenEnc }): **Test** (`POST /api/contacts/contrack/test`: `/healthz`, then the token
|
||||
against `/api/contacts?view=slim`; Contrack's `/api/auth/me` refuses tokens, so a token is
|
||||
checked by reading contacts).
|
||||
- Admin, same page: the **company Contrack account's token** (read-only is enough; read-write lets
|
||||
"Add to company contacts" work), write-only like the SMTP password (`hasCompanyToken` in GET).
|
||||
The admin makes that account in Contrack (e.g. `company@…`) and fills it like any other.
|
||||
- Contrack tokens expire in 90 days unless made with **Never**: the guide and the forms say to pick
|
||||
Never (or a year).
|
||||
- Each user, My settings → Contacts: **paste a Contrack personal token** (read-write; read-only
|
||||
allowed, then nothing is written to the timeline). `PUT /api/me/contrack { token }` checks it
|
||||
reads contacts, then stores it with `encrypt()` in `users.contrack_token_enc` (contacts are a
|
||||
person's, not an extension's); `DELETE` disconnects. Never logged, never returned: `GET /api/me`
|
||||
has `contrack: { connected, tokenPrefix: "ctk_AbCdEfGh…", publicUrl }`.
|
||||
- Why not have the PBX create Contrack accounts: Contrack's admin routes refuse tokens
|
||||
(`SESSION_REQUIRED`), so the PBX would have to keep an admin password. A pasted token is
|
||||
explicit, revocable from Contrack, and scoped to one person.
|
||||
- **Lookup cache** (`contacts/contrack.ts`): per connected user and for the company account,
|
||||
`GET /api/contacts?view=slim`, every phone through `normalizeNumber` (text that isn't a number
|
||||
is left out), held in memory. As built: fetched on use when older than 5 minutes (no timer),
|
||||
forgotten when SelfPhoneNG adds a contact or the token changes. A failing Contrack keeps the
|
||||
last good list and the error, and is tried again 5 minutes later; a refused token (401) shows as
|
||||
"doesn't work any more" in My settings. No error email.
|
||||
- `/me/contacts` in this mode: search and list from Contrack (read-only rows, "Open in Contrack"
|
||||
link to `${publicUrl}/contact/:id`), plus the company book. "Add to contacts" does
|
||||
`POST /api/contacts { name, company, emails, phones: [{ phone, label }] }`. Editing, deleting
|
||||
and importing answer 409 in this mode: they happen in Contrack. Desk phone directories (07) get
|
||||
the company account's contacts. Accepting a share creates the copy with the recipient's token;
|
||||
without one it answers 409 and the offer waits.
|
||||
|
||||
### 4.4 Calls on the Contrack timeline
|
||||
|
||||
New per-user options (My settings → Contacts, shown when connected):
|
||||
|
||||
- **Log my calls in Contrack** (default on): each of my calls with a number that matches one of my
|
||||
Contrack contacts becomes a `call` interaction on that contact:
|
||||
- title: "Incoming call (answered)", "Missed call", "Outgoing call", "Voicemail left" …
|
||||
- date: `calls.started_at`; duration: talk minutes, rounded up; content: number, who answered,
|
||||
"recorded" (escaped HTML).
|
||||
- **Add the call summary** (default off, needs `ai.callSummaries`): the summary and action items
|
||||
(10 §3) go in the content; the first action item also becomes a Contrack follow-up
|
||||
(`actionItem`). Warning next to it: if Contrack's own AI uses a cloud provider, what was said on
|
||||
the call reaches it. The admin guide recommends `AI_DISABLED=true` or our Ollama for Contrack.
|
||||
- Job `contrackLog` (every 60 s, 04 §8): calls ended more than 1 min ago and not logged yet. A
|
||||
recorded call waits for its summary (`recordings.ai_done_at`) up to 30 minutes when the summary
|
||||
option is on. One row per logged call:
|
||||
|
||||
```sql
|
||||
create table contrack_call_log (
|
||||
call_id uuid not null references calls(id) on delete cascade,
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
interaction_id text, -- null: no matching contact, or not logged
|
||||
logged_at timestamptz not null default now(),
|
||||
primary key (call_id, user_id)
|
||||
);
|
||||
```
|
||||
|
||||
Contrack unreachable: try again next run, give up after 2 days (like call transcripts).
|
||||
- Unknown numbers are not added to Contrack automatically; "Add to contacts" does it, and calls
|
||||
from then on are logged.
|
||||
|
||||
As built (C5, `contacts/timeline.ts`):
|
||||
|
||||
- Options on `users` (`contrack_log_calls`, `contrack_log_summaries`, `contrack_log_since`),
|
||||
`PATCH /api/me/contrack { logCalls?, logSummaries? }`, shown in `GET /api/me` `contrack` with
|
||||
`summariesAvailable`. Calls count from `contrack_log_since`: set when the token is first
|
||||
connected, and when logging is turned back on, so connecting doesn't fill the timeline with the
|
||||
last 2 days. Migration 0011 sets it to the migration time for people connected before.
|
||||
- Calls between colleagues (`internal`) aren't logged. Every person on the call (caller, rang,
|
||||
answered) gets it on their own timeline, as in My calls, with their own contact.
|
||||
- Titles: "Incoming call", "Incoming call, answered by Sue", "Missed call", "Missed call, voicemail
|
||||
left", "Outgoing call", "Outgoing call, busy" / "no answer". Content: `<p>From … to … · 3:12 ·
|
||||
recorded</p>`, then the summary's sentences and an `<ul>` of its action items, all escaped.
|
||||
`source: "selfphoneng"`. Contrack requires a due date on a follow-up: the day after the call,
|
||||
in the company time zone.
|
||||
- `contrack_call_log` also has `error`: a refused write (4xx other than 401/429: a read-only
|
||||
token, a deleted contact, a bad request) is noted once and not retried. Contrack down (5xx,
|
||||
unreachable), or a token it refuses (401): tried again on the next run.
|
||||
- Proven against Contrack 2.0.2 (`contrack-live.test.ts`): the entry, its follow-up, and the
|
||||
escaped content arrive. Contrack answers `duration` as a string ("4.0").
|
||||
|
||||
### 4.5 Not in Contrack mode
|
||||
|
||||
- Editing a Contrack contact from SelfPhoneNG.
|
||||
- A contact in several books is shown once per book; lookup takes the first in §1's order.
|
||||
|
||||
## 5. Setup wizard and switching
|
||||
|
||||
- New step **Contacts**, after Users (05 §2.1): two cards.
|
||||
- **Simple**: "A phonebook inside SelfPhoneNG: names, numbers, CSV import. Nothing else to run."
|
||||
- **Contrack**: "A full contact and relationship manager next to the phone system: notes,
|
||||
reminders, relationship tracking, imports from Google/Apple/LinkedIn, CardDAV sync. Every call
|
||||
lands on the contact's timeline. Runs as its own container." Then the URL field, Test, and the
|
||||
two commands to start `deploy/contrack/compose.yml`. Skippable: "Set it up later" keeps Simple.
|
||||
- Settings → Contacts can switch later. Builtin personal contacts are kept, not deleted; switching
|
||||
to Contrack offers **Copy my contacts to Contrack** per user (`POST /api/contacts/bulk`,
|
||||
≤ 5,000), switching back just shows the builtin table again.
|
||||
|
||||
## 6. Order of work
|
||||
|
||||
| Task | What | Done when |
|
||||
|---|---|---|
|
||||
| C1 | Seam + Simple: migration, builtin provider, routes, `/me/contacts`, `/admin/contacts`, CSV and vCard import (§3.1), sharing (§2.1), "Add to contacts", lookup everywhere in §1 except desk phones | a contact's name shows in call history, voicemail, call report and the ringing softphone; an iPhone and a Google .vcf export import |
|
||||
| C2 | Company book on desk phones (07) | a Yealink shows company contacts in its directory. Already built in M10-T2 (`loadPhonebook`, the builtin company book); C3 adds the Contrack company account to it |
|
||||
| C3 | Contrack: decision 0003, `deploy/contrack`, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step | with the owner's Contrack, a call from a Contrack contact shows their name |
|
||||
| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book. Done, see §6.1 |
|
||||
| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary. Done, see §4.4 as built |
|
||||
| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional. Done, see §6.2 |
|
||||
|
||||
### 6.1 As built: C4
|
||||
|
||||
- `sp-sub-dial-user` (03 §6.2), for calls from outside only: `sp-sub-cnam` asks
|
||||
`GET /api/internal/caller-name?ext=&number=&token=` with `func_curl` (connect 1 s, answer 2 s).
|
||||
A name goes on each ringing leg with a pre-dial handler, `CONNECTEDLINE(name,i)` (what a
|
||||
PJSIP phone shows in the INVITE's From), not on the call: the next ring group member, a queue
|
||||
agent or a forward does its own lookup in its own user's book, and never sees another's.
|
||||
- The prefix of a number, ring group or queue ("Sales:") is kept in front:
|
||||
`__SP_CNAM_PREFIX` grows the same way `CALLERID(name)` does.
|
||||
- Globals `SP_CNAM_URL` (http://127.0.0.1:API_PORT/…) and `SP_CNAM_TOKEN` (HMAC-SHA256 of
|
||||
APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this
|
||||
machine without `X-Forwarded-For` (not through the reverse proxy) and with the key, and isn't
|
||||
logged (the key is in the query). Names come back as letters, digits, spaces and `. ' & @ -`,
|
||||
at most 40 characters, so nothing can be read as dialplan or SIP syntax.
|
||||
- It never waits on Contrack: a stale book answers what it has and refreshes in the background,
|
||||
and the `contactsWarm` job refreshes every connected book every 5 minutes.
|
||||
- `apps/api/test/asterisk/cnam-test.ts` proves it on real Asterisk with a baresip phone: a
|
||||
contact's name, a stranger's provider name, "Sales: Jane Doe" through a ring group, 101's
|
||||
contact name not reaching 102 after a no-answer forward, and the phone still ringing (with the
|
||||
provider's name) when the api hangs.
|
||||
|
||||
### 6.2 As built: C6
|
||||
|
||||
- A phone's directory (`/prov/phonebook/<mac>.xml`, 07) is the extensions, then **its owner's own
|
||||
contacts** (accepted shares are among them), then the company's, each by name, from the
|
||||
builtin books or Contrack. A phone whose extension has no user gets the extensions and the
|
||||
company only.
|
||||
- Contrack mode: `GET /api/me/contacts` (and `/api/contacts`) says in `contrack.toCopy` how many
|
||||
builtin contacts that book's Contrack account doesn't have yet (same name and number), and the
|
||||
page offers **Copy them to Contrack**: `POST /api/me/contacts/copy-to-contrack` (admin:
|
||||
`/api/contacts/copy-to-contrack`, audited) sends them in one `POST /api/contacts/bulk` (with an
|
||||
`X-Import-Id`). The builtin rows stay, for switching back; once copied the offer is gone.
|
||||
Contrack's own duplicate check then runs on the import as usual.
|
||||
- Proven against Contrack 2.0.2 (`contrack-live.test.ts`).
|
||||
|
||||
Tests: builtin lookup/search/import against the test DB; contrack provider against a fake HTTP
|
||||
server built from the pinned `openapi.json`; one e2e script against a real Contrack container.
|
||||
|
||||
## 7. Decided
|
||||
|
||||
- Owner, 2026-10-23: caller name on desk phones while ringing is its own task, C4, after C3.
|
||||
- Owner, 2026-10-23: in Contrack mode the company book is a company Contrack account shared with
|
||||
everyone; each user has their own private Contrack account.
|
||||
- Owner, 2026-10-23: sharing offers a copy to picked colleagues or everyone, in both modes; each
|
||||
recipient accepts (it goes into their own book) or denies (nothing is added). An import or an
|
||||
accepted share skips contacts already in the book (§2.1, §3.1).
|
||||
- Owner, 2026-10-23: admins and users can import .vcf files (§3.1).
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Contacts (spec 11): my book, the company book, imports and shares.
|
||||
import { z } from 'zod';
|
||||
|
||||
const optionalText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((s) => s || null);
|
||||
|
||||
export const ContactInputSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Enter a name').max(200),
|
||||
company: optionalText(200),
|
||||
email: optionalText(200).refine((s) => !s || z.email().safeParse(s).success, 'Not an email'),
|
||||
/** Any way of writing a number; stored normalized (00 §8). */
|
||||
numbers: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().trim().max(30).default(''),
|
||||
number: z.string().trim().min(1).max(40),
|
||||
}),
|
||||
)
|
||||
.max(20)
|
||||
.default([]),
|
||||
notes: optionalText(5000),
|
||||
});
|
||||
export type ContactInput = z.input<typeof ContactInputSchema>;
|
||||
|
||||
export type ContactNumber = { label: string; number: string };
|
||||
|
||||
/** A contact as stored, and as a share offer carries it. */
|
||||
export type ContactData = {
|
||||
name: string;
|
||||
company: string | null;
|
||||
email: string | null;
|
||||
numbers: ContactNumber[];
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type ContactDto = ContactData & {
|
||||
/** A uuid, or a Contrack contact id such as jane-smith-f60e8536. */
|
||||
id: string;
|
||||
/** mine: I edit it; company: read-only for users (admins edit it in Admin → Contacts). */
|
||||
book: 'mine' | 'company';
|
||||
/** Contrack: the contact's page there (editing happens in Contrack). */
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
/** GET /api/me/contacts and /api/contacts: the book(s), and where they live. */
|
||||
export type ContactBookDto = {
|
||||
items: ContactDto[];
|
||||
provider: 'builtin' | 'contrack';
|
||||
/** Contrack mode: the address people open, and whether this book's token works. */
|
||||
contrack: {
|
||||
publicUrl: string | null;
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
/** Contacts still only in the builtin book (from before Contrack): "Copy them" (11 §5). */
|
||||
toCopy: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/** POST …/contacts/copy-to-contrack (11 §5, C6). */
|
||||
export type ContactCopyResult = { added: number; duplicates: number; failed: number };
|
||||
|
||||
/** POST …/contacts/import: the files' text, read in the browser. */
|
||||
export const ContactImportSchema = z.object({
|
||||
files: z
|
||||
.array(z.object({ name: z.string().max(200), text: z.string() }))
|
||||
.min(1)
|
||||
.max(20),
|
||||
});
|
||||
|
||||
export type ContactImportResult = {
|
||||
committed: boolean;
|
||||
added: number;
|
||||
/** Same name and number already in the book (or earlier in the files): skipped. */
|
||||
duplicates: number;
|
||||
/** Added without a phone number. */
|
||||
noNumber: number;
|
||||
/** The first problems: a card or row that was skipped, or a number that was dropped. */
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export const ContactShareSchema = z
|
||||
.object({
|
||||
contactIds: z.array(z.string().min(1).max(100)).min(1).max(500),
|
||||
/** Colleagues by extension number. */
|
||||
extensions: z.array(z.string().max(20)).max(500).default([]),
|
||||
everyone: z.boolean().default(false),
|
||||
})
|
||||
.refine((s) => s.everyone || s.extensions.length, 'Pick who to share with');
|
||||
export type ContactShare = z.input<typeof ContactShareSchema>;
|
||||
|
||||
export const ContactShareDecisionSchema = z.object({ ids: z.array(z.uuid()).min(1).max(1000) });
|
||||
|
||||
/** A contact someone shared with me, waiting for Accept or Deny (11 §2.1). */
|
||||
export type ContactShareDto = {
|
||||
id: string;
|
||||
from: { userId: string; name: string } | null;
|
||||
contact: ContactData;
|
||||
createdAt: string;
|
||||
};
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user