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) => {