import { call as defaultCall } from "./retriever.mjs";
const chunk = (array, size = 1000) =>
Array.from({ length: Math.ceil(array.length / size) }, (_, i) => array.slice(i * size, (i + 1) * size));
// Same rules as the server: "https://www.Stripe.com/pricing" -> "stripe.com"
export const canonicalDomain = (value) => {
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
if (!raw) return null;
try {
const host = new URL(/^[a-z][a-z\d+.-]*:\/\//.test(raw) ? raw : `https://${raw}`)
.hostname.replace(/\.$/, "").replace(/^www\./, "");
const valid =
host.length <= 253 &&
host.includes(".") &&
!/^\d{1,3}(\.\d{1,3}){3}$/.test(host) &&
host.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
return valid ? host : null;
} catch {
return null;
}
};
// Same rules as the server: "https://www.linkedin.com/in/John-Doe/?trk=x" -> "john-doe"
export const canonicalHandle = (value) => {
if (typeof value !== "string" || !/linkedin\.com/i.test(value)) return null;
const raw = value.trim().replace(/^@/, "");
try {
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
const host = url.hostname.replace(/^www\./, "");
if (host !== "linkedin.com" && !host.endsWith(".linkedin.com")) return null;
const [section, slug] = url.pathname.split("/").filter(Boolean);
if (section?.toLowerCase() !== "in" || !slug) return null;
const handle = decodeURIComponent(slug).trim().replace(/[?#].*$/, "").replace(/\/+$/, "").toLowerCase();
return /^[a-z0-9][a-z0-9_%.-]*$/.test(handle) ? handle : null;
} catch {
return null; // malformed %-escape: the server rejects it too
}
};
const KINDS = {
companies: { field: "domains", canonical: canonicalDomain, toValue: (domain) => domain },
people: {
field: "linkedin_urls",
canonical: canonicalHandle,
// The API rejects a bare handle: send a profile URL.
toValue: (handle) => `https://www.linkedin.com/in/${encodeURIComponent(handle)}`,
},
};
/**
* kind : "companies" or "people".
* crmValues: current domains (companies) or LinkedIn profile URLs (people) from the CRM.
* registry : Set of the canonical values this sync manages: domains for companies,
* handles for people. One registry per kind and per organization.
* call : the client for that organization's key.
* save : stores the registry. Called before each upsert batch and after each removal batch.
*/
export async function syncExclusions(
kind,
crmValues,
registry,
{ call = defaultCall, save = async () => {}, prune = false, maxPruneRatio = 0.2, minPrune = 1 } = {},
) {
const { field, canonical } = KINDS[kind];
// An empty string, null or a non-string fails the whole request with 400: drop them first.
const values = [...new Set(crmValues.filter((value) => typeof value === "string" && value.trim() !== ""))];
const wanted = new Set(values.map(canonical).filter(Boolean));
if (wanted.size === 0) throw new Error("No usable value in the CRM export: sync aborted for safety.");
const sizeBefore = registry.size; // the cap applies to the registry as it was before this run
// 1. Upsert, 1,000 values per call. Register and save first, so a crash never leaves
// a value on the list that the registry does not know. A value the server puts in
// `invalid` has no canonical form, so it is never registered.
for (const batch of chunk(values)) {
batch.map(canonical).forEach((key) => key && registry.add(key));
await save(registry);
const { invalid } = await call(`/v1/exclusions/${kind}`, { method: "POST", body: { [field]: batch } });
if (invalid.length) console.warn("Skipped values:", invalid); // [{ value, reason }]
}
// 2. Removal: only with prune: true, only values in the registry
const stale = () => [...registry].filter((value) => !wanted.has(value));
const toRemove = stale();
if (!prune || toRemove.length === 0) return { removed: 0, pending: toRemove.length };
const cap = Math.max(minPrune, Math.floor(sizeBefore * maxPruneRatio));
if (toRemove.length > cap) {
throw new Error(`Removing ${toRemove.length} of ${sizeBefore} registered values refused: check the CRM export.`);
}
const removed = await removeRegistered(kind, toRemove, registry, call, save);
return { removed, pending: stale().length };
}
/** Removes every registered value, with no cap. See "Switching to the integration". */
export async function releaseExclusions(kind, registry, { call = defaultCall, save = async () => {} } = {}) {
const removed = await removeRegistered(kind, [...registry], registry, call, save);
return { removed, left: registry.size };
}
async function removeRegistered(kind, values, registry, call, save) {
const { field, toValue } = KINDS[kind];
let removed = 0;
for (const batch of chunk(values)) {
const sent = batch.map(toValue);
const result = await call(`/v1/exclusions/${kind}/remove`, { method: "POST", body: { [field]: sent } });
removed += result.removed;
if (result.invalid.length) console.warn("Not removed:", result.invalid);
// Keep a value the server refused in the registry: the next run tries again.
const refused = new Set(result.invalid.map((entry) => entry.value));
batch.forEach((value, i) => refused.has(sent[i]) || registry.delete(value));
await save(registry);
}
return removed;
}