seahaven-door-unlock-api/lambda/blf-sync/three-cx-client.ts
Adam Moussa bbc113497a
Some checks failed
Deploy / deploy (push) Has been cancelled
feat(3cx): sync office department blfs via xapi (PLAT-116) (#80)
* feat(3cx): sync office department BLFs via XAPI

Keep unlock and lockdown keys in the templates and write colleague plus shared-parking BLFs per extension so phones skip their own line.

* fix(3cx): preserve parking BLF IDs and fail the job on PATCH errors
2026-08-27 14:58:55 -04:00

107 lines
3.6 KiB
TypeScript

export interface ThreeCxConfig {
domain: string;
clientId: string;
clientSecret: string;
}
export class ThreeCxClient {
private accessToken: string | undefined;
private readonly baseUrl: string;
constructor(private readonly config: ThreeCxConfig) {
this.baseUrl = `https://${config.domain.replace(/^https?:\/\//, "")}`;
}
async getAccessToken(): Promise<string> {
if (this.accessToken) {
return this.accessToken;
}
const res = await fetch(`${this.baseUrl}/connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!res.ok) {
throw new Error(`3CX token request failed: ${res.status}`);
}
const body = (await res.json()) as { access_token?: string };
if (!body.access_token) {
throw new Error("3CX token response missing access_token");
}
this.accessToken = body.access_token;
return this.accessToken;
}
async getJson<T>(path: string): Promise<{ status: number; body: T }> {
const token = await this.getAccessToken();
const res = await fetch(`${this.baseUrl}${path}`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
});
const text = await res.text();
const body = (text ? JSON.parse(text) : {}) as T;
return { status: res.status, body };
}
async patchJson<T>(path: string, payload: unknown): Promise<{ status: number; body: T }> {
const token = await this.getAccessToken();
const res = await fetch(`${this.baseUrl}${path}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const text = await res.text();
const body = (text ? JSON.parse(text) : {}) as T;
return { status: res.status, body };
}
async listUsers<T>(): Promise<T[]> {
const users: T[] = [];
let path =
"/xapi/v1/Users?$top=100&$select=Id,Number,FirstName,LastName,Blfs,PrimaryGroupId&$expand=Groups($select=Id,Name),Phones";
for (let i = 0; i < 20; i++) {
const page = await this.getJson<{ value?: T[]; "@odata.nextLink"?: string }>(path);
if (page.status !== 200) {
throw new Error(`3CX Users list failed: ${page.status}`);
}
users.push(...(page.body.value ?? []));
const next = page.body["@odata.nextLink"];
if (!next) {
break;
}
path = next.replace(this.baseUrl, "");
}
return users;
}
async getPeerByNumber(number: string): Promise<{ Id: number; Number: string; Name?: string } | undefined> {
const encoded = number.replace(/'/g, "''");
const page = await this.getJson<{ value?: Array<{ Id: number; Number: string; Name?: string }> }>(
`/xapi/v1/Peers?$filter=Number eq '${encoded}'&$top=1`
);
if (page.status !== 200) {
return undefined;
}
return page.body.value?.[0];
}
async patchUserBlfs(userId: number, blfs: string): Promise<{ status: number }> {
const path = `/xapi/v1/Users(${userId})`;
const current = await this.getJson<Record<string, unknown>>(path);
if (current.status !== 200) {
return { status: current.status };
}
const payload = { ...current.body };
delete payload["@odata.context"];
payload.Blfs = blfs;
const patched = await this.patchJson<Record<string, unknown>>(path, payload);
return { status: patched.status };
}
}