contacts: owners' contacts on their desk phones, copy builtin contacts to Contrack (C6)
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.
This commit is contained in:
1 parent
e6a1373f36
commit
604e40adf1
16 files changed
+294
-15
No files matched your search
@@ -6,6 +6,7 @@ import { asc, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import {
|
||||
normalizeNumber,
|
||||
type ContactBookDto,
|
||||
type ContactCopyResult,
|
||||
type ContactData,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
ContrackError,
|
||||
contrackBook,
|
||||
contrackCreate,
|
||||
contrackBulk,
|
||||
contrackSettings,
|
||||
needToken,
|
||||
type Contrack,
|
||||
@@ -101,10 +103,38 @@ export async function readBooks(db: Db, userId: string | null): Promise<ContactB
|
||||
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) };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Contrack (11 §4): a self-hosted contact manager beside the PBX. We call a handful of its REST
|
||||
// routes with a person's (or the company account's) personal token, and keep each book in memory
|
||||
// for 5 minutes, so name lookups don't wait on it.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
|
||||
import { AiError, assertLocalUrl } from '../ai/client.ts';
|
||||
@@ -36,6 +37,7 @@ async function ctk<T>(
|
||||
r = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
@@ -204,6 +206,31 @@ export async function contrackCreate(
|
||||
return toDto(c, row, owner === null ? 'company' : 'mine');
|
||||
}
|
||||
|
||||
/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */
|
||||
export async function contrackBulk(
|
||||
db: Db,
|
||||
c: Contrack,
|
||||
owner: string | null,
|
||||
list: ContactData[],
|
||||
): Promise<{ count: number; failed: number }> {
|
||||
const token = await tokenFor(db, c, owner);
|
||||
if (!token) throw needToken(owner);
|
||||
const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Import-Id': randomUUID() },
|
||||
body: JSON.stringify(
|
||||
list.slice(0, 5000).map((d) => ({
|
||||
name: d.name,
|
||||
...(d.company ? { company: d.company } : {}),
|
||||
...(d.email ? { emails: [{ email: d.email }] } : {}),
|
||||
phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
|
||||
})),
|
||||
),
|
||||
});
|
||||
forgetBook(owner);
|
||||
return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 };
|
||||
}
|
||||
|
||||
/** A timeline entry on a contact (11 §4.4); answers its id. */
|
||||
export async function contrackInteraction(
|
||||
c: Contrack,
|
||||
|
||||
@@ -27,6 +27,8 @@ export type ProvisionContext = {
|
||||
provPassword: string;
|
||||
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
|
||||
phonebookUrl: string;
|
||||
/** Whose phone: their own contacts go in its directory (11 §6 C6). */
|
||||
ownerUserId: string | null;
|
||||
ntpServer: string;
|
||||
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
|
||||
dialRules: DialRule[];
|
||||
@@ -80,6 +82,7 @@ export async function loadContext(
|
||||
provUser: d.mac,
|
||||
provPassword,
|
||||
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
|
||||
ownerUserId: e.userId,
|
||||
ntpServer: NTP_SERVER,
|
||||
dialRules: await loadDialRules(db),
|
||||
},
|
||||
@@ -88,14 +91,17 @@ export async function loadContext(
|
||||
|
||||
export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] };
|
||||
|
||||
/** The company phonebook (07 §2): every extension, then the company contacts, by name (from
|
||||
* Contrack's company account in Contrack mode, 11 §4.3). */
|
||||
export async function loadPhonebook(db: Db): Promise<PhonebookEntry[]> {
|
||||
/** A phone's directory (07 §2): every extension, then its owner's own contacts (11 §6 C6), then
|
||||
* the company's, each by name. Contrack's books in Contrack mode (11 §4.3). */
|
||||
export async function loadPhonebook(
|
||||
db: Db,
|
||||
ownerUserId: string | null = null,
|
||||
): Promise<PhonebookEntry[]> {
|
||||
const exts = await db
|
||||
.select({ name: extensions.name, number: extensions.number })
|
||||
.from(extensions)
|
||||
.orderBy(asc(extensions.name));
|
||||
const company = (await readBooks(db, null)).items;
|
||||
const company = (await readBooks(db, ownerUserId)).items; // mine first, then the company's
|
||||
return [
|
||||
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
|
||||
...company.map((c) => ({
|
||||
|
||||
@@ -31,6 +31,7 @@ const ctx = (over: Partial<ProvisionContext> = {}): ProvisionContext => ({
|
||||
provPassword: 'Pr0vPassw0rdXyZ12345',
|
||||
phonebookUrl:
|
||||
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
|
||||
ownerUserId: null,
|
||||
ntpServer: 'pool.ntp.org',
|
||||
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
|
||||
...over,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ContactShareDecisionSchema,
|
||||
ContactShareSchema,
|
||||
type ContactBookDto,
|
||||
type ContactCopyResult,
|
||||
type ContactData,
|
||||
type ContactDto,
|
||||
type ContactImportResult,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
assertBuiltin,
|
||||
cleanNumbers,
|
||||
contactNames,
|
||||
copyToContrack,
|
||||
createContact,
|
||||
readBooks,
|
||||
toDto,
|
||||
@@ -188,6 +190,29 @@ export async function contactRoutes(app: FastifyInstance) {
|
||||
.send(phoneSafeName(names.get(q.data.number) ?? ''));
|
||||
});
|
||||
|
||||
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
|
||||
app.post(
|
||||
'/api/me/contacts/copy-to-contrack',
|
||||
{ preHandler: requireAuth },
|
||||
async (req): Promise<ContactCopyResult> => copyToContrack(db, req.user!.id),
|
||||
);
|
||||
app.post(
|
||||
'/api/contacts/copy-to-contrack',
|
||||
{ preHandler: requireAdmin },
|
||||
async (req): Promise<ContactCopyResult> => {
|
||||
const r = await copyToContrack(db, null);
|
||||
if (r.added)
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'contact',
|
||||
summary: `Copied ${r.added} company contacts to Contrack`,
|
||||
});
|
||||
return r;
|
||||
},
|
||||
);
|
||||
|
||||
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
|
||||
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
|
||||
const b = z
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
|
||||
/** The config, or (phonebook) the directory in the phone's vendor's format. */
|
||||
async function render(c: ProvisionContext, phonebook = false): Promise<string> {
|
||||
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
|
||||
const entries = await loadPhonebook(db);
|
||||
const entries = await loadPhonebook(db, c.ownerUserId);
|
||||
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { contactNames } from '../src/contacts/book.ts';
|
||||
import { logCallsToContrack } from '../src/contacts/timeline.ts';
|
||||
import { callExtensions, calls, extensions, recordings, users } from '../src/db/schema.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contacts,
|
||||
extensions,
|
||||
recordings,
|
||||
users,
|
||||
} from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { loadPhonebook } from '../src/provisioning/context.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
@@ -188,6 +195,7 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
connected: true,
|
||||
error: null,
|
||||
toCopy: 0,
|
||||
});
|
||||
expect(book.items).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -307,6 +315,26 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
|
||||
expect(content).not.toContain('<quote>');
|
||||
});
|
||||
|
||||
it('contacts from before Contrack copy into the account in one import (C6)', async () => {
|
||||
await t.db.insert(contacts).values([
|
||||
{
|
||||
name: 'Old Friend',
|
||||
ownerUserId: danId,
|
||||
numbers: [{ label: 'home', number: '13125550202' }],
|
||||
},
|
||||
{ name: 'Jane Doe', ownerUserId: danId, numbers: [{ label: '', number: '13125551234' }] },
|
||||
]);
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
|
||||
const r = await dan('POST', '/api/me/contacts/copy-to-contrack');
|
||||
expect(r.json(), r.body).toEqual({ added: 1, duplicates: 1, failed: 0 });
|
||||
const rows = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
|
||||
name: string;
|
||||
phones: { phone: string }[];
|
||||
}[];
|
||||
expect(rows.find((x) => x.name === 'Old Friend')?.phones).toEqual([{ phone: '13125550202' }]);
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
|
||||
});
|
||||
|
||||
it('a share is accepted into the recipient’s own Contrack account', async () => {
|
||||
const jane = (await dan('GET', '/api/me/contacts'))
|
||||
.json()
|
||||
@@ -328,6 +356,6 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
|
||||
const dans = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
|
||||
name: string;
|
||||
}[];
|
||||
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe']);
|
||||
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe', 'Old Friend']);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { logCallsToContrack } from '../src/contacts/timeline.ts';
|
||||
import {
|
||||
callExtensions,
|
||||
calls,
|
||||
contacts,
|
||||
contrackCallLog,
|
||||
extensions,
|
||||
recordings,
|
||||
@@ -67,6 +68,30 @@ function fakeContrack() {
|
||||
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
|
||||
const book = books[token];
|
||||
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
|
||||
if (req.method === 'POST' && req.url === '/api/contacts/bulk') {
|
||||
if (!/^[0-9a-f-]{36}$/.test(String(req.headers['x-import-id'])))
|
||||
return send(400, { error: { code: 'VALIDATION_ERROR', message: 'X-Import-Id' } });
|
||||
let raw = '';
|
||||
req.on('data', (c) => (raw += c));
|
||||
req.on('end', () => {
|
||||
const rows = JSON.parse(raw) as { name: string; phones: { phone: string }[] }[];
|
||||
for (const r of rows)
|
||||
book.push({
|
||||
id: `bulk-${++n}`,
|
||||
name: r.name,
|
||||
company: null,
|
||||
emails: [],
|
||||
phones: r.phones,
|
||||
});
|
||||
send(201, {
|
||||
success: true,
|
||||
count: rows.length,
|
||||
failed: 0,
|
||||
importId: req.headers['x-import-id'],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
|
||||
if (req.method === 'POST' && into) {
|
||||
if (state.readOnly.has(token))
|
||||
@@ -217,6 +242,7 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
publicUrl: 'https://contacts.example.com',
|
||||
connected: true,
|
||||
error: null,
|
||||
toCopy: 0,
|
||||
});
|
||||
expect(book.items).toEqual([
|
||||
{
|
||||
@@ -436,10 +462,63 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
expect(jane).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
|
||||
await t.db.insert(contacts).values([
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
ownerUserId: danId,
|
||||
numbers: [{ label: 'mobile', number: '13125551234' }],
|
||||
}, // in Contrack
|
||||
{
|
||||
name: 'Old Friend',
|
||||
ownerUserId: danId,
|
||||
numbers: [{ label: 'home', number: '13125550202' }],
|
||||
},
|
||||
{ name: 'Plumber', numbers: [{ label: '', number: '13125557777' }] }, // in the company account
|
||||
{ name: 'Locksmith', numbers: [{ label: 'work', number: '13125550303' }] },
|
||||
]);
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
|
||||
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
|
||||
added: 1,
|
||||
duplicates: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
|
||||
name: 'Old Friend',
|
||||
phones: [{ phone: '13125550202', label: 'home' }],
|
||||
});
|
||||
expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
|
||||
expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
|
||||
added: 0,
|
||||
duplicates: 2,
|
||||
failed: 0,
|
||||
});
|
||||
// The company book, by an admin only.
|
||||
expect((await dan('POST', '/api/contacts/copy-to-contrack')).statusCode).toBe(403);
|
||||
expect((await admin('GET', '/api/contacts')).json().contrack.toCopy).toBe(1);
|
||||
expect((await admin('POST', '/api/contacts/copy-to-contrack')).json()).toEqual({
|
||||
added: 1,
|
||||
duplicates: 1,
|
||||
failed: 0,
|
||||
});
|
||||
expect(fake.books.ctk_company!.map((c) => c.name)).toEqual(['Plumber', 'Locksmith']);
|
||||
// Dan's desk phone: his Contrack contacts, then the company's.
|
||||
const names = (await loadPhonebook(t.db, danId)).map((e) => e.name);
|
||||
expect(names.indexOf('Old Friend')).toBeGreaterThan(-1);
|
||||
expect(names.indexOf('Old Friend')).toBeLessThan(names.indexOf('Locksmith'));
|
||||
expect((await loadPhonebook(t.db, null)).map((e) => e.name)).not.toContain('Old Friend');
|
||||
});
|
||||
|
||||
it('back to builtin: the table again, tokens kept for later', async () => {
|
||||
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
|
||||
const book = (await dan('GET', '/api/me/contacts')).json();
|
||||
expect(book).toEqual({ items: [], provider: 'builtin', contrack: null });
|
||||
expect([book.provider, book.contrack]).toEqual(['builtin', null]);
|
||||
expect(book.items.map((c: { name: string }) => c.name)).toEqual([
|
||||
'Jane Doe',
|
||||
'Old Friend',
|
||||
'Locksmith',
|
||||
'Plumber',
|
||||
]);
|
||||
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -245,6 +245,32 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
|
||||
await setSetting(t.db, 'general.timezone', 'America/Chicago');
|
||||
});
|
||||
|
||||
it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => {
|
||||
const owner = await t.addUser('dan@example.com', 'dan-password-123');
|
||||
const other = await t.addUser('sue@example.com', 'sue-password-123');
|
||||
await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id });
|
||||
await t.db.insert(contacts).values([
|
||||
{ name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] },
|
||||
{
|
||||
name: 'Sue Secret',
|
||||
ownerUserId: other.id,
|
||||
numbers: [{ label: '', number: '13125550102' }],
|
||||
},
|
||||
]);
|
||||
const book = (
|
||||
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
|
||||
).body;
|
||||
expect(book).toContain('<Telephone label="mobile">13125550101</Telephone>');
|
||||
expect(book).not.toContain('Sue Secret');
|
||||
expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom'));
|
||||
expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's
|
||||
await req('PATCH', `/api/extensions/${extId}`, { userId: null });
|
||||
const unowned = (
|
||||
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
|
||||
).body;
|
||||
expect(unowned).not.toContain('Mom');
|
||||
});
|
||||
|
||||
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
|
||||
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
CallDto,
|
||||
CallFilter,
|
||||
ContactBookDto,
|
||||
ContactCopyResult,
|
||||
ContrackOptions,
|
||||
ContactDto,
|
||||
ContactImportResult,
|
||||
@@ -299,6 +300,7 @@ export type ContactBase = '/api/me/contacts' | '/api/contacts';
|
||||
|
||||
export const contactsApi = {
|
||||
list: (base: ContactBase) => api<ContactBookDto>('GET', base),
|
||||
copyToContrack: (base: ContactBase) => api<ContactCopyResult>('POST', `${base}/copy-to-contrack`),
|
||||
testContrack: (url: string, companyToken?: string) =>
|
||||
api<{ ok: true; detail: string } | { ok: false; error: string }>(
|
||||
'POST',
|
||||
|
||||
@@ -159,6 +159,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
: 'The company Contrack account has no token yet: Settings → Contacts.')}
|
||||
</Alert>
|
||||
)}
|
||||
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
|
||||
{list.data && shown.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
{q ? 'No contact matches that.' : 'No contacts yet.'}
|
||||
@@ -261,6 +262,31 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Contacts from before Contrack (11 §5, C6): copied once, kept here for switching back. */
|
||||
function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
|
||||
const qc = useQueryClient();
|
||||
const copy = useMutation({
|
||||
mutationFn: () => contactsApi.copyToContrack(base),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-wrap items-center gap-2 rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950"
|
||||
>
|
||||
<span className="flex-1">
|
||||
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
|
||||
{count === 1 ? "isn't" : "aren't"} in {base === COMPANY ? "the company's" : 'your'} Contrack
|
||||
yet.
|
||||
</span>
|
||||
<Button variant="primary" disabled={copy.isPending} onClick={() => copy.mutate()}>
|
||||
Copy {count === 1 ? 'it' : 'them'} to Contrack
|
||||
</Button>
|
||||
{copy.error && <Alert>{copy.error.message}</Alert>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LABELS = ['mobile', 'work', 'home', 'main', 'fax', 'other'];
|
||||
|
||||
function ContactDrawer(props: {
|
||||
|
||||
Reference in new issue
Block a user