diff --git a/.env.example b/.env.example
index 2be5d22..5835676 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index b8f0fab..58d0544 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 42e8262..ee254f6 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -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);
diff --git a/apps/api/src/contacts/book.ts b/apps/api/src/contacts/book.ts
new file mode 100644
index 0000000..f764580
--- /dev/null
+++ b/apps/api/src/contacts/book.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
}
+ confirmLabel="Delete"
+ busy={remove.isPending}
+ error={remove.error?.message}
+ onCancel={() => setDeleting(null)}
+ onConfirm={() => deleting && remove.mutate(deleting.id)}
+ />
+
+ );
+}
+
+/** 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 (
+
+
+ {count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
+ {count === 1 ? "isn't" : "aren't"} in {base === COMPANY ? "the company's" : 'your'} Contrack
+ yet.
+
+
+ {copy.error &&
{copy.error.message}}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => void }) {
+ const qc = useQueryClient();
+ const [files, setFiles] = useState<{ name: string; text: string }[]>([]);
+ const [preview, setPreview] = useState(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 (
+
+ );
+}
+
+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>(new Set());
+ const share = useMutation({
+ mutationFn: () => contactsApi.share({ contactIds: ids, everyone, extensions: [...to] }),
+ });
+ return (
+
+ );
+}
+
+/** 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(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();
+ for (const s of shares.data ?? []) {
+ const k = s.from?.name ?? 'Someone who left';
+ bySender.set(k, [...(bySender.get(k) ?? []), s]);
+ }
+ return (
+
+
+ Shared with you
+
+ {result && (
+
+ {result}
+
+ )}
+ {[...bySender].map(([from, list]) => (
+
+
+ From {from}
+ {list!.length > 1 && (
+ <>
+
+
+ >
+ )}
+
+
+ {list!.map((s) => (
+ -
+
+ {s.contact.name}
+ {s.contact.company && (
+ · {s.contact.company}
+ )}
+ {s.contact.numbers.map((n) => (
+
+ {' '}
+ · {show(n.number)}
+
+ ))}
+
+
+
+
+ ))}
+
+
+ ))}
+ {decide.error && {decide.error.message}}
+
+ );
+}
diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx
index d0f84a0..2baee2b 100644
--- a/apps/web/src/pages/user/MePage.tsx
+++ b/apps/web/src/pages/user/MePage.tsx
@@ -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 }) {
Voicemail
+
+ Contacts
+ {offers > 0 && (
+
+ , shared with you:
+ {offers}
+
+ )}
+
Settings
@@ -111,6 +121,7 @@ export function MePage({ user }: { user: AuthUser }) {
} />
} />
} />
+ } />
} />
} />
@@ -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 (
Hi {user.displayName}.
+
{user.extension &&
}
{user.extension &&
}
{user.extension &&
}
@@ -142,7 +155,7 @@ function Home({ user }: { user: AuthUser }) {