Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95561f651f | ||
|
|
53aefaa5d7 | ||
|
|
604e40adf1 | ||
|
|
e6a1373f36 | ||
|
|
b05361e840 | ||
|
|
692dd59552 | ||
|
|
a754974a90 | ||
|
|
32f4590369 | ||
|
|
20d27232ac | ||
|
|
4c006f2388 | ||
|
|
46cc8c2002 |
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
|
||||
|
||||
|
||||
@@ -5,6 +5,39 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
|
||||
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
|
||||
Docker.
|
||||
|
||||
## v0.0.7 — 2026-10-23
|
||||
|
||||
**Before you update:** this version changes the database (call reports, contacts); the api backs
|
||||
up first, by itself. Portainer: change both tags to `0.0.7` and redeploy. Compose:
|
||||
`sudo scripts/update.sh v0.0.7`. Contacts start in Simple mode with empty address books;
|
||||
Contrack is optional and off until you set it up (admin guide: [Contacts](docs/admin-guide/contacts.md)).
|
||||
Desk phones pick up their new directory at their next daily check (or reboot them).
|
||||
|
||||
- **Contacts** (spec 11, admin guide: [Contacts](docs/admin-guide/contacts.md)): each person's own
|
||||
contacts and the company's, in My Phone → Contacts and Admin → Contacts. Import `.vcf` files
|
||||
(iPhone, Android, Google, Outlook, Apple) or CSV, with a preview; duplicates are skipped. Share
|
||||
contacts with colleagues or everyone: they Accept or Deny each one, and nothing lands in their
|
||||
contacts without it. Contact names show in call history, voicemail, Home, the browser phone,
|
||||
and the call report email, with **Add to contacts** on calls and voicemails from numbers that
|
||||
aren't contacts yet.
|
||||
- **Caller names on desk phones while they ring**: the caller's name from the ringing person's
|
||||
own contacts (or the company's), per phone, so nobody sees a colleague's private contact names;
|
||||
ring group and queue prefixes ("Sales:") stay in front.
|
||||
- **Desk phone directories** now have the phone owner's own contacts, after the extensions and
|
||||
before the company's.
|
||||
- **Contrack** (optional): the setup wizard (new step 8, Contacts) and Settings → Contacts offer
|
||||
[Contrack](https://github.com/arvarik/contrack), a self-hosted contact and relationship manager,
|
||||
as a second container (`deploy/contrack/compose.yml`, pinned to 2.0.2). Each person connects
|
||||
their own Contrack account in My settings → Contacts, and the company has one everyone sees.
|
||||
Calls go on the contact's Contrack timeline, optionally with the call summary and its action
|
||||
items (the first becomes a follow-up); contacts from before Contrack can be copied over.
|
||||
- **Call report email** (My settings → Call report): your calls every hour, day or week, at the
|
||||
time you pick (company time zone), with whether each was recorded and, optionally, each
|
||||
recorded call's summary. No calls, no email.
|
||||
- **Voicemail search**: search your messages by what was said (the transcript) or by the caller.
|
||||
- Database changes: call report columns on `extensions`; `contact_shares`, `contrack_call_log`,
|
||||
and Contrack columns on `users` (the api backs up first).
|
||||
|
||||
## v0.0.6 — 2026-10-21
|
||||
|
||||
**Before you update:** this version changes the database (call transcripts); the api backs up
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
|
||||
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
|
||||
|
||||
**Status:** pre-1.0 (v0.0.6), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
**Status:** pre-1.0 (v0.0.7), for testing. See [what's left for v1](docs/v1-checklist.md).
|
||||
|
||||
## What it does today
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/api",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,6 @@
|
||||
CREATE TYPE "public"."call_report" AS ENUM('off', 'hourly', 'daily', 'weekly');--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report" "call_report" DEFAULT 'off' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_time" text DEFAULT '08:00' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_weekday" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD COLUMN "call_report_sent_at" timestamp with time zone;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -57,6 +57,34 @@
|
||||
"when": 1792626762332,
|
||||
"tag": "0007_recording_transcripts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"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,
|
||||
});
|
||||
|
||||
@@ -102,6 +109,7 @@ export const outboundClass = pgEnum('outbound_class', [
|
||||
'international',
|
||||
]);
|
||||
export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']);
|
||||
export const callReport = pgEnum('call_report', ['off', 'hourly', 'daily', 'weekly']);
|
||||
|
||||
export const extensions = pgTable('extensions', {
|
||||
id: id(),
|
||||
@@ -128,6 +136,13 @@ export const extensions = pgTable('extensions', {
|
||||
language: text().notNull().default('en'),
|
||||
mohClass: text().notNull().default('default'),
|
||||
blfNumbers: emptyTextArray(),
|
||||
/** The call report email (05 §4): how often, at what local time (HH:MM) and weekday (0 = Sunday). */
|
||||
callReport: callReport().notNull().default('off'),
|
||||
callReportTime: text().notNull().default('08:00'),
|
||||
callReportWeekday: integer().notNull().default(1),
|
||||
callReportSummaries: boolean().notNull().default(false),
|
||||
/** The end of the last period reported on (or when the schedule last changed). */
|
||||
callReportSentAt: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -612,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', {
|
||||
|
||||
+38
-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,10 +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';
|
||||
@@ -107,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');
|
||||
}
|
||||
|
||||
@@ -237,7 +242,35 @@ export function createJobs(d: JobDeps) {
|
||||
);
|
||||
};
|
||||
|
||||
return { daily, backup, publicIpWatch, trunkWatch, diskWatch };
|
||||
/** 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() });
|
||||
};
|
||||
|
||||
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. */
|
||||
@@ -259,6 +292,9 @@ export function startJobs(d: JobDeps): () => void {
|
||||
run('trunkWatch', j.trunkWatch);
|
||||
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'`),
|
||||
),
|
||||
);
|
||||
+100
-3
@@ -1,12 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3): the signed-in user's own mailbox.
|
||||
// /api/me (settings, call report), /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3).
|
||||
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';
|
||||
@@ -111,9 +120,88 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
voicemailEmail: e.voicemailEmail,
|
||||
voicemailAttachAudio: e.voicemailAttachAudio,
|
||||
voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail,
|
||||
callReport: e.callReport,
|
||||
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.
|
||||
@@ -144,6 +232,11 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
if (body.voicemailPin && isTrivialPin(body.voicemailPin, ext.number))
|
||||
throw badRequest('That PIN is too easy to guess (repeated, sequential or your extension)');
|
||||
const { dnd: _d, cfu: _u, cfb: _b, cfna: _n, voicemailPin, ...vm } = body;
|
||||
// A new call report schedule starts with its next period, not one that just ended.
|
||||
const rescheduled =
|
||||
body.callReport !== undefined ||
|
||||
body.callReportTime !== undefined ||
|
||||
body.callReportWeekday !== undefined;
|
||||
await db.transaction(async (tx) => {
|
||||
if (Object.keys(runtime).length)
|
||||
await tx
|
||||
@@ -153,7 +246,11 @@ export async function meRoutes(app: FastifyInstance) {
|
||||
if (Object.keys(vm).length || voicemailPin)
|
||||
await tx
|
||||
.update(extensions)
|
||||
.set({ ...vm, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) })
|
||||
.set({
|
||||
...vm,
|
||||
...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}),
|
||||
...(rescheduled ? { callReportSentAt: new Date() } : {}),
|
||||
})
|
||||
.where(eq(extensions.id, ext.id));
|
||||
});
|
||||
// Offline Asterisk catches up from PostgreSQL on its next connect.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The call report email (05 §4): each user's calls of the last hour, day or week, with each
|
||||
// recorded call's summary if they asked for it. No calls, no email.
|
||||
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';
|
||||
import { getSetting } from './settings.ts';
|
||||
|
||||
/** Calls are written a few seconds after hang-up and summarized a minute or two after. */
|
||||
const LAG_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* The last whole period that ended by `now`, in the time zone `tz`: e.g. daily at 08:00 is
|
||||
* yesterday 08:00 to today 08:00. Local-time arithmetic in PostgreSQL, so DST days are 23 or 25 h.
|
||||
*/
|
||||
export async function lastPeriod(
|
||||
db: Db,
|
||||
tz: string,
|
||||
s: { callReport: Exclude<CallReport, 'off'>; callReportTime: string; callReportWeekday: number },
|
||||
now: Date,
|
||||
): Promise<{ from: Date; to: Date }> {
|
||||
const r = await db.execute<{ from: Date; to: Date }>(sql`
|
||||
with l as (select (${now.toISOString()}::timestamptz at time zone ${tz}::text) as t),
|
||||
s as (
|
||||
select t, case ${s.callReport}::text
|
||||
when 'hourly' then date_trunc('hour', t)
|
||||
when 'daily' then t::date + ${s.callReportTime}::time
|
||||
else t::date - ((extract(dow from t)::int - ${s.callReportWeekday}::int + 7) % 7)
|
||||
+ ${s.callReportTime}::time
|
||||
end as at,
|
||||
case ${s.callReport}::text
|
||||
when 'hourly' then interval '1 hour'
|
||||
when 'daily' then interval '1 day'
|
||||
else interval '7 days'
|
||||
end as p
|
||||
from l),
|
||||
e as (select case when at > t then at - p else at end as at, p from s)
|
||||
select (at - p) at time zone ${tz}::text as "from", at at time zone ${tz}::text as "to" from e`);
|
||||
const row = r.rows[0]!;
|
||||
return { from: new Date(row.from), to: new Date(row.to) };
|
||||
}
|
||||
|
||||
const duration = (s: number) =>
|
||||
s >= 3600
|
||||
? `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
: `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
/** "102" stays as is; outside numbers get their usual format. */
|
||||
const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'unknown');
|
||||
|
||||
export type ReportCall = {
|
||||
startedAt: Date;
|
||||
out: boolean;
|
||||
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;
|
||||
recorded: boolean;
|
||||
summary: string | null;
|
||||
summaryPending: boolean;
|
||||
};
|
||||
|
||||
/** The email's text: one block per call, oldest first. */
|
||||
export function reportText(
|
||||
tz: string,
|
||||
period: { from: Date; to: Date },
|
||||
list: ReportCall[],
|
||||
withSummaries: boolean,
|
||||
): string {
|
||||
const when = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
const lines = [
|
||||
`${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)}${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),
|
||||
...(c.recorded ? ['recorded'] : []),
|
||||
];
|
||||
lines.push('', `${when.format(c.startedAt)} ${who}`, ` ${facts.join(' · ')}`);
|
||||
if (withSummaries && c.summary)
|
||||
lines.push(
|
||||
...c.summary
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`),
|
||||
);
|
||||
else if (withSummaries && c.summaryPending)
|
||||
lines.push(' Summary not ready yet: see Recordings in the web portal.');
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
const answerer = alias(extensions, 'answerer');
|
||||
|
||||
/** One run: every user whose period ended and wasn't reported on yet. */
|
||||
export async function sendCallReports(d: {
|
||||
db: Db;
|
||||
send: SendMail;
|
||||
log: { error(o: object, m: string): void };
|
||||
now: Date;
|
||||
}): Promise<number> {
|
||||
const tz = await getSetting(d.db, 'general.timezone');
|
||||
const asOf = new Date(d.now.getTime() - LAG_MS);
|
||||
const due = await d.db
|
||||
.select({ e: extensions, email: users.email })
|
||||
.from(extensions)
|
||||
.innerJoin(users, eq(users.id, extensions.userId))
|
||||
.where(and(ne(extensions.callReport, 'off'), eq(users.isActive, true)));
|
||||
let sent = 0;
|
||||
for (const { e, email } of due) {
|
||||
if (e.callReport === 'off') continue;
|
||||
const period = await lastPeriod(d.db, tz, { ...e, callReport: e.callReport }, asOf);
|
||||
if (e.callReportSentAt && e.callReportSentAt >= period.to) continue;
|
||||
// ponytail: by hang-up time, and only the last period: one that passed while the server
|
||||
// was down isn't caught up.
|
||||
const rows = await d.db
|
||||
.select({
|
||||
c: calls,
|
||||
answeredBy: answerer.name,
|
||||
answeredById: answerer.id,
|
||||
summary: recordings.summary,
|
||||
aiDoneAt: recordings.aiDoneAt,
|
||||
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
|
||||
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
|
||||
})
|
||||
.from(calls)
|
||||
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
|
||||
.leftJoin(recordings, eq(recordings.id, calls.recordingId))
|
||||
.where(
|
||||
and(
|
||||
gte(calls.endedAt, period.from),
|
||||
lt(calls.endedAt, period.to),
|
||||
inArray(
|
||||
calls.id,
|
||||
d.db
|
||||
.select({ id: callExtensions.callId })
|
||||
.from(callExtensions)
|
||||
.where(eq(callExtensions.extensionId, e.id)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(calls.startedAt));
|
||||
// Marked first: a failing mail server is logged once, not retried every minute.
|
||||
await d.db
|
||||
.update(extensions)
|
||||
.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,
|
||||
recorded: r.c.recordingId !== null,
|
||||
summary: r.summary,
|
||||
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
|
||||
}));
|
||||
const withSummaries =
|
||||
e.callReportSummaries &&
|
||||
(await getSetting(d.db, 'ai.callTranscripts')) &&
|
||||
(await getSetting(d.db, 'ai.callSummaries'));
|
||||
try {
|
||||
await d.send({
|
||||
to: [email],
|
||||
subject: `Your calls (${e.number}): ${list.length} call${list.length === 1 ? '' : 's'}`,
|
||||
text: reportText(tz, period, list, withSummaries),
|
||||
});
|
||||
sent++;
|
||||
} catch (err) {
|
||||
d.log.error({ err, extension: e.number }, 'call report not sent');
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -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,184 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The call report email (05 §4): its periods, and who gets what.
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { callExtensions, calls, extensions, recordings } from '../src/db/schema.ts';
|
||||
import { lastPeriod, sendCallReports } from '../src/services/callreport.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('call report email', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let dan: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let extId: string;
|
||||
const tz = 'America/Chicago';
|
||||
const period = (callReport: 'hourly' | 'daily' | 'weekly', now: string, at = '08:00', wd = 1) =>
|
||||
lastPeriod(
|
||||
t.db,
|
||||
tz,
|
||||
{ callReport, callReportTime: at, callReportWeekday: wd },
|
||||
new Date(now),
|
||||
).then((p) => [p.from.toISOString(), p.to.toISOString()]);
|
||||
const run = (now: string) =>
|
||||
sendCallReports({ db: t.db, send: t.app.sendMail, log: t.app.log, now: new Date(now) });
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
await setSetting(t.db, 'general.timezone', tz);
|
||||
const as = (cookie: string) => (method: string, url: string, payload?: unknown) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
const admin = as(await t.login(ADMIN.email, ADMIN.password));
|
||||
const danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
|
||||
extId = (
|
||||
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId })
|
||||
).json().id;
|
||||
dan = as(await t.login('dan@example.com', 'dan-password-123'));
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('periods end at the last scheduled local time, DST included', async () => {
|
||||
// CDT is UTC-5. 2026-10-23 is a Friday.
|
||||
expect(await period('hourly', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-23T13:00:00.000Z',
|
||||
'2026-10-23T14:00:00.000Z',
|
||||
]);
|
||||
expect(await period('daily', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-22T13:00:00.000Z',
|
||||
'2026-10-23T13:00:00.000Z',
|
||||
]);
|
||||
// 07:59 local: still yesterday's period.
|
||||
expect(await period('daily', '2026-10-23T12:59:00Z')).toEqual([
|
||||
'2026-10-21T13:00:00.000Z',
|
||||
'2026-10-22T13:00:00.000Z',
|
||||
]);
|
||||
// Weekly on Monday 08:00, asked on Friday: the week up to this Monday.
|
||||
expect(await period('weekly', '2026-10-23T14:25:00Z')).toEqual([
|
||||
'2026-10-12T13:00:00.000Z',
|
||||
'2026-10-19T13:00:00.000Z',
|
||||
]);
|
||||
// DST ends 02:00 Sunday 2026-11-01 (CDT to CST, UTC-6): Saturday 08:00 to Sunday 08:00 is 25 h.
|
||||
expect(await period('daily', '2026-11-01T15:00:00Z')).toEqual([
|
||||
'2026-10-31T13:00:00.000Z',
|
||||
'2026-11-01T14:00:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('PATCH /api/me saves the schedule, starting with the next period', async () => {
|
||||
const r = await dan('PATCH', '/api/me', {
|
||||
callReport: 'daily',
|
||||
callReportTime: '17:30',
|
||||
callReportSummaries: true,
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
expect(r.json()).toMatchObject({
|
||||
callReport: 'daily',
|
||||
callReportTime: '17:30',
|
||||
callReportWeekday: 1,
|
||||
callReportSummaries: true,
|
||||
});
|
||||
const [e] = await t.db.select().from(extensions).where(eq(extensions.id, extId));
|
||||
expect(e!.callReportSentAt).not.toBeNull();
|
||||
expect((await dan('PATCH', '/api/me', { callReportTime: '25:00' })).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('emails the calls of the period once, and nothing for a period without calls', async () => {
|
||||
await dan('PATCH', '/api/me', { callReport: 'daily', callReportTime: '08:00' });
|
||||
await t.db.update(extensions).set({ callReportSentAt: null }).where(eq(extensions.id, extId));
|
||||
await setSetting(t.db, 'ai.callTranscripts', true);
|
||||
await setSetting(t.db, 'ai.callSummaries', true);
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'r1',
|
||||
filePath: '2026/10/22/r1.wav',
|
||||
startedAt: new Date('2026-10-22T15:00:00Z'),
|
||||
summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote',
|
||||
aiDoneAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [inCall, outCall, before] = await t.db
|
||||
.insert(calls)
|
||||
.values([
|
||||
{
|
||||
linkedid: 'c1',
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-22T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-22T15:03:12Z'),
|
||||
fromNumber: '13125551234',
|
||||
fromName: 'Acme',
|
||||
toNumber: '13125550000',
|
||||
talkSeconds: 192,
|
||||
answeredByExtensionId: extId,
|
||||
recordingId: rec!.id,
|
||||
},
|
||||
{
|
||||
linkedid: 'c2',
|
||||
direction: 'outbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-22T20:00:00Z'),
|
||||
endedAt: new Date('2026-10-22T20:00:45Z'),
|
||||
fromNumber: '101',
|
||||
toNumber: '18005550100',
|
||||
talkSeconds: 40,
|
||||
},
|
||||
{
|
||||
linkedid: 'c3', // the period before
|
||||
direction: 'inbound',
|
||||
status: 'missed',
|
||||
startedAt: new Date('2026-10-21T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-21T15:00:20Z'),
|
||||
fromNumber: '13125559999',
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values([
|
||||
{ callId: inCall!.id, extensionId: extId, role: 'answered' },
|
||||
{ callId: outCall!.id, extensionId: extId, role: 'caller' },
|
||||
{ callId: before!.id, extensionId: extId, role: 'rang' },
|
||||
]);
|
||||
|
||||
t.mails.length = 0;
|
||||
// Today 08:00 CDT is 13:00Z; the report waits a few minutes for the last calls and summaries.
|
||||
expect(await run('2026-10-23T13:02:00Z')).toBe(1); // yesterday's period, never reported
|
||||
t.mails.length = 0;
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
expect(await run('2026-10-23T13:06:00Z')).toBe(1);
|
||||
expect(await run('2026-10-23T13:07:00Z')).toBe(0); // once
|
||||
expect(t.mails).toHaveLength(1);
|
||||
const m = t.mails[0]!;
|
||||
expect(m.to).toEqual(['dan@example.com']);
|
||||
expect(m.subject).toBe('Your calls (101): 2 calls');
|
||||
expect(m.text).toContain('Incoming from (312) 555-1234 (Acme) to (312) 555-0000');
|
||||
expect(m.text).toContain('answered · 3:12 · recorded');
|
||||
expect(m.text).toContain(' - Dan sends the quote');
|
||||
expect(m.text).toContain('Outgoing to (800) 555-0100');
|
||||
expect(m.text).toContain('answered · 0:40\n');
|
||||
expect(m.text).not.toContain('555-9999');
|
||||
expect(m.text.indexOf('Incoming')).toBeLessThan(m.text.indexOf('Outgoing'));
|
||||
|
||||
// The next day had no calls: no email.
|
||||
expect(await run('2026-10-24T13:06:00Z')).toBe(0);
|
||||
expect(t.mails).toHaveLength(1);
|
||||
|
||||
// Summaries off: the calls, not what was said.
|
||||
await dan('PATCH', '/api/me', { callReportSummaries: false });
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
await run('2026-10-23T13:06:00Z');
|
||||
expect(t.mails[1]!.text).not.toContain('quote');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -62,6 +62,11 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
|
||||
voicemailEmail: true,
|
||||
voicemailAttachAudio: true,
|
||||
voicemailDeleteAfterEmail: false,
|
||||
callReport: 'off',
|
||||
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: {
|
||||
@@ -48,6 +52,11 @@ const EXPECTED: Record<string, Record<string, string>> = {
|
||||
language: "text|NO|'en'::text",
|
||||
moh_class: "text|NO|'default'::text",
|
||||
blf_numbers: "ARRAY:_text|NO|'{}'::text[]",
|
||||
call_report: "USER-DEFINED:call_report|NO|'off'::call_report",
|
||||
call_report_time: "text|NO|'08:00'::text",
|
||||
call_report_weekday: 'integer|NO|1',
|
||||
call_report_summaries: 'boolean|NO|false',
|
||||
call_report_sent_at: 'timestamp with time zone|YES|',
|
||||
...ts,
|
||||
},
|
||||
trunks: {
|
||||
@@ -106,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) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/web",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options.
|
||||
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call
|
||||
// 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';
|
||||
import { Link } from 'react-router';
|
||||
import { formatNumber, type MeDto, type MeUpdate } from '@selfphoneng/shared';
|
||||
import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared';
|
||||
import { meApi } from '../../api/client.ts';
|
||||
import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx';
|
||||
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
|
||||
@@ -61,6 +62,8 @@ function SettingsForms({ me }: { me: MeDto }) {
|
||||
<Forwarding me={me} />
|
||||
</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
|
||||
@@ -196,3 +199,220 @@ function Voicemail({ me }: { me: MeDto }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const REPORTS: [CallReport, string][] = [
|
||||
['off', 'Off'],
|
||||
['hourly', 'Every hour'],
|
||||
['daily', 'Every day'],
|
||||
['weekly', 'Every week'],
|
||||
];
|
||||
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
|
||||
function CallReportForm({ me }: { me: MeDto }) {
|
||||
const save = useUpdateMe();
|
||||
const [form, setForm] = useState({
|
||||
callReport: me.callReport,
|
||||
callReportTime: me.callReportTime,
|
||||
callReportWeekday: me.callReportWeekday,
|
||||
callReportSummaries: me.callReportSummaries,
|
||||
});
|
||||
const on = form.callReport !== 'off';
|
||||
return (
|
||||
<section aria-labelledby="report-h" className="flex flex-col gap-3">
|
||||
<h2 id="report-h" className="text-lg font-semibold">
|
||||
Call report
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500">
|
||||
An email listing your calls: who, which numbers, how long, and if they were recorded. No
|
||||
calls, no email. It goes out a few minutes after each period ends, in the company time zone.
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate(form);
|
||||
}}
|
||||
>
|
||||
<Field label="Email me">
|
||||
{(p) => (
|
||||
<select
|
||||
{...p}
|
||||
className={`${inputClass} max-w-48`}
|
||||
value={form.callReport}
|
||||
onChange={(e) => setForm({ ...form, callReport: e.target.value as CallReport })}
|
||||
>
|
||||
{REPORTS.map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{form.callReport === 'weekly' && (
|
||||
<Field label="On">
|
||||
{(p) => (
|
||||
<select
|
||||
{...p}
|
||||
className={`${inputClass} max-w-48`}
|
||||
value={form.callReportWeekday}
|
||||
onChange={(e) => setForm({ ...form, callReportWeekday: Number(e.target.value) })}
|
||||
>
|
||||
{WEEKDAYS.map((d, i) => (
|
||||
<option key={d} value={i}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{(form.callReport === 'daily' || form.callReport === 'weekly') && (
|
||||
<Field label="At" hint="Covers the day (or week) up to this time">
|
||||
{(p) => (
|
||||
<input
|
||||
{...p}
|
||||
type="time"
|
||||
required
|
||||
className={`${inputClass} max-w-48`}
|
||||
value={form.callReportTime}
|
||||
onChange={(e) => setForm({ ...form, callReportTime: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
{on && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.callReportSummaries}
|
||||
onChange={(e) => setForm({ ...form, callReportSummaries: e.target.checked })}
|
||||
/>
|
||||
Add a summary of each recorded call
|
||||
<span className="text-xs text-zinc-500">(when your admin has call summaries on)</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save call report
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
|
||||
</div>
|
||||
{save.error && <Alert>{save.error.message}</Alert>}
|
||||
</form>
|
||||
</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
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My voicemail (05 §4): messages (play marks read, download, delete) and greetings.
|
||||
// My voicemail (05 §4): messages (play marks read, download, delete, search) and greetings.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { Alert, Button } from '../../components/ui.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 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()),
|
||||
);
|
||||
|
||||
export function VoicemailPage() {
|
||||
return (
|
||||
@@ -25,6 +31,14 @@ function Messages() {
|
||||
const qc = useQueryClient();
|
||||
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
||||
const [deleting, setDeleting] = useState<VoicemailDto | null>(null);
|
||||
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 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'),
|
||||
@@ -44,16 +58,29 @@ function Messages() {
|
||||
Voicemail
|
||||
</h2>
|
||||
{list.error && <Alert>{list.error.message}</Alert>}
|
||||
{!!list.data?.length && (
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search voicemail"
|
||||
placeholder="Search what was said, or the caller"
|
||||
className={`${inputClass} max-w-72`}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>}
|
||||
{!!list.data?.length && shown?.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No message mentions that.</p>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.data?.map((m) => (
|
||||
{shown?.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800"
|
||||
>
|
||||
<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
|
||||
@@ -74,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)}
|
||||
/>
|
||||
@@ -88,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>
|
||||
))}
|
||||
@@ -95,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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ on, each recorded call is also recorded one side at a time, and a minute or two
|
||||
|
||||
On the **Recordings** page each call shows its summary, **Show transcript**, and the search box
|
||||
finds calls by what was said. People who were on a call can read its transcript too, like they can
|
||||
play its recording.
|
||||
play its recording, and get its summary in their call report email (My settings → Call report).
|
||||
|
||||
- Only calls that are recorded are written out (user record modes, phone numbers, queues and
|
||||
conference rooms set to record), and only those recorded while it's on.
|
||||
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user