Spec 11 §6.2: a phone's directory is the extensions, its owner's own contacts (accepted shares included), then the company's, in both modes. In Contrack mode, contacts from before Contrack are offered for copying (contrack.toCopy, POST …/copy-to-contrack: one Contrack bulk import, skipping what's already there; the builtin rows stay). Tested against a fake and a real Contrack 2.0.2.
237 lines
8.9 KiB
TypeScript
237 lines
8.9 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Address books (11 §2): a user's (owner = their id) and the company's (owner null), kept in the
|
|
// `contacts` table (builtin) or in Contrack (contacts.provider = 'contrack', 11 §4). Every caller
|
|
// goes through these functions and doesn't care which.
|
|
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
|
|
import {
|
|
normalizeNumber,
|
|
type ContactBookDto,
|
|
type ContactCopyResult,
|
|
type ContactData,
|
|
type ContactDto,
|
|
type ContactImportResult,
|
|
} from '@selfphoneng/shared';
|
|
import type { Db } from '../db/client.ts';
|
|
import { contacts } from '../db/schema.ts';
|
|
import { HttpError, badRequest } from '../errors.ts';
|
|
import {
|
|
ContrackError,
|
|
contrackBook,
|
|
contrackCreate,
|
|
contrackBulk,
|
|
contrackSettings,
|
|
needToken,
|
|
type Contrack,
|
|
} from './contrack.ts';
|
|
|
|
/** null = the company book. */
|
|
export type Owner = string | null;
|
|
const inBook = (owner: Owner) =>
|
|
owner ? eq(contacts.ownerUserId, owner) : isNull(contacts.ownerUserId);
|
|
|
|
type Row = typeof contacts.$inferSelect;
|
|
export const toDto = (r: Row): ContactDto => ({
|
|
id: r.id,
|
|
book: r.ownerUserId ? 'mine' : 'company',
|
|
name: r.name,
|
|
company: r.company,
|
|
email: r.email,
|
|
numbers: r.numbers as ContactData['numbers'],
|
|
notes: r.notes,
|
|
url: null,
|
|
});
|
|
|
|
/** Contrack's errors as API answers (409 for "connect first", 502 for Contrack itself). */
|
|
const asHttp = (err: unknown): never => {
|
|
if (err instanceof ContrackError)
|
|
throw new HttpError(err.status === 409 ? 409 : 502, 'contrack', err.message);
|
|
throw err;
|
|
};
|
|
|
|
/** Editing, deleting and importing happen in Contrack itself in Contrack mode (11 §3.1, §4.3). */
|
|
export async function assertBuiltin(db: Db) {
|
|
if (await contrackSettings(db))
|
|
throw new HttpError(409, 'contrack', 'Contacts live in Contrack: change them there');
|
|
}
|
|
|
|
/** Normalized numbers (00 §8); `bad` gets the ones that aren't phone numbers. */
|
|
export function cleanNumbers(c: ContactData, bad?: (n: string) => void): ContactData['numbers'] {
|
|
const out: ContactData['numbers'] = [];
|
|
for (const n of c.numbers)
|
|
try {
|
|
const number = normalizeNumber(n.number);
|
|
if (!out.some((o) => o.number === number)) out.push({ label: n.label, number });
|
|
} catch {
|
|
if (!bad) throw badRequest(`"${n.number}" isn't a phone number`);
|
|
bad(n.number);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const byName = (a: ContactDto, b: ContactDto) => a.name.localeCompare(b.name);
|
|
|
|
async function contrackList(db: Db, c: Contrack, owner: Owner) {
|
|
const b = await contrackBook(db, c, owner);
|
|
return { items: (b?.contacts ?? []).toSorted(byName), book: b };
|
|
}
|
|
|
|
/**
|
|
* The books someone sees: my contacts, then the company's (userId), or only the company's (null,
|
|
* Admin → Contacts). `contrack` says whether that person's (or the company's) token works.
|
|
*/
|
|
export async function readBooks(db: Db, userId: string | null): Promise<ContactBookDto> {
|
|
const c = await contrackSettings(db);
|
|
if (!c) {
|
|
const rows = await db
|
|
.select()
|
|
.from(contacts)
|
|
.where(
|
|
userId
|
|
? or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId))
|
|
: isNull(contacts.ownerUserId),
|
|
)
|
|
.orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
|
|
return { items: rows.map(toDto), provider: 'builtin', contrack: null };
|
|
}
|
|
const company = await contrackList(db, c, null);
|
|
const mine = userId ? await contrackList(db, c, userId) : null;
|
|
const own = mine ?? company; // whose token the page is about
|
|
return {
|
|
items: [...(mine?.items ?? []), ...company.items],
|
|
provider: 'contrack',
|
|
contrack: {
|
|
publicUrl: c.publicUrl,
|
|
connected: !!own.book && !own.book.error,
|
|
error: own.book?.error ?? null,
|
|
toCopy:
|
|
own.book && !own.book.error
|
|
? (await notInContrack(db, userId, own.book.contacts)).length
|
|
: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** This book's builtin contacts that its Contrack account doesn't have yet. */
|
|
async function notInContrack(db: Db, owner: Owner, have: ContactDto[]): Promise<ContactData[]> {
|
|
const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
|
|
return rows.filter((r) => !have.some((h) => isDuplicate(h, r)));
|
|
}
|
|
|
|
/**
|
|
* "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet,
|
|
* in one Contrack import. The builtin ones stay, for switching back.
|
|
*/
|
|
export async function copyToContrack(db: Db, owner: Owner): Promise<ContactCopyResult> {
|
|
const c = await contrackSettings(db);
|
|
if (!c)
|
|
throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)');
|
|
const b = await contrackBook(db, c, owner);
|
|
if (!b) asHttp(needToken(owner));
|
|
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
|
|
const all = (await db.select().from(contacts).where(inBook(owner))).length;
|
|
const fresh = await notInContrack(db, owner, b!.contacts);
|
|
if (!fresh.length) return { added: 0, duplicates: all, failed: 0 };
|
|
const r = await contrackBulk(db, c, owner, fresh).catch(asHttp);
|
|
return { added: r.count, duplicates: all - fresh.length, failed: r.failed };
|
|
}
|
|
|
|
/** One contact added by hand (or from a call): a row, or a Contrack contact. */
|
|
export async function createContact(db: Db, owner: Owner, d: ContactData): Promise<ContactDto> {
|
|
const clean = { ...d, numbers: cleanNumbers(d) };
|
|
const c = await contrackSettings(db);
|
|
if (c) return contrackCreate(db, c, owner, clean).catch(asHttp);
|
|
const [row] = await db
|
|
.insert(contacts)
|
|
.values({ ...clean, ownerUserId: owner })
|
|
.returning();
|
|
return toDto(row!);
|
|
}
|
|
|
|
/** Same contact: same name (any case) and a number in common, or neither has a number. */
|
|
const sameKey = (c: ContactData) => c.name.trim().toLowerCase();
|
|
const isDuplicate = (a: ContactData, b: ContactData) =>
|
|
sameKey(a) === sameKey(b) &&
|
|
((!a.numbers.length && !b.numbers.length) ||
|
|
a.numbers.some((n) => b.numbers.some((m) => m.number === n.number)));
|
|
|
|
/**
|
|
* Adds contacts to a book, skipping ones it already has (11 §3.1): an import or accepted shares.
|
|
* With `commit` false it only counts. Bad numbers are dropped and reported.
|
|
*/
|
|
export async function addToBook(
|
|
db: Db,
|
|
owner: Owner,
|
|
list: ContactData[],
|
|
commit: boolean,
|
|
errors: string[] = [],
|
|
): Promise<ContactImportResult> {
|
|
const c = await contrackSettings(db);
|
|
let have: ContactDto[];
|
|
if (c) {
|
|
const b = await contrackBook(db, c, owner);
|
|
if (!b) asHttp(needToken(owner));
|
|
if (b!.error) throw new HttpError(502, 'contrack', b!.error);
|
|
have = b!.contacts;
|
|
} else have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
|
|
const fresh: ContactData[] = [];
|
|
let duplicates = 0;
|
|
for (const d of list) {
|
|
const numbers = cleanNumbers(d, (n) =>
|
|
errors.push(`${d.name}: "${n}" isn't a phone number, left out`),
|
|
);
|
|
const clean = { ...d, numbers };
|
|
if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
|
|
else fresh.push(clean);
|
|
}
|
|
if (commit && fresh.length) {
|
|
// ponytail: one Contrack request per contact; imports go to Contrack's own importer, so
|
|
// this is only accepted shares.
|
|
if (c) for (const d of fresh) await contrackCreate(db, c, owner, d).catch(asHttp);
|
|
// ponytail: one insert of up to 5,000 rows (the import cap).
|
|
else await db.insert(contacts).values(fresh.map((d) => ({ ...d, ownerUserId: owner })));
|
|
}
|
|
return {
|
|
committed: commit,
|
|
added: fresh.length,
|
|
duplicates,
|
|
noNumber: fresh.filter((d) => !d.numbers.length).length,
|
|
errors: errors.slice(0, 20),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Names for numbers, from my book first, then the company's (11 §1). Numbers as calls store them
|
|
* (normalized). userId null: the company book only.
|
|
*/
|
|
export async function contactNames(
|
|
db: Db,
|
|
userId: string | null,
|
|
numbers: (string | null)[],
|
|
opts: { wait?: boolean } = {},
|
|
): Promise<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;
|
|
const c = await contrackSettings(db);
|
|
if (c) {
|
|
const books = [
|
|
userId ? await contrackBook(db, c, userId, opts) : null,
|
|
await contrackBook(db, c, null, opts),
|
|
];
|
|
for (const b of books)
|
|
for (const ct of b?.contacts ?? [])
|
|
for (const n of ct.numbers)
|
|
if (want.includes(n.number) && !out.has(n.number)) out.set(n.number, ct.name);
|
|
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;
|
|
}
|