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:
1 parent
32f4590369
commit
a754974a90
26 files changed
+6103
-23
No files matched your search
@@ -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,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;
|
||||
}
|
||||
@@ -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,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// C1: vCard and CSV imports read what phones and address books export (11 §3.1).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCsv } from './csv.ts';
|
||||
import { parseVcards } from './vcard.ts';
|
||||
|
||||
const IPHONE = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'PRODID:-//Apple Inc.//iPhone OS 17.0//EN',
|
||||
'N:Doe;Jane;;;',
|
||||
'FN:Jane Doe',
|
||||
'ORG:Acme Corp;',
|
||||
'EMAIL;type=INTERNET;type=HOME;type=pref:jane@example.com',
|
||||
'TEL;type=CELL;type=VOICE;type=pref:(312) 555-1234',
|
||||
'item1.TEL:+1 312 555 0000',
|
||||
'item1.X-ABLabel:_$!<Main>!$_',
|
||||
'NOTE:Met at the fair\\, 2026\\nCall after 5',
|
||||
'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD',
|
||||
' /2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJ',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const GOOGLE = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Bob Smith
|
||||
N:Smith;Bob;;;
|
||||
TEL;TYPE=WORK:+13125559999
|
||||
TEL;TYPE=HOME:3125558888
|
||||
EMAIL;TYPE=INTERNET:bob@example.com
|
||||
ORG:Widgets Inc.
|
||||
item1.URL:http\\://example.com
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:;;;;
|
||||
TEL;TYPE=CELL:3125550101
|
||||
END:VCARD
|
||||
`;
|
||||
|
||||
const V21 = `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.;
|
||||
TEL;CELL:+49301234567
|
||||
TEL;WORK;FAX:+49301234500
|
||||
NOTE;ENCODING=QUOTED-PRINTABLE:Line one=0D=0ALine tw=
|
||||
o
|
||||
END:VCARD`;
|
||||
|
||||
const V40 = `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Carol Lee
|
||||
TEL;VALUE=uri;TYPE="voice,cell":tel:+1-312-555-7777
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
NOTE:nothing to call
|
||||
END:VCARD`;
|
||||
|
||||
describe('parseVcards', () => {
|
||||
it('reads an iPhone export: names, company, labels, note; skips the photo', () => {
|
||||
expect(parseVcards(IPHONE)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
company: 'Acme Corp',
|
||||
email: 'jane@example.com',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '(312) 555-1234' },
|
||||
{ label: 'main', number: '+1 312 555 0000' },
|
||||
],
|
||||
notes: 'Met at the fair, 2026\nCall after 5',
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a Google export, and names a card with only a number after it', () => {
|
||||
const { contacts } = parseVcards(GOOGLE);
|
||||
expect(contacts.map((c) => [c.name, c.company, c.numbers])).toEqual([
|
||||
[
|
||||
'Bob Smith',
|
||||
'Widgets Inc.',
|
||||
[
|
||||
{ label: 'work', number: '+13125559999' },
|
||||
{ label: 'home', number: '3125558888' },
|
||||
],
|
||||
],
|
||||
['3125550101', null, [{ label: 'mobile', number: '3125550101' }]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads vCard 2.1: quoted-printable UTF-8, bare types, soft line breaks', () => {
|
||||
const [c] = parseVcards(V21).contacts;
|
||||
expect(c).toMatchObject({
|
||||
name: 'Dr. Jürgen Müller',
|
||||
numbers: [
|
||||
{ label: 'mobile', number: '+49301234567' },
|
||||
{ label: 'fax', number: '+49301234500' },
|
||||
],
|
||||
notes: 'Line one\r\nLine two',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads vCard 4.0 tel: URIs, and reports a card with nothing to call', () => {
|
||||
expect(parseVcards(V40)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Carol Lee',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '+1-312-555-7777' }],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Card 2: no name or number, skipped'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('reads the 04 §7.4 columns in any order, with quotes and line breaks', () => {
|
||||
const csv =
|
||||
'Number1,Name,label1,company,notes,number2,label2\r\n' +
|
||||
'312-555-1234,"Doe, Jane",mobile,Acme,"two\nlines",,\r\n' +
|
||||
',,,,,,\r\n' +
|
||||
'3125550000,,work,,,,\r\n' +
|
||||
'+44 20 7946 0958,"Bob ""B"" Smith",,,,101,desk\r\n';
|
||||
expect(parseCsv(csv)).toEqual({
|
||||
contacts: [
|
||||
{
|
||||
name: 'Doe, Jane',
|
||||
company: 'Acme',
|
||||
email: null,
|
||||
numbers: [{ label: 'mobile', number: '312-555-1234' }],
|
||||
notes: 'two\nlines',
|
||||
},
|
||||
{
|
||||
name: 'Bob "B" Smith',
|
||||
company: null,
|
||||
email: null,
|
||||
numbers: [
|
||||
{ label: '', number: '+44 20 7946 0958' },
|
||||
{ label: 'desk', number: '101' },
|
||||
],
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
errors: ['Row 4: no name, skipped'],
|
||||
});
|
||||
});
|
||||
|
||||
it('needs a header with a name column', () => {
|
||||
expect(parseCsv('Jane,3125551234\n').errors[0]).toMatch(/"name" column/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and
|
||||
// the note. Everything else (photos, addresses, birthdays) is ignored.
|
||||
import type { ContactData } from '@selfphoneng/shared';
|
||||
|
||||
type Prop = { group: string; name: string; params: Map<string, string[]>; value: string };
|
||||
|
||||
/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */
|
||||
function parseParams(parts: string[]): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const p of parts) {
|
||||
const eq = p.indexOf('=');
|
||||
const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase();
|
||||
const raw = eq < 0 ? p : p.slice(eq + 1);
|
||||
const vals = raw.replace(/"/g, '').split(',').filter(Boolean);
|
||||
// 2.1's bare QUOTED-PRINTABLE is an encoding, not a type.
|
||||
const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key;
|
||||
out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */
|
||||
function parseLine(line: string): Prop | null {
|
||||
let quoted = false;
|
||||
let colon = -1;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === '"') quoted = !quoted;
|
||||
else if (line[i] === ':' && !quoted) {
|
||||
colon = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (colon < 0) return null;
|
||||
const [head = '', ...params] = line.slice(0, colon).split(';');
|
||||
const dot = head.lastIndexOf('.');
|
||||
return {
|
||||
group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(),
|
||||
name: head.slice(dot + 1).toUpperCase(),
|
||||
params: parseParams(params),
|
||||
value: line.slice(colon + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeQp(v: string, charset: string): string {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const hex = v.slice(i + 1, i + 3);
|
||||
if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) {
|
||||
bytes.push(parseInt(hex, 16));
|
||||
i += 2;
|
||||
} else bytes.push(v.charCodeAt(i) & 0xff);
|
||||
}
|
||||
try {
|
||||
return new TextDecoder(charset).decode(new Uint8Array(bytes));
|
||||
} catch {
|
||||
return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */
|
||||
function components(v: string, sep: ';' | ','): string[] {
|
||||
const out = [''];
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const c = v[i]!;
|
||||
if (c === '\\' && i + 1 < v.length) {
|
||||
const n = v[++i]!;
|
||||
out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n;
|
||||
} else if (c === sep) out.push('');
|
||||
else out[out.length - 1] += c;
|
||||
}
|
||||
return out.map((s) => s.trim());
|
||||
}
|
||||
const text = (v: string) => components(v, ';').join(';').trim();
|
||||
|
||||
/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */
|
||||
function telLabel(types: string[], abLabel: string | undefined): string {
|
||||
if (abLabel) {
|
||||
const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim();
|
||||
if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase();
|
||||
}
|
||||
if (types.includes('FAX')) return 'fax';
|
||||
if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile';
|
||||
if (types.includes('WORK')) return 'work';
|
||||
if (types.includes('HOME')) return 'home';
|
||||
if (types.includes('MAIN')) return 'main';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Every card in the text, in order. A card with no name, company or number is an error. */
|
||||
export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } {
|
||||
// Unfold: a line starting with a space or tab continues the one before (and 2.1
|
||||
// quoted-printable continues a line that ends with "=").
|
||||
const lines: string[] = [];
|
||||
for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) {
|
||||
const prev = lines.at(-1);
|
||||
if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!))
|
||||
lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, '');
|
||||
else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1);
|
||||
else lines.push(l);
|
||||
}
|
||||
|
||||
const contacts: ContactData[] = [];
|
||||
const errors: string[] = [];
|
||||
let card: Prop[] | null = null;
|
||||
for (const line of lines) {
|
||||
const p = parseLine(line);
|
||||
if (!p) continue;
|
||||
if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = [];
|
||||
else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) {
|
||||
const c = toContact(card);
|
||||
if (c) contacts.push(c);
|
||||
else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`);
|
||||
card = null;
|
||||
} else if (card) {
|
||||
const enc = p.params.get('ENCODING') ?? [];
|
||||
if (enc.includes('QUOTED-PRINTABLE'))
|
||||
p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8');
|
||||
card.push(p);
|
||||
}
|
||||
}
|
||||
return { contacts, errors };
|
||||
}
|
||||
|
||||
function toContact(props: Prop[]): ContactData | null {
|
||||
const first = (n: string) => props.find((p) => p.name === n)?.value;
|
||||
const fn = first('FN') ? text(first('FN')!) : '';
|
||||
const n = first('N');
|
||||
const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n
|
||||
? components(n, ';')
|
||||
: [];
|
||||
const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' ');
|
||||
const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null;
|
||||
const email = first('EMAIL') ? text(first('EMAIL')!) || null : null;
|
||||
const abLabels = new Map(
|
||||
props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]),
|
||||
);
|
||||
const numbers = props
|
||||
.filter((p) => p.name === 'TEL')
|
||||
.map((p) => ({
|
||||
label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined),
|
||||
number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(),
|
||||
}))
|
||||
.filter((t) => t.number);
|
||||
const note = first('NOTE') ? text(first('NOTE')!) || null : null;
|
||||
const name = fn || fromN || org || numbers[0]?.number;
|
||||
if (!name) return null;
|
||||
return { name, company: org, email, numbers, notes: note };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "contact_shares" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"from_user_id" uuid,
|
||||
"to_user_id" uuid NOT NULL,
|
||||
"source_id" text NOT NULL,
|
||||
"contact" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"decided_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending';
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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'`),
|
||||
),
|
||||
);
|
||||
@@ -4,6 +4,7 @@
|
||||
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { formatNumber, type CallReport } from '@selfphoneng/shared';
|
||||
import { contactNames } from '../contacts/book.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
|
||||
import type { SendMail } from './mail.ts';
|
||||
@@ -56,6 +57,8 @@ export type ReportCall = {
|
||||
status: string;
|
||||
fromNumber: string | null;
|
||||
fromName: string | null;
|
||||
/** The other side's name in my contacts or the company's (11 §1). */
|
||||
contactName: string | null;
|
||||
toNumber: string | null;
|
||||
talkSeconds: number;
|
||||
answeredBy: string | null;
|
||||
@@ -80,9 +83,10 @@ export function reportText(
|
||||
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
|
||||
];
|
||||
for (const c of list) {
|
||||
const name = c.contactName ?? (c.out ? null : c.fromName);
|
||||
const who = c.out
|
||||
? `Outgoing to ${num(c.toNumber)}`
|
||||
: `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`;
|
||||
? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}`
|
||||
: `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`;
|
||||
const facts = [
|
||||
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
|
||||
duration(c.talkSeconds),
|
||||
@@ -158,12 +162,18 @@ export async function sendCallReports(d: {
|
||||
.set({ callReportSentAt: period.to })
|
||||
.where(eq(extensions.id, e.id));
|
||||
if (!rows.length) continue;
|
||||
const names = await contactNames(
|
||||
d.db,
|
||||
e.userId,
|
||||
rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)),
|
||||
);
|
||||
const list = rows.map((r): ReportCall => ({
|
||||
startedAt: r.c.startedAt,
|
||||
out: r.out,
|
||||
status: r.c.status,
|
||||
fromNumber: r.c.fromNumber,
|
||||
fromName: r.c.fromName,
|
||||
contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null,
|
||||
toNumber: r.c.toNumber,
|
||||
talkSeconds: r.c.talkSeconds,
|
||||
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
|
||||
|
||||
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow
|
||||
// with their milestones (M5–M7).
|
||||
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
|
||||
@@ -16,6 +15,7 @@ import { MyCallsPage, otherParty, useCallBack } from './MyCallsPage.tsx';
|
||||
import { fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
|
||||
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
|
||||
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
|
||||
|
||||
const link = 'text-sky-700 underline dark:text-sky-400';
|
||||
|
||||
@@ -34,6 +34,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'settings'] });
|
||||
}, [myDnd, qc]);
|
||||
const toasts = useLive((s) => s.toasts);
|
||||
const offers = useContactShares().data?.length ?? 0;
|
||||
|
||||
const tab = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
|
||||
@@ -78,6 +79,15 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<NavLink to="/me/voicemail" className={tab}>
|
||||
Voicemail
|
||||
</NavLink>
|
||||
<NavLink to="/me/contacts" className={tab}>
|
||||
Contacts
|
||||
{offers > 0 && (
|
||||
<span className="ml-1 rounded-full bg-sky-600 px-1.5 text-xs text-white">
|
||||
<span className="sr-only">, shared with you: </span>
|
||||
{offers}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
<NavLink to="/me/settings" className={tab}>
|
||||
Settings
|
||||
</NavLink>
|
||||
@@ -111,6 +121,7 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
<Route index element={<Home user={user} />} />
|
||||
<Route path="calls" element={<MyCallsPage />} />
|
||||
<Route path="voicemail" element={<VoicemailPage />} />
|
||||
<Route path="contacts" element={<ContactsPage />} />
|
||||
<Route path="settings" element={<MeSettingsPage />} />
|
||||
<Route path="people" element={<PresencePanel me={me} />} />
|
||||
</Routes>
|
||||
@@ -127,9 +138,11 @@ export function MePage({ user }: { user: AuthUser }) {
|
||||
function Home({ user }: { user: AuthUser }) {
|
||||
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
|
||||
const fresh = list.data?.filter(isNew) ?? [];
|
||||
const nameOf = useContactName();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p>Hi {user.displayName}.</p>
|
||||
<SharedNotice />
|
||||
{user.extension && <StatusCard ext={user.extension.number} />}
|
||||
{user.extension && <MissedCalls />}
|
||||
{user.extension && <ParkedOnHome />}
|
||||
@@ -142,7 +155,7 @@ function Home({ user }: { user: AuthUser }) {
|
||||
<ul className="flex flex-col gap-1">
|
||||
{fresh.slice(0, 5).map((m) => (
|
||||
<li key={m.id} className="text-sm">
|
||||
<span className="font-semibold">{callerOf(m)}</span>{' '}
|
||||
<span className="font-semibold">{nameOf(m.callerNumber) ?? callerOf(m)}</span>{' '}
|
||||
<span className="text-zinc-500">
|
||||
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
|
||||
</span>
|
||||
@@ -164,6 +177,21 @@ function Home({ user }: { user: AuthUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
|
||||
function SharedNotice() {
|
||||
const offers = useContactShares().data ?? [];
|
||||
if (!offers.length) return null;
|
||||
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
|
||||
return (
|
||||
<p role="status" className="rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950">
|
||||
{from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
|
||||
<Link className={link} to="/me/contacts">
|
||||
Accept or deny
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Do not disturb and forwarding at a glance (05 §4 Home). */
|
||||
function StatusCard({ ext }: { ext: string }) {
|
||||
const me = useMe();
|
||||
@@ -243,6 +271,7 @@ function MissedCalls() {
|
||||
const calls = useQuery({ queryKey: ['me', 'calls', 'home'], queryFn: () => meApi.calls() });
|
||||
const { call, note } = useCallBack();
|
||||
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
|
||||
const nameOf = useContactName();
|
||||
return (
|
||||
<section aria-labelledby="missed-h" className="flex flex-col gap-2">
|
||||
<h2 id="missed-h" className="text-lg font-semibold">
|
||||
@@ -259,7 +288,9 @@ function MissedCalls() {
|
||||
const o = otherParty(c);
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<span className="font-semibold">{o.name ?? fmtNumber(o.number)}</span>{' '}
|
||||
<span className="font-semibold">
|
||||
{nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
|
||||
</span>{' '}
|
||||
<span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '}
|
||||
{o.number && (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// My calls (05 §4): my call history; click the other number to call back (the softphone when it's
|
||||
// on, else my desk phone rings first). "Add to contacts" arrives with contacts (M10).
|
||||
// on, else my desk phone rings first). Names from my contacts (11 §1), "Add to contacts".
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { MyCallDto } from '@selfphoneng/shared';
|
||||
@@ -8,6 +8,7 @@ import { meApi, recordingsApi } from '../../api/client.ts';
|
||||
import { Alert, Button } from '../../components/ui.tsx';
|
||||
import { softphone } from '../../softphone/SoftphoneEngine.ts';
|
||||
import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx';
|
||||
import { AddToContacts, useContactName } from './ContactsPage.tsx';
|
||||
|
||||
/** The other side of the call, from where I sit. */
|
||||
export const otherParty = (c: MyCallDto) =>
|
||||
@@ -36,6 +37,7 @@ export function MyCallsPage() {
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
});
|
||||
const { call, note } = useCallBack();
|
||||
const nameOf = useContactName();
|
||||
const calls = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
return (
|
||||
<section aria-labelledby="calls-h" className="flex flex-col gap-3">
|
||||
@@ -52,6 +54,7 @@ export function MyCallsPage() {
|
||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{calls.map((c) => {
|
||||
const other = otherParty(c);
|
||||
const known = nameOf(other.number);
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
@@ -62,7 +65,7 @@ export function MyCallsPage() {
|
||||
<span
|
||||
className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`}
|
||||
>
|
||||
{other.name && <span>{other.name} </span>}
|
||||
{(known ?? other.name) && <span>{known ?? other.name} </span>}
|
||||
{other.number ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -78,6 +81,9 @@ export function MyCallsPage() {
|
||||
{c.direction !== 'internal' && (
|
||||
<span className="text-zinc-500"> · {DIRECTION_TEXT[c.direction]}</span>
|
||||
)}
|
||||
{!known && other.number && c.direction !== 'internal' && (
|
||||
<AddToContacts number={other.number} name={other.name} />
|
||||
)}
|
||||
</span>
|
||||
{c.recordingId && (
|
||||
<audio
|
||||
|
||||
@@ -5,15 +5,16 @@ import { useRef, useState } from 'react';
|
||||
import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared';
|
||||
import { meApi } from '../../api/client.ts';
|
||||
import { ConfirmDialog } from '../../components/Dialog.tsx';
|
||||
import { AddToContacts, useContactName } from './ContactsPage.tsx';
|
||||
import { Alert, Button, inputClass } from '../../components/ui.tsx';
|
||||
|
||||
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
export const callerOf = (m: VoicemailDto) =>
|
||||
m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller');
|
||||
export const isNew = (m: VoicemailDto) => m.folder !== 'Old';
|
||||
/** Words in the transcript, caller name or number, ignoring case. */
|
||||
export const matches = (m: VoicemailDto, q: string) =>
|
||||
[m.transcript, m.callerName, m.callerNumber].some((f) =>
|
||||
/** Words in the transcript, caller name (or contact name) or number, ignoring case. */
|
||||
export const matches = (m: VoicemailDto, q: string, contact?: string) =>
|
||||
[m.transcript, m.callerName, m.callerNumber, contact].some((f) =>
|
||||
f?.toLowerCase().includes(q.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
@@ -33,7 +34,11 @@ function Messages() {
|
||||
const [q, setQ] = useState('');
|
||||
// ponytail: the whole mailbox is already loaded, so search filters it here; move to the API if
|
||||
// mailboxes ever get paged.
|
||||
const shown = q.trim() ? list.data?.filter((m) => matches(m, q)) : list.data;
|
||||
const nameOf = useContactName();
|
||||
const who = (m: VoicemailDto) => nameOf(m.callerNumber) ?? callerOf(m);
|
||||
const shown = q.trim()
|
||||
? list.data?.filter((m) => matches(m, q, nameOf(m.callerNumber)))
|
||||
: list.data;
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] });
|
||||
const markRead = useMutation({
|
||||
mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'),
|
||||
@@ -75,7 +80,7 @@ function Messages() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={isNew(m) ? 'font-semibold' : ''}>
|
||||
{callerOf(m)}
|
||||
{who(m)}
|
||||
{isNew(m) && (
|
||||
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
|
||||
New
|
||||
@@ -96,7 +101,7 @@ function Messages() {
|
||||
controls
|
||||
preload="none"
|
||||
src={meApi.audioUrl(m.id)}
|
||||
aria-label={`Voicemail from ${callerOf(m)}`}
|
||||
aria-label={`Voicemail from ${who(m)}`}
|
||||
className="w-full sm:w-64"
|
||||
onPlay={() => isNew(m) && !markRead.isPending && markRead.mutate(m.id)}
|
||||
/>
|
||||
@@ -110,6 +115,9 @@ function Messages() {
|
||||
<Button variant="ghost" onClick={() => setDeleting(m)}>
|
||||
Delete
|
||||
</Button>
|
||||
{m.callerNumber && !nameOf(m.callerNumber) && (
|
||||
<AddToContacts number={m.callerNumber} name={m.callerName} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
@@ -117,9 +125,7 @@ function Messages() {
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="Delete voicemail"
|
||||
message={
|
||||
<p>Delete the message from {deleting && callerOf(deleting)}? This can't be undone.</p>
|
||||
}
|
||||
message={<p>Delete the message from {deleting && who(deleting)}? This can't be undone.</p>}
|
||||
confirmLabel="Delete"
|
||||
busy={remove.isPending}
|
||||
error={remove.error?.message}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
|
||||
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
|
||||
import { useSoftphone } from './useSoftphone.ts';
|
||||
import { startRinging, stopRinging } from './sounds.ts';
|
||||
import { useContactName } from '../pages/user/ContactsPage.tsx';
|
||||
|
||||
const KEY = 'softphone.enabled';
|
||||
const remembered = () => {
|
||||
@@ -52,11 +53,14 @@ const STATUS = {
|
||||
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
|
||||
const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
|
||||
|
||||
/** Caller name: extensions (live presence) → the name the caller sent → the number. */
|
||||
/** Caller name: extensions (live presence) → my contacts and the company's → the name the caller
|
||||
* sent → the number (11 §1). */
|
||||
function useNameOf() {
|
||||
const exts = useLive((s) => s.extensions);
|
||||
const contact = useContactName();
|
||||
return (c: { remote: string; remoteName: string | null }) =>
|
||||
exts[c.remote]?.name ??
|
||||
contact(c.remote) ??
|
||||
c.remoteName ??
|
||||
(c.remote.length > 6 ? formatNumber(c.remote) : c.remote || 'Unknown');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user