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

+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;
}