contacts: my book, the company book, vCard/CSV import, sharing with Accept/Deny

C1 (spec 11): /api/me/contacts and /api/contacts (admin), our own vCard 2.1/3.0/4.0 and CSV
readers with a preview, contact_shares (migration 0009) for offers, and contact names in call
history, voicemail, Home, the softphone and the call report email. Admin → Contacts.
This commit is contained in:
iamdoubz committed 2026-10-23 14:53:21 -05:00
1 parent 32f4590369
commit a754974a90
26 files changed
+6103 -23

No files matched your search

+2
View File
@@ -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);
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The builtin address books (11 §3): a user's (owner_user_id) and the company's (null).
// ponytail: builtin only. Contrack (C3) adds its provider here, behind the same functions.
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
import {
normalizeNumber,
type ContactData,
type ContactDto,
type ContactImportResult,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { contacts } from '../db/schema.ts';
import { badRequest } from '../errors.ts';
/** 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,
});
/** 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;
}
/** My contacts, then the company's, by name. */
export async function listBooks(db: Db, userId: string): Promise<ContactDto[]> {
const rows = await db
.select()
.from(contacts)
.where(or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId)))
.orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
return rows.map(toDto);
}
export async function listBook(db: Db, owner: Owner): Promise<ContactDto[]> {
return (await db.select().from(contacts).where(inBook(owner)).orderBy(asc(contacts.name))).map(
toDto,
);
}
/** 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). With `commit` false it only
* counts. Bad numbers are dropped and reported.
*/
export async function addToBook(
db: Pick<Db, 'select' | 'insert'>, // a transaction too
owner: Owner,
list: ContactData[],
commit: boolean,
errors: string[] = [],
): Promise<ContactImportResult> {
const have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
const fresh: ContactData[] = [];
let duplicates = 0;
for (const c of list) {
const numbers = cleanNumbers(c, (n) =>
errors.push(`${c.name}: "${n}" isn't a phone number, left out`),
);
const clean = { ...c, numbers };
if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
else fresh.push(clean);
}
// ponytail: one insert of up to 5,000 rows (the import cap).
if (commit && fresh.length)
await db.insert(contacts).values(fresh.map((c) => ({ ...c, ownerUserId: owner })));
return {
committed: commit,
added: fresh.length,
duplicates,
noNumber: fresh.filter((c) => !c.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).
*/
export async function contactNames(
db: Db,
userId: string | null,
numbers: (string | null)[],
): 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;
// 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;
}
+68
View File
@@ -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 };
}
+157
View File
@@ -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/);
});
});
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and
// the note. Everything else (photos, addresses, birthdays) is ignored.
import type { ContactData } from '@selfphoneng/shared';
type Prop = { group: string; name: string; params: Map<string, string[]>; value: string };
/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */
function parseParams(parts: string[]): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const p of parts) {
const eq = p.indexOf('=');
const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase();
const raw = eq < 0 ? p : p.slice(eq + 1);
const vals = raw.replace(/"/g, '').split(',').filter(Boolean);
// 2.1's bare QUOTED-PRINTABLE is an encoding, not a type.
const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key;
out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]);
}
return out;
}
/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */
function parseLine(line: string): Prop | null {
let quoted = false;
let colon = -1;
for (let i = 0; i < line.length; i++) {
if (line[i] === '"') quoted = !quoted;
else if (line[i] === ':' && !quoted) {
colon = i;
break;
}
}
if (colon < 0) return null;
const [head = '', ...params] = line.slice(0, colon).split(';');
const dot = head.lastIndexOf('.');
return {
group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(),
name: head.slice(dot + 1).toUpperCase(),
params: parseParams(params),
value: line.slice(colon + 1),
};
}
function decodeQp(v: string, charset: string): string {
const bytes: number[] = [];
for (let i = 0; i < v.length; i++) {
const hex = v.slice(i + 1, i + 3);
if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) {
bytes.push(parseInt(hex, 16));
i += 2;
} else bytes.push(v.charCodeAt(i) & 0xff);
}
try {
return new TextDecoder(charset).decode(new Uint8Array(bytes));
} catch {
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
}
}
/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */
function components(v: string, sep: ';' | ','): string[] {
const out = [''];
for (let i = 0; i < v.length; i++) {
const c = v[i]!;
if (c === '\\' && i + 1 < v.length) {
const n = v[++i]!;
out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n;
} else if (c === sep) out.push('');
else out[out.length - 1] += c;
}
return out.map((s) => s.trim());
}
const text = (v: string) => components(v, ';').join(';').trim();
/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */
function telLabel(types: string[], abLabel: string | undefined): string {
if (abLabel) {
const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim();
if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase();
}
if (types.includes('FAX')) return 'fax';
if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile';
if (types.includes('WORK')) return 'work';
if (types.includes('HOME')) return 'home';
if (types.includes('MAIN')) return 'main';
return 'other';
}
/** Every card in the text, in order. A card with no name, company or number is an error. */
export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } {
// Unfold: a line starting with a space or tab continues the one before (and 2.1
// quoted-printable continues a line that ends with "=").
const lines: string[] = [];
for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) {
const prev = lines.at(-1);
if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!))
lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, '');
else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1);
else lines.push(l);
}
const contacts: ContactData[] = [];
const errors: string[] = [];
let card: Prop[] | null = null;
for (const line of lines) {
const p = parseLine(line);
if (!p) continue;
if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = [];
else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) {
const c = toContact(card);
if (c) contacts.push(c);
else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`);
card = null;
} else if (card) {
const enc = p.params.get('ENCODING') ?? [];
if (enc.includes('QUOTED-PRINTABLE'))
p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8');
card.push(p);
}
}
return { contacts, errors };
}
function toContact(props: Prop[]): ContactData | null {
const first = (n: string) => props.find((p) => p.name === n)?.value;
const fn = first('FN') ? text(first('FN')!) : '';
const n = first('N');
const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n
? components(n, ';')
: [];
const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' ');
const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null;
const email = first('EMAIL') ? text(first('EMAIL')!) || null : null;
const abLabels = new Map(
props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]),
);
const numbers = props
.filter((p) => p.name === 'TEL')
.map((p) => ({
label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined),
number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(),
}))
.filter((t) => t.number);
const note = first('NOTE') ? text(first('NOTE')!) || null : null;
const name = fn || fromN || org || numbers[0]?.number;
if (!name) return null;
return { name, company: org, email, numbers, notes: note };
}
@@ -0,0 +1,16 @@
CREATE TABLE "contact_shares" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"from_user_id" uuid,
"to_user_id" uuid NOT NULL,
"source_id" text NOT NULL,
"contact" jsonb NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"decided_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied'))
);
--> statement-breakpoint
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending';
File diff suppressed because it is too large. Load diff
@@ -64,6 +64,13 @@
"when": 1792781321577,
"tag": "0008_call_reports",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1792784242837,
"tag": "0009_contact_shares",
"breakpoints": true
}
]
}
+25
View File
@@ -620,6 +620,31 @@ export const contacts = pgTable('contacts', {
...timestamps,
});
/** 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
View File
@@ -19,6 +19,7 @@ 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';
@@ -108,6 +109,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');
}
+274
View File
@@ -0,0 +1,274 @@
// 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 ContactData,
type ContactDto,
type ContactImportResult,
type ContactShareDto,
type ListResponse,
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import {
addToBook,
cleanNumbers,
listBook,
listBooks,
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';
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 [row] = await db
.insert(contacts)
.values({ ...c, numbers: cleanNumbers(c), ownerUserId: ownerOf(req.user!) })
.returning();
await log(req, 'create', row!.id, `Added ${c.name} to the company contacts`);
return reply.code(201).send(toDto(row!));
});
app.patch(`${prefix}/:id`, { preHandler: guard }, async (req): Promise<ContactDto> => {
const { id } = IdParams.parse(req.params);
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);
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> => {
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<ListResponse<ContactDto>> => ({
items: await listBooks(db, req.user!.id),
nextCursor: null,
}),
);
book('/api/me/contacts', requireAuth, (u) => u.id);
// ───── the company book ─────
app.get(
'/api/contacts',
{ preHandler: requireAdmin },
async (): Promise<ListResponse<ContactDto>> => ({
items: await listBook(db, null),
nextCursor: null,
}),
);
book('/api/contacts', requireAdmin, () => null);
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
const s = ContactShareSchema.parse(req.body);
const me = req.user!.id;
const picked = await db
.select()
.from(contacts)
.where(and(eq(contacts.ownerUserId, me), inArray(contacts.id, s.contactIds)));
if (picked.length !== s.contactIds.length) throw notFound('Contact not found');
const 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((c) => {
const { id: _i, book: _b, ...contact } = toDto(c);
return { fromUserId: me, toUserId: u.id, sourceId: c.id, contact };
}),
),
);
});
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;
return db.transaction(async (tx) => {
const offers = await tx
.update(contactShares)
.set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() })
.where(
and(
eq(contactShares.toUserId, me),
eq(contactShares.status, 'pending'),
inArray(contactShares.id, ids),
),
)
.returning();
if (decision === 'deny') return { denied: offers.length };
const r = await addToBook(
tx,
me,
offers.map((o) => o.contact as ContactData),
true,
);
return { added: r.added, duplicates: r.duplicates };
});
});
}
/** 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'`),
),
);
+12 -2
View File
@@ -4,6 +4,7 @@
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { formatNumber, type CallReport } from '@selfphoneng/shared';
import { contactNames } from '../contacts/book.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
import type { SendMail } from './mail.ts';
@@ -56,6 +57,8 @@ export type ReportCall = {
status: string;
fromNumber: string | null;
fromName: string | null;
/** The other side's name in my contacts or the company's (11 §1). */
contactName: string | null;
toNumber: string | null;
talkSeconds: number;
answeredBy: string | null;
@@ -80,9 +83,10 @@ export function reportText(
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
];
for (const c of list) {
const name = c.contactName ?? (c.out ? null : c.fromName);
const who = c.out
? `Outgoing to ${num(c.toNumber)}`
: `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`;
? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}`
: `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`;
const facts = [
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
duration(c.talkSeconds),
@@ -158,12 +162,18 @@ export async function sendCallReports(d: {
.set({ callReportSentAt: period.to })
.where(eq(extensions.id, e.id));
if (!rows.length) continue;
const names = await contactNames(
d.db,
e.userId,
rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)),
);
const list = rows.map((r): ReportCall => ({
startedAt: r.c.startedAt,
out: r.out,
status: r.c.status,
fromNumber: r.c.fromNumber,
fromName: r.c.fromName,
contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null,
toNumber: r.c.toNumber,
talkSeconds: r.c.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
+204
View File
@@ -0,0 +1,204 @@
// 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 { 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');
});
});
+2 -2
View File
@@ -111,11 +111,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 42 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: 42 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+28
View File
@@ -22,6 +22,11 @@ import type {
CallDetailDto,
CallDto,
CallFilter,
ContactDto,
ContactImportResult,
ContactInput,
ContactShare,
ContactShareDto,
MyCallDto,
RecordingDto,
AuthUser,
@@ -284,6 +289,29 @@ 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<ListResponse<ContactDto>>('GET', base).then((r) => r.items),
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`,
+3
View File
@@ -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' ? (
+1 -1
View File
@@ -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 },
+620
View File
@@ -0,0 +1,620 @@
// 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 ?? [])
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 shown = (list.data ?? []).filter(
(c) =>
!words ||
`${c.name} ${c.company ?? ''} ${c.email ?? ''}`.toLowerCase().includes(words) ||
(digits.length >= 3 && c.numbers.some((n) => n.number.includes(digits))),
);
const canShare = base === MINE;
const mine = (c: ContactDto) => base === COMPANY || c.book === 'mine';
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>
<Button onClick={() => setImporting(true)}>Import</Button>
{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>}
{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>
{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>
);
}
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>
);
}
+35 -4
View File
@@ -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
+8 -2
View File
@@ -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
+15 -9
View File
@@ -5,15 +5,16 @@ import { useRef, useState } from 'react';
import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared';
import { meApi } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { AddToContacts, useContactName } from './ContactsPage.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx';
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
export const callerOf = (m: VoicemailDto) =>
m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller');
export const isNew = (m: VoicemailDto) => m.folder !== 'Old';
/** Words in the transcript, caller name or number, ignoring case. */
export const matches = (m: VoicemailDto, q: string) =>
[m.transcript, m.callerName, m.callerNumber].some((f) =>
/** Words in the transcript, caller name (or contact name) or number, ignoring case. */
export const matches = (m: VoicemailDto, q: string, contact?: string) =>
[m.transcript, m.callerName, m.callerNumber, contact].some((f) =>
f?.toLowerCase().includes(q.trim().toLowerCase()),
);
@@ -33,7 +34,11 @@ function Messages() {
const [q, setQ] = useState('');
// ponytail: the whole mailbox is already loaded, so search filters it here; move to the API if
// mailboxes ever get paged.
const shown = q.trim() ? list.data?.filter((m) => matches(m, q)) : list.data;
const nameOf = useContactName();
const who = (m: VoicemailDto) => nameOf(m.callerNumber) ?? callerOf(m);
const shown = q.trim()
? list.data?.filter((m) => matches(m, q, nameOf(m.callerNumber)))
: list.data;
const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
const markRead = useMutation({
mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'),
@@ -75,7 +80,7 @@ function Messages() {
>
<div className="min-w-0 flex-1">
<p className={isNew(m) ? 'font-semibold' : ''}>
{callerOf(m)}
{who(m)}
{isNew(m) && (
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
New
@@ -96,7 +101,7 @@ function Messages() {
controls
preload="none"
src={meApi.audioUrl(m.id)}
aria-label={`Voicemail from ${callerOf(m)}`}
aria-label={`Voicemail from ${who(m)}`}
className="w-full sm:w-64"
onPlay={() => isNew(m) && !markRead.isPending && markRead.mutate(m.id)}
/>
@@ -110,6 +115,9 @@ function Messages() {
<Button variant="ghost" onClick={() => setDeleting(m)}>
Delete
</Button>
{m.callerNumber && !nameOf(m.callerNumber) && (
<AddToContacts number={m.callerNumber} name={m.callerName} />
)}
</div>
</li>
))}
@@ -117,9 +125,7 @@ function Messages() {
<ConfirmDialog
open={deleting !== null}
title="Delete voicemail"
message={
<p>Delete the message from {deleting && callerOf(deleting)}? This can't be undone.</p>
}
message={<p>Delete the message from {deleting && who(deleting)}? This can't be undone.</p>}
confirmLabel="Delete"
busy={remove.isPending}
error={remove.error?.message}
+5 -1
View File
@@ -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');
}