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

+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Contacts (spec 11): my book, the company book, imports and shares.
import { z } from 'zod';
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((s) => s || null);
export const ContactInputSchema = z.object({
name: z.string().trim().min(1, 'Enter a name').max(200),
company: optionalText(200),
email: optionalText(200).refine((s) => !s || z.email().safeParse(s).success, 'Not an email'),
/** Any way of writing a number; stored normalized (00 §8). */
numbers: z
.array(
z.object({
label: z.string().trim().max(30).default(''),
number: z.string().trim().min(1).max(40),
}),
)
.max(20)
.default([]),
notes: optionalText(5000),
});
export type ContactInput = z.input<typeof ContactInputSchema>;
export type ContactNumber = { label: string; number: string };
/** A contact as stored, and as a share offer carries it. */
export type ContactData = {
name: string;
company: string | null;
email: string | null;
numbers: ContactNumber[];
notes: string | null;
};
export type ContactDto = ContactData & {
id: string;
/** mine: I edit it; company: read-only for users (admins edit it in Admin → Contacts). */
book: 'mine' | 'company';
};
/** POST …/contacts/import: the files' text, read in the browser. */
export const ContactImportSchema = z.object({
files: z
.array(z.object({ name: z.string().max(200), text: z.string() }))
.min(1)
.max(20),
});
export type ContactImportResult = {
committed: boolean;
added: number;
/** Same name and number already in the book (or earlier in the files): skipped. */
duplicates: number;
/** Added without a phone number. */
noNumber: number;
/** The first problems: a card or row that was skipped, or a number that was dropped. */
errors: string[];
};
export const ContactShareSchema = z
.object({
contactIds: z.array(z.uuid()).min(1).max(500),
/** Colleagues by extension number. */
extensions: z.array(z.string().max(20)).max(500).default([]),
everyone: z.boolean().default(false),
})
.refine((s) => s.everyone || s.extensions.length, 'Pick who to share with');
export type ContactShare = z.input<typeof ContactShareSchema>;
export const ContactShareDecisionSchema = z.object({ ids: z.array(z.uuid()).min(1).max(1000) });
/** A contact someone shared with me, waiting for Accept or Deny (11 §2.1). */
export type ContactShareDto = {
id: string;
from: { userId: string; name: string } | null;
contact: ContactData;
createdAt: string;
};
+1
View File
@@ -25,4 +25,5 @@ export * from './announcements.ts';
export * from './me.ts';
export * from './blocked.ts';
export * from './calls.ts';
export * from './contacts.ts';
export * from './integrations.ts';