167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
|
|
/**
|
||
|
|
* Recipient search for the share People tab — system users (via the system
|
||
|
|
* address book) + groups (via /api/groups/search) + a synthesized "invite by
|
||
|
|
* email" suggestion when the query parses as an email. Ported from the original
|
||
|
|
* shareModal recipient autocomplete (addressBook.searchContacts + _searchGroups
|
||
|
|
* + _looksLikeEmail).
|
||
|
|
*/
|
||
|
|
import { apiFetch } from '$lib/api/client';
|
||
|
|
import { session } from '$lib/stores/session.svelte';
|
||
|
|
import type { SubjectType } from './grants';
|
||
|
|
|
||
|
|
export interface Recipient {
|
||
|
|
type: Extract<SubjectType, 'user' | 'group' | 'email'>;
|
||
|
|
/** For email recipients this is the normalised email; for users/groups, the UUID. */
|
||
|
|
id: string;
|
||
|
|
label: string;
|
||
|
|
sublabel?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Contact {
|
||
|
|
id: string;
|
||
|
|
first_name?: string;
|
||
|
|
last_name?: string;
|
||
|
|
full_name?: string;
|
||
|
|
email?: Array<{ email: string; is_primary?: boolean }>;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface GroupResult {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Permissive client-side email check — matches a non-whitespace local part, an
|
||
|
|
* `@`, and a domain with a dot. The server's `normalize_email` is authoritative;
|
||
|
|
* this just decides whether to surface the synthetic invite-by-email row.
|
||
|
|
*/
|
||
|
|
function looksLikeEmail(q: string): boolean {
|
||
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
|
||
|
|
}
|
||
|
|
|
||
|
|
// The system book lists all users; we filter client-side (matches the original).
|
||
|
|
let contactCache: Contact[] | null = null;
|
||
|
|
/** `false` once we confirm the system address book is unavailable. */
|
||
|
|
let directoryAvailable: boolean | null = null;
|
||
|
|
|
||
|
|
async function systemContacts(): Promise<Contact[]> {
|
||
|
|
if (contactCache) return contactCache;
|
||
|
|
try {
|
||
|
|
const res = await apiFetch('/api/address-books/system/contacts', {
|
||
|
|
credentials: 'same-origin'
|
||
|
|
});
|
||
|
|
if (!res.ok) {
|
||
|
|
directoryAvailable = false;
|
||
|
|
contactCache = [];
|
||
|
|
return contactCache;
|
||
|
|
}
|
||
|
|
directoryAvailable = true;
|
||
|
|
contactCache = (await res.json()) as Contact[];
|
||
|
|
} catch {
|
||
|
|
directoryAvailable = false;
|
||
|
|
contactCache = [];
|
||
|
|
}
|
||
|
|
return contactCache;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether the system user directory is reachable. Returns `true` until proven
|
||
|
|
* otherwise so callers degrade gracefully; call `ensureResolvers()` first to
|
||
|
|
* get an accurate answer.
|
||
|
|
*/
|
||
|
|
export function isDirectoryAvailable(): boolean {
|
||
|
|
return directoryAvailable !== false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function contactLabel(c: Contact): { label: string; email: string } {
|
||
|
|
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || '';
|
||
|
|
const email = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
|
||
|
|
return { label: name || email || c.id, email };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function searchGroups(q: string): Promise<Recipient[]> {
|
||
|
|
try {
|
||
|
|
const res = await apiFetch(`/api/groups/search?q=${encodeURIComponent(q)}&limit=8`, {
|
||
|
|
credentials: 'same-origin'
|
||
|
|
});
|
||
|
|
if (!res.ok) return [];
|
||
|
|
const groups = (await res.json()) as GroupResult[];
|
||
|
|
return groups.map((g) => ({ type: 'group' as const, id: g.id, label: g.name }));
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Label resolution for existing grants (subject id → display name) ────────
|
||
|
|
let groupCache: Map<string, string> | null = null;
|
||
|
|
|
||
|
|
async function loadGroups(): Promise<Map<string, string>> {
|
||
|
|
if (groupCache) return groupCache;
|
||
|
|
groupCache = new Map();
|
||
|
|
try {
|
||
|
|
const res = await apiFetch('/api/groups/search?q=&limit=200', { credentials: 'same-origin' });
|
||
|
|
if (res.ok) {
|
||
|
|
for (const g of (await res.json()) as GroupResult[]) groupCache.set(g.id, g.name);
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
/* leave empty */
|
||
|
|
}
|
||
|
|
return groupCache;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Preload the user + group caches so grant rows can show names. */
|
||
|
|
export async function ensureResolvers(): Promise<void> {
|
||
|
|
await Promise.all([systemContacts(), loadGroups()]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Resolve a subject id to a display label using the preloaded caches. */
|
||
|
|
export function resolveLabel(type: 'user' | 'group', id: string): string {
|
||
|
|
if (type === 'group') return groupCache?.get(id) ?? id;
|
||
|
|
const c = contactCache?.find((x) => x.id === id);
|
||
|
|
return c ? contactLabel(c).label : id;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Resolve a subject id to a label + sublabel (email) for member vignettes. */
|
||
|
|
export function resolveRecipient(type: 'user' | 'group', id: string): Recipient {
|
||
|
|
if (type === 'group') {
|
||
|
|
return { type: 'group', id, label: groupCache?.get(id) ?? id };
|
||
|
|
}
|
||
|
|
const c = contactCache?.find((x) => x.id === id);
|
||
|
|
if (!c) return { type: 'user', id, label: id };
|
||
|
|
const { label, email } = contactLabel(c);
|
||
|
|
return { type: 'user', id, label, sublabel: email };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Combined user + group results matching the query (case-insensitive), plus a
|
||
|
|
* synthetic invite-by-email suggestion when the query is an email that no
|
||
|
|
* contact already owns. The current logged-in user is excluded — you can't
|
||
|
|
* share with yourself. Capped at 8 combined (groups, then users, then email).
|
||
|
|
*/
|
||
|
|
export async function searchRecipients(query: string): Promise<Recipient[]> {
|
||
|
|
const q = query.toLowerCase().trim();
|
||
|
|
if (!q) return [];
|
||
|
|
const currentUserId = session.user?.id ?? null;
|
||
|
|
const [contacts, groups] = await Promise.all([systemContacts(), searchGroups(q)]);
|
||
|
|
const matched = contacts
|
||
|
|
.filter((c) => c.id !== currentUserId)
|
||
|
|
.map((c) => ({ c, ...contactLabel(c) }))
|
||
|
|
.filter(
|
||
|
|
({ label, email }) => label.toLowerCase().includes(q) || email.toLowerCase().includes(q)
|
||
|
|
);
|
||
|
|
const users: Recipient[] = matched.map(({ c, label, email }) => ({
|
||
|
|
type: 'user' as const,
|
||
|
|
id: c.id,
|
||
|
|
label,
|
||
|
|
sublabel: email
|
||
|
|
}));
|
||
|
|
|
||
|
|
const emailItems: Recipient[] = [];
|
||
|
|
if (looksLikeEmail(q)) {
|
||
|
|
const exists = matched.some(({ email }) => email.toLowerCase() === q);
|
||
|
|
if (!exists) emailItems.push({ type: 'email', id: q, label: q });
|
||
|
|
}
|
||
|
|
|
||
|
|
return [...groups, ...users, ...emailItems].slice(0, 8);
|
||
|
|
}
|