mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 04:53:10 +00:00
feat(3cx): sync office department blfs via xapi (PLAT-116) (#80)
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
* 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
This commit is contained in:
parent
39414156ef
commit
bbc113497a
17 changed files with 1014 additions and 27 deletions
|
|
@ -16,6 +16,10 @@
|
||||||
"id": "semgrep-detect-child-process-210",
|
"id": "semgrep-detect-child-process-210",
|
||||||
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 202 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
|
"justification": "False positive. Same as semgrep-detect-child-process-55: CDK local-bundling esbuild execSync with framework-supplied synth-time outputDir. Build-time only. INFRA-105. Re-pointed from line 202 on 2026-07-23: fromStringParameterName cleanup shifted lines in lib/door-unlock-stack.ts; finding unchanged."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "semgrep-detect-child-process-262",
|
||||||
|
"justification": "False positive. Same as the other CDK local-bundling esbuild execSync findings in lib/door-unlock-stack.ts. tryBundle(outputDir) for door-unlock-api-blf-sync; outputDir is supplied by the CDK framework at synth time; remaining path segments are repo-relative constants. No untrusted input, build-time only, never runs at request time. PLAT-116."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "checkov-CKV_AWS_111-234",
|
"id": "checkov-CKV_AWS_111-234",
|
||||||
"justification": "False positive. CDK-generated LogRetention custom-resource role (cdk.out synth output). logs:PutRetentionPolicy/DeleteRetentionPolicy on Resource:* is inherent to the aws-cdk LogRetention singleton construct (log-group names are not known at synth time). Accepted CDK boilerplate, not hand-written IAM. INFRA-105."
|
"justification": "False positive. CDK-generated LogRetention custom-resource role (cdk.out synth output). logs:PutRetentionPolicy/DeleteRetentionPolicy on Resource:* is inherent to the aws-cdk LogRetention singleton construct (log-group names are not known at synth time). Accepted CDK boilerplate, not hand-written IAM. INFRA-105."
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -35,7 +35,8 @@ lambda/
|
||||||
├── unlock/unlock-handler.ts # Unlock Lambda
|
├── unlock/unlock-handler.ts # Unlock Lambda
|
||||||
├── lockdown/lockdown-handler.ts # Lockdown Lambda
|
├── lockdown/lockdown-handler.ts # Lockdown Lambda
|
||||||
├── poller/lockdown-poller.ts # Lockdown Poller Lambda
|
├── poller/lockdown-poller.ts # Lockdown Poller Lambda
|
||||||
└── authorizer/authorizer-handler.ts # Token authorizer Lambda
|
├── authorizer/authorizer-handler.ts # Token authorizer Lambda
|
||||||
|
└── blf-sync/blf-sync-handler.ts # 3CX department BLF sync Lambda
|
||||||
cdk.json # CDK config (app command, watch, context flags)
|
cdk.json # CDK config (app command, watch, context flags)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -47,13 +48,14 @@ Instantiates `DoorUnlockStack` with an explicit `stackName` of `seahaven-door-un
|
||||||
|
|
||||||
Defines every resource the stack owns:
|
Defines every resource the stack owns:
|
||||||
|
|
||||||
- The four Lambda functions (Node 24.x, arm64, 60-day log retention), bundled from TypeScript with esbuild
|
- The five Lambda functions (Node 24.x, arm64, 60-day log retention), bundled from TypeScript with esbuild
|
||||||
- The HTTP API (`door-unlock-api`), its `GET /unlock`, `GET /lockdown`, and `GET /lockdown/status` routes, throttling, and JSON access logging
|
- The HTTP API (`door-unlock-api`), its `GET /unlock`, `GET /lockdown`, and `GET /lockdown/status` routes, throttling, and JSON access logging
|
||||||
- The `HttpLambdaAuthorizer` token authorizer (identity source `$request.querystring.token`, 5-minute result cache)
|
- The `HttpLambdaAuthorizer` token authorizer (identity source `$request.querystring.token`, 5-minute result cache)
|
||||||
- The EventBridge rule that invokes the poller once a minute, plus the poller's VPC config and security group (imported VPC/subnets, egress to the Elements API and phone LAN)
|
- The EventBridge rule that invokes the poller once a minute, plus the poller's VPC config and security group (imported VPC/subnets, egress to the Elements API and phone LAN)
|
||||||
|
- The EventBridge rule that invokes department BLF sync daily at 09:00 UTC, plus imports of the afterhours 3CX XAPI secrets
|
||||||
- The custom domain, ACM certificate import, and Route 53 A record for `doorunlock.seahaven.com`
|
- The custom domain, ACM certificate import, and Route 53 A record for `doorunlock.seahaven.com`
|
||||||
- Imports of the SSM parameters, the phone-password secret, and the `site-alerts` SNS topic, with the corresponding `grantRead` IAM permissions
|
- Imports of the SSM parameters, the phone-password secret, the afterhours 3CX XAPI secrets, and the `site-alerts` SNS topic, with the corresponding `grantRead` IAM permissions
|
||||||
- The four per-Lambda CloudWatch error alarms
|
- The five per-Lambda CloudWatch error alarms
|
||||||
|
|
||||||
### `cdk.json`
|
### `cdk.json`
|
||||||
|
|
||||||
|
|
@ -138,5 +140,15 @@ Custom 3CX templates are included with door unlock and lockdown URLs hardcoded.
|
||||||
| Template | Model | Key 2 | Keys 3-4 | Display |
|
| Template | Model | Key 2 | Keys 3-4 | Display |
|
||||||
|----------|-------|-------|----------|---------|
|
|----------|-------|-------|----------|---------|
|
||||||
| `yealinkT54W-door-unlock.ph.xml` | T54W | Unlock Door | Managed by 3CX BLF | Dim after 5 min, never sleep |
|
| `yealinkT54W-door-unlock.ph.xml` | T54W | Unlock Door | Managed by 3CX BLF | Dim after 5 min, never sleep |
|
||||||
| `yealinkT54W-door-unlock-with-sp.ph.xml` | T54W | Unlock Door | Shared Parking SP1-3 | Dim after 5 min, never sleep |
|
| `yealinkT54W-door-unlock-with-sp.ph.xml` | T54W | Unlock Door | SP1-3 via BLF sync | Dim after 5 min, never sleep |
|
||||||
| `yealinkT58W-door-unlock.ph.xml` | T58W | Unlock Door | Lockdown Toggle (Bohemia/Ronkonkoma) | Default T58W display settings |
|
| `yealinkT58W-door-unlock.ph.xml` | T58W | Unlock Door | Lockdown Toggle (Bohemia/Ronkonkoma) | Default T58W display settings |
|
||||||
|
|
||||||
|
Department colleague BLFs are not encoded in these templates. A scheduled Lambda (`door-unlock-api-blf-sync`) writes each Yealink user's 3CX BLF list from that user's first non-DEFAULT 3CX department, excluding the phone's own extension. Extension 100 is always included, even when the XAPI Users list omits it. Unlock and lockdown URL keys stay hardcoded in the template. Shared parking on the T54W+SP template is written by the sync job as 3CX SharedParking BLFs.
|
||||||
|
|
||||||
|
| Template | Reserved (never write) | Sync-owned parking | Own line | Managed department BLFs | Personal |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `yealinkT54W-door-unlock.ph.xml` | `blf2` | none | `blf1` | `blf3`–`blf12` | `blf13+` |
|
||||||
|
| `yealinkT54W-door-unlock-with-sp.ph.xml` | `blf2` | `blf3`–`blf5` (SP1–SP3) | `blf1` | `blf6`–`blf15` | `blf16+` |
|
||||||
|
| `yealinkT58W-door-unlock.ph.xml` | `blf2`–`blf4` | none | `blf1` | `blf5`–`blf14` | `blf15+` |
|
||||||
|
|
||||||
|
The job authenticates to 3CX XAPI with the existing `afterhours-shift-manager/3cx-*` Secrets Manager values. Invoke `door-unlock-api-blf-sync` with `DRY_RUN=true` for a proposed-XML log and no writes. Set `SMOKE_EXTENSION` to PATCH a single extension. The daily EventBridge rule runs at `09:00 UTC` (05:00 ET during EDT).
|
||||||
|
|
|
||||||
192
lambda/blf-sync/blf-sync-handler.ts
Normal file
192
lambda/blf-sync/blf-sync-handler.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import {
|
||||||
|
SecretsManagerClient,
|
||||||
|
GetSecretValueCommand,
|
||||||
|
} from "@aws-sdk/client-secrets-manager";
|
||||||
|
import {
|
||||||
|
ALWAYS_INCLUDE_EXTENSIONS,
|
||||||
|
addAlwaysIncludedColleagues,
|
||||||
|
groupUsersByDepartment,
|
||||||
|
isEligibleColleague,
|
||||||
|
primaryDepartmentName,
|
||||||
|
splitPeerName,
|
||||||
|
type ColleagueRef,
|
||||||
|
type SyncUser,
|
||||||
|
} from "./department";
|
||||||
|
import { mergeDepartmentBlfs } from "./merge";
|
||||||
|
import { resolveTemplateId, SLOT_CONTRACT } from "./slot-contract";
|
||||||
|
import { ThreeCxClient } from "./three-cx-client";
|
||||||
|
|
||||||
|
const secrets = new SecretsManagerClient({});
|
||||||
|
|
||||||
|
export interface BlfSyncEvent {
|
||||||
|
dryRun?: boolean;
|
||||||
|
smokeExtension?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSecretString(raw: string | undefined): string {
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("empty secret");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return typeof parsed === "string" ? parsed : raw;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSecret(name: string): Promise<string> {
|
||||||
|
const res = await secrets.send(new GetSecretValueCommand({ SecretId: name }));
|
||||||
|
return parseSecretString(res.SecretString);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTemplate(user: SyncUser) {
|
||||||
|
const phones = user.Phones ?? [];
|
||||||
|
for (const phone of phones) {
|
||||||
|
const id = resolveTemplateId(phone.TemplateName, phone.Name);
|
||||||
|
if (id) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handler(event: BlfSyncEvent = {}) {
|
||||||
|
const dryRun = event.dryRun ?? process.env.DRY_RUN !== "false";
|
||||||
|
const smokeExtension = (event.smokeExtension ?? process.env.SMOKE_EXTENSION ?? "").trim();
|
||||||
|
|
||||||
|
const domain = await readSecret(process.env.THREE_CX_DOMAIN_SECRET!);
|
||||||
|
const clientId = await readSecret(process.env.THREE_CX_CLIENT_ID_SECRET!);
|
||||||
|
const clientSecret = await readSecret(process.env.THREE_CX_CLIENT_SECRET_SECRET!);
|
||||||
|
const client = new ThreeCxClient({ domain, clientId, clientSecret });
|
||||||
|
|
||||||
|
const users = await client.listUsers<SyncUser>();
|
||||||
|
const byDept = groupUsersByDepartment(users);
|
||||||
|
const extraColleagues: ColleagueRef[] = [];
|
||||||
|
for (const number of ALWAYS_INCLUDE_EXTENSIONS) {
|
||||||
|
const already = users.find((u) => u.Number === number);
|
||||||
|
if (already && isEligibleColleague(already)) {
|
||||||
|
extraColleagues.push({
|
||||||
|
id: already.Id,
|
||||||
|
number: already.Number,
|
||||||
|
firstName: already.FirstName ?? "",
|
||||||
|
lastName: already.LastName ?? "",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const peer = await client.getPeerByNumber(number);
|
||||||
|
if (!peer) {
|
||||||
|
console.log(JSON.stringify({ action: "blf_sync_skip_extra", reason: "peer_not_found", extension: number }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const names = splitPeerName(peer.Name);
|
||||||
|
extraColleagues.push({
|
||||||
|
id: peer.Id,
|
||||||
|
number: peer.Number,
|
||||||
|
firstName: names.firstName,
|
||||||
|
lastName: names.lastName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
let unchanged = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const templateId = resolveTemplate(user);
|
||||||
|
if (!templateId) {
|
||||||
|
skipped += 1;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
action: "blf_sync_skip",
|
||||||
|
reason: "unknown_template",
|
||||||
|
extension: user.Number,
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dept = primaryDepartmentName(user);
|
||||||
|
if (!dept) {
|
||||||
|
skipped += 1;
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
action: "blf_sync_skip",
|
||||||
|
reason: "no_department",
|
||||||
|
extension: user.Number,
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colleagues = addAlwaysIncludedColleagues(
|
||||||
|
(byDept.get(dept) ?? [])
|
||||||
|
.filter(isEligibleColleague)
|
||||||
|
.map((u) => ({
|
||||||
|
id: u.Id,
|
||||||
|
number: u.Number,
|
||||||
|
firstName: u.FirstName ?? "",
|
||||||
|
lastName: u.LastName ?? "",
|
||||||
|
})),
|
||||||
|
extraColleagues
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = mergeDepartmentBlfs({
|
||||||
|
currentXml: user.Blfs,
|
||||||
|
selfExtension: user.Number,
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT[templateId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const logBase = {
|
||||||
|
extension: user.Number,
|
||||||
|
department: dept,
|
||||||
|
templateId,
|
||||||
|
placed: result.placed,
|
||||||
|
overflow: result.overflow,
|
||||||
|
changed: result.changed,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!result.changed) {
|
||||||
|
unchanged += 1;
|
||||||
|
console.log(JSON.stringify({ action: "blf_sync_unchanged", ...logBase }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun || (smokeExtension && user.Number !== smokeExtension)) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
action: dryRun ? "blf_sync_dry_run" : "blf_sync_skipped_not_smoke",
|
||||||
|
...logBase,
|
||||||
|
proposedBlfs: result.xml,
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const write = await client.patchUserBlfs(user.Id, result.xml);
|
||||||
|
if (write.status >= 200 && write.status < 300) {
|
||||||
|
patched += 1;
|
||||||
|
console.log(JSON.stringify({ action: "blf_sync_patched", ...logBase, status: write.status }));
|
||||||
|
} else {
|
||||||
|
failed += 1;
|
||||||
|
console.error(JSON.stringify({
|
||||||
|
action: "blf_sync_patch_failed",
|
||||||
|
...logBase,
|
||||||
|
status: write.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
action: "blf_sync_complete",
|
||||||
|
dryRun,
|
||||||
|
smokeExtension: smokeExtension || undefined,
|
||||||
|
visibleUsers: users.length,
|
||||||
|
departments: [...byDept.keys()],
|
||||||
|
patched,
|
||||||
|
unchanged,
|
||||||
|
skipped,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(summary));
|
||||||
|
if (failed > 0) {
|
||||||
|
throw new Error(`blf sync patch failed for ${failed} user(s)`);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
210
lambda/blf-sync/blf-sync.test.ts
Normal file
210
lambda/blf-sync/blf-sync.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { blfsEqual, parseBlfs, serializeBlfs } from "./blf-xml";
|
||||||
|
import { addAlwaysIncludedColleagues, isEligibleColleague, primaryDepartmentName, splitPeerName } from "./department";
|
||||||
|
import { mergeDepartmentBlfs } from "./merge";
|
||||||
|
import { resolveTemplateId, SLOT_CONTRACT } from "./slot-contract";
|
||||||
|
|
||||||
|
const colleagues = [
|
||||||
|
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
|
||||||
|
{ id: 33, number: "111", firstName: "Alyssa", lastName: "Ficarra" },
|
||||||
|
{ id: 38, number: "114", firstName: "Ashley", lastName: "Fedner" },
|
||||||
|
{ id: 66, number: "115", firstName: "Derrick", lastName: "Smith" },
|
||||||
|
{ id: 68, number: "113", firstName: "Sarah", lastName: "May" },
|
||||||
|
{ id: 69, number: "116", firstName: "Cindy", lastName: "Vallecillo" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("resolveTemplateId matches longest Sea Haven template first", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveTemplateId("yealinkT54W-door-unlock-with-sp.ph.xml"),
|
||||||
|
"t54w-door-unlock-with-sp"
|
||||||
|
);
|
||||||
|
assert.equal(resolveTemplateId("yealinkT54W-door-unlock.ph.xml"), "t54w-door-unlock");
|
||||||
|
assert.equal(resolveTemplateId("yealinkT58W-door-unlock.ph.xml"), "t58w-door-unlock");
|
||||||
|
assert.equal(resolveTemplateId("yealinkT54W.ph.xml"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseBlfs reads Line self-close and extension BLFs", () => {
|
||||||
|
const xml =
|
||||||
|
'<PhoneDevice><BLFS><BLF ID="-1" BLFNo="1" BLFType="Line" BLFTypeID="6" /><BLF ID="91" BLFNo="6" BLFType="BLF" BLFTypeID="0">112</BLF></BLFS></PhoneDevice>';
|
||||||
|
assert.deepEqual(parseBlfs(xml), [
|
||||||
|
{ id: "-1", blfNo: 1, blfType: "Line", blfTypeId: "6", value: "" },
|
||||||
|
{ id: "91", blfNo: 6, blfType: "BLF", blfTypeId: "0", value: "112" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge skips self and sorts by extension", () => {
|
||||||
|
const result = mergeDepartmentBlfs({
|
||||||
|
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||||
|
selfExtension: "114",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
const managed = parseBlfs(result.xml).filter((e) => e.blfNo >= 6);
|
||||||
|
assert.deepEqual(
|
||||||
|
managed.map((e) => e.value),
|
||||||
|
["100", "111", "113", "115", "116"]
|
||||||
|
);
|
||||||
|
assert.equal(result.overflow, 0);
|
||||||
|
assert.equal(result.placed, 5);
|
||||||
|
assert.equal(managed.some((e) => e.value === "114"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge never writes reserved T54W+SP unlock key 2", () => {
|
||||||
|
const current =
|
||||||
|
'<PhoneDevice><BLFS><BLF ID="33" BLFNo="1" BLFType="BLF" BLFTypeID="0">111</BLF><BLF ID="91" BLFNo="2" BLFType="BLF" BLFTypeID="0">112</BLF><BLF ID="38" BLFNo="3" BLFType="BLF" BLFTypeID="0">114</BLF></BLFS></PhoneDevice>';
|
||||||
|
const result = mergeDepartmentBlfs({
|
||||||
|
currentXml: current,
|
||||||
|
selfExtension: "113",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
const entries = parseBlfs(result.xml);
|
||||||
|
assert.equal(entries.some((e) => e.blfNo === 2), false);
|
||||||
|
assert.equal(entries.find((e) => e.blfNo === 1)?.blfType, "Line");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge writes SP1-SP3 on T54W+SP keys 3-5", () => {
|
||||||
|
const result = mergeDepartmentBlfs({
|
||||||
|
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||||
|
selfExtension: "111",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
const parking = parseBlfs(result.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 5);
|
||||||
|
assert.deepEqual(
|
||||||
|
parking.map((e) => ({ blfNo: e.blfNo, type: e.blfType, value: e.value })),
|
||||||
|
[
|
||||||
|
{ blfNo: 3, type: "SharedParking", value: "SP1" },
|
||||||
|
{ blfNo: 4, type: "SharedParking", value: "SP2" },
|
||||||
|
{ blfNo: 5, type: "SharedParking", value: "SP3" },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge preserves personal BLFs outside the managed range", () => {
|
||||||
|
const current =
|
||||||
|
'<PhoneDevice><BLFS><BLF ID="-1" BLFNo="1" BLFType="Line" BLFTypeID="6" /><BLF ID="9" BLFNo="16" BLFType="BLF" BLFTypeID="0">215</BLF></BLFS></PhoneDevice>';
|
||||||
|
const result = mergeDepartmentBlfs({
|
||||||
|
currentXml: current,
|
||||||
|
selfExtension: "116",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
const personal = parseBlfs(result.xml).find((e) => e.blfNo === 16);
|
||||||
|
assert.equal(personal?.value, "215");
|
||||||
|
assert.equal(personal?.id, "9");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge caps overflow at the managed slot count", () => {
|
||||||
|
const many = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
id: 200 + i,
|
||||||
|
number: String(300 + i),
|
||||||
|
firstName: "User",
|
||||||
|
lastName: `Z${String(i).padStart(2, "0")}`,
|
||||||
|
}));
|
||||||
|
const result = mergeDepartmentBlfs({
|
||||||
|
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||||
|
selfExtension: "111",
|
||||||
|
colleagues: many,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock"],
|
||||||
|
});
|
||||||
|
assert.equal(result.placed, 10);
|
||||||
|
assert.equal(result.overflow, 2);
|
||||||
|
const managed = parseBlfs(result.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 12);
|
||||||
|
assert.equal(managed.length, 10);
|
||||||
|
assert.equal(managed[0]?.blfNo, 3);
|
||||||
|
assert.equal(managed[9]?.blfNo, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge is a no-op when XML already matches", () => {
|
||||||
|
const first = mergeDepartmentBlfs({
|
||||||
|
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||||
|
selfExtension: "116",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
const second = mergeDepartmentBlfs({
|
||||||
|
currentXml: first.xml,
|
||||||
|
selfExtension: "116",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
assert.equal(first.changed, true);
|
||||||
|
assert.equal(second.changed, false);
|
||||||
|
assert.equal(blfsEqual(first.xml, second.xml), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merge keeps assigned shared-parking IDs so later runs are a no-op", () => {
|
||||||
|
const first = mergeDepartmentBlfs({
|
||||||
|
currentXml: "<PhoneDevice><BLFS/></PhoneDevice>",
|
||||||
|
selfExtension: "116",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
const assigned = serializeBlfs(
|
||||||
|
parseBlfs(first.xml).map((entry) =>
|
||||||
|
entry.blfType === "SharedParking" || entry.blfType === "Line"
|
||||||
|
? { ...entry, id: String(900 + entry.blfNo) }
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const second = mergeDepartmentBlfs({
|
||||||
|
currentXml: assigned,
|
||||||
|
selfExtension: "116",
|
||||||
|
colleagues,
|
||||||
|
contract: SLOT_CONTRACT["t54w-door-unlock-with-sp"],
|
||||||
|
});
|
||||||
|
assert.equal(second.changed, false);
|
||||||
|
const parking = parseBlfs(second.xml).filter((e) => e.blfNo >= 3 && e.blfNo <= 5);
|
||||||
|
assert.deepEqual(
|
||||||
|
parking.map((e) => e.id),
|
||||||
|
["903", "904", "905"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializeBlfs normalizes empty lists", () => {
|
||||||
|
assert.equal(serializeBlfs([]), "<PhoneDevice><BLFS></BLFS></PhoneDevice>");
|
||||||
|
assert.equal(blfsEqual("<PhoneDevice><BLFS/></PhoneDevice>", serializeBlfs([])), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("primaryDepartmentName prefers Office over DEFAULT", () => {
|
||||||
|
assert.equal(
|
||||||
|
primaryDepartmentName({
|
||||||
|
Id: 1,
|
||||||
|
Number: "111",
|
||||||
|
Groups: [{ Id: 28, Name: "DEFAULT" }, { Id: 142, Name: "Office" }],
|
||||||
|
}),
|
||||||
|
"Office"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
primaryDepartmentName({
|
||||||
|
Id: 2,
|
||||||
|
Number: "201",
|
||||||
|
Groups: [{ Id: 28, Name: "DEFAULT" }],
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("addAlwaysIncludedColleagues injects ext 100 when missing", () => {
|
||||||
|
const with100 = addAlwaysIncludedColleagues(colleagues, [
|
||||||
|
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
|
||||||
|
]);
|
||||||
|
assert.equal(with100.some((c) => c.number === "100"), true);
|
||||||
|
const again = addAlwaysIncludedColleagues(with100, [
|
||||||
|
{ id: 29, number: "100", firstName: "Adam", lastName: "Moussa" },
|
||||||
|
]);
|
||||||
|
assert.equal(again.filter((c) => c.number === "100").length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("splitPeerName uses last token as last name", () => {
|
||||||
|
assert.deepEqual(splitPeerName("Adam Moussa"), { firstName: "Adam", lastName: "Moussa" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isEligibleColleague skips queues and voicemail", () => {
|
||||||
|
assert.equal(isEligibleColleague({ Id: 1, Number: "111", FirstName: "A", LastName: "B" }), true);
|
||||||
|
assert.equal(isEligibleColleague({ Id: 2, Number: "801", FirstName: "After", LastName: "Hours" }), false);
|
||||||
|
assert.equal(isEligibleColleague({ Id: 3, Number: "201", FirstName: "Voicemail" }), false);
|
||||||
|
assert.equal(isEligibleColleague({ Id: 4, Number: "scheduler" }), false);
|
||||||
|
});
|
||||||
63
lambda/blf-sync/blf-xml.ts
Normal file
63
lambda/blf-sync/blf-xml.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
export interface BlfEntry {
|
||||||
|
id: string;
|
||||||
|
blfNo: number;
|
||||||
|
blfType: string;
|
||||||
|
blfTypeId: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLF_TAG =
|
||||||
|
/<BLF\b([^>/]*)(?:\s*\/>|>([\s\S]*?)<\/BLF>)/gi;
|
||||||
|
|
||||||
|
function attr(attrs: string, name: string): string {
|
||||||
|
const match = attrs.match(new RegExp(`\\b${name}="([^"]*)"`, "i"));
|
||||||
|
return match?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlfs(xml: string | undefined | null): BlfEntry[] {
|
||||||
|
if (!xml) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries: BlfEntry[] = [];
|
||||||
|
for (const match of xml.matchAll(BLF_TAG)) {
|
||||||
|
const attrs = match[1] ?? "";
|
||||||
|
const blfNo = Number.parseInt(attr(attrs, "BLFNo"), 10);
|
||||||
|
if (!Number.isFinite(blfNo)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
id: attr(attrs, "ID") || "-1",
|
||||||
|
blfNo,
|
||||||
|
blfType: attr(attrs, "BLFType") || "BLF",
|
||||||
|
blfTypeId: attr(attrs, "BLFTypeID") || "0",
|
||||||
|
value: (match[2] ?? "").trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeBlfs(entries: BlfEntry[]): string {
|
||||||
|
const sorted = [...entries].sort((a, b) => a.blfNo - b.blfNo);
|
||||||
|
const inner = sorted
|
||||||
|
.map((entry) => {
|
||||||
|
const attrs = `ID="${escapeXml(entry.id)}" BLFNo="${entry.blfNo}" BLFType="${escapeXml(entry.blfType)}" BLFTypeID="${escapeXml(entry.blfTypeId)}"`;
|
||||||
|
if (entry.value === "") {
|
||||||
|
return `<BLF ${attrs} />`;
|
||||||
|
}
|
||||||
|
return `<BLF ${attrs}>${escapeXml(entry.value)}</BLF>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<PhoneDevice><BLFS>${inner}</BLFS></PhoneDevice>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blfsEqual(a: string | undefined | null, b: string): boolean {
|
||||||
|
return serializeBlfs(parseBlfs(a)) === serializeBlfs(parseBlfs(b));
|
||||||
|
}
|
||||||
95
lambda/blf-sync/department.ts
Normal file
95
lambda/blf-sync/department.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
export interface UserGroup {
|
||||||
|
Id?: number;
|
||||||
|
Name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncUser {
|
||||||
|
Id: number;
|
||||||
|
Number: string;
|
||||||
|
FirstName?: string;
|
||||||
|
LastName?: string;
|
||||||
|
PrimaryGroupId?: number;
|
||||||
|
Groups?: UserGroup[];
|
||||||
|
Blfs?: string;
|
||||||
|
Phones?: Array<{
|
||||||
|
TemplateName?: string;
|
||||||
|
Name?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEUE_EXTENSION_MIN = 800;
|
||||||
|
|
||||||
|
export const ALWAYS_INCLUDE_EXTENSIONS = ["100"] as const;
|
||||||
|
|
||||||
|
export interface ColleagueRef {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitPeerName(name: string | undefined): { firstName: string; lastName: string } {
|
||||||
|
const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return { firstName: "", lastName: "" };
|
||||||
|
}
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { firstName: parts[0], lastName: "" };
|
||||||
|
}
|
||||||
|
return { firstName: parts.slice(0, -1).join(" "), lastName: parts[parts.length - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAlwaysIncludedColleagues(
|
||||||
|
colleagues: ColleagueRef[],
|
||||||
|
extras: ColleagueRef[]
|
||||||
|
): ColleagueRef[] {
|
||||||
|
const next = [...colleagues];
|
||||||
|
for (const extra of extras) {
|
||||||
|
if (!next.some((c) => c.number === extra.number)) {
|
||||||
|
next.push(extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEligibleColleague(user: SyncUser): boolean {
|
||||||
|
if (!/^\d+$/.test(user.Number)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Number.parseInt(user.Number, 10) >= QUEUE_EXTENSION_MIN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const first = (user.FirstName ?? "").trim();
|
||||||
|
const last = (user.LastName ?? "").trim();
|
||||||
|
if (!first && !last) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const full = `${first} ${last}`.trim().toLowerCase();
|
||||||
|
if (full === "voicemail") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function primaryDepartmentName(user: SyncUser): string | undefined {
|
||||||
|
const groups = user.Groups ?? [];
|
||||||
|
const named = groups.find((g) => (g.Name ?? "").toUpperCase() !== "DEFAULT" && (g.Name ?? "").trim() !== "");
|
||||||
|
if (named?.Name) {
|
||||||
|
return named.Name;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupUsersByDepartment(users: SyncUser[]): Map<string, SyncUser[]> {
|
||||||
|
const byDept = new Map<string, SyncUser[]>();
|
||||||
|
for (const user of users) {
|
||||||
|
const dept = primaryDepartmentName(user);
|
||||||
|
if (!dept) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const list = byDept.get(dept) ?? [];
|
||||||
|
list.push(user);
|
||||||
|
byDept.set(dept, list);
|
||||||
|
}
|
||||||
|
return byDept;
|
||||||
|
}
|
||||||
102
lambda/blf-sync/merge.ts
Normal file
102
lambda/blf-sync/merge.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import {
|
||||||
|
type BlfEntry,
|
||||||
|
parseBlfs,
|
||||||
|
serializeBlfs,
|
||||||
|
} from "./blf-xml";
|
||||||
|
import {
|
||||||
|
type SlotContract,
|
||||||
|
isManagedSlot,
|
||||||
|
isReservedSlot,
|
||||||
|
isSharedParkingSlot,
|
||||||
|
managedSlotCount,
|
||||||
|
} from "./slot-contract";
|
||||||
|
|
||||||
|
export interface Colleague {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergeResult {
|
||||||
|
xml: string;
|
||||||
|
changed: boolean;
|
||||||
|
overflow: number;
|
||||||
|
placed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortColleagues(colleagues: Colleague[]): Colleague[] {
|
||||||
|
return [...colleagues].sort((a, b) => a.number.localeCompare(b.number, "en", { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDepartmentBlfs(input: {
|
||||||
|
currentXml: string | undefined | null;
|
||||||
|
selfExtension: string;
|
||||||
|
colleagues: Colleague[];
|
||||||
|
contract: SlotContract;
|
||||||
|
}): MergeResult {
|
||||||
|
const current = parseBlfs(input.currentXml);
|
||||||
|
const others = sortColleagues(
|
||||||
|
input.colleagues.filter((c) => c.number !== input.selfExtension)
|
||||||
|
);
|
||||||
|
const slotCount = managedSlotCount(input.contract);
|
||||||
|
const placedColleagues = others.slice(0, slotCount);
|
||||||
|
const overflow = Math.max(0, others.length - slotCount);
|
||||||
|
|
||||||
|
const preserved = current.filter((entry) => {
|
||||||
|
if (entry.blfNo === input.contract.ownLine) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isReservedSlot(input.contract, entry.blfNo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isSharedParkingSlot(input.contract, entry.blfNo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isManagedSlot(input.contract, entry.blfNo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingOwnLine = current.find((entry) => entry.blfNo === input.contract.ownLine);
|
||||||
|
const ownLine: BlfEntry = {
|
||||||
|
id: existingOwnLine?.blfType === "Line" ? existingOwnLine.id : "-1",
|
||||||
|
blfNo: input.contract.ownLine,
|
||||||
|
blfType: "Line",
|
||||||
|
blfTypeId: "6",
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const parking: BlfEntry[] = input.contract.sharedParking.map((slot) => {
|
||||||
|
const existing = current.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.blfNo === slot.blfNo &&
|
||||||
|
entry.blfType === "SharedParking" &&
|
||||||
|
entry.value === slot.value
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: existing?.id ?? "-1",
|
||||||
|
blfNo: slot.blfNo,
|
||||||
|
blfType: "SharedParking",
|
||||||
|
blfTypeId: existing?.blfTypeId ?? "3",
|
||||||
|
value: slot.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const managed: BlfEntry[] = placedColleagues.map((colleague, index) => ({
|
||||||
|
id: String(colleague.id),
|
||||||
|
blfNo: input.contract.managedStart + index,
|
||||||
|
blfType: "BLF",
|
||||||
|
blfTypeId: "0",
|
||||||
|
value: colleague.number,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nextXml = serializeBlfs([...preserved, ownLine, ...parking, ...managed]);
|
||||||
|
return {
|
||||||
|
xml: nextXml,
|
||||||
|
changed: serializeBlfs(current) !== nextXml,
|
||||||
|
overflow,
|
||||||
|
placed: placedColleagues.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
lambda/blf-sync/slot-contract.ts
Normal file
80
lambda/blf-sync/slot-contract.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
export type TemplateId =
|
||||||
|
| "t54w-door-unlock"
|
||||||
|
| "t54w-door-unlock-with-sp"
|
||||||
|
| "t58w-door-unlock";
|
||||||
|
|
||||||
|
export interface SharedParkingSlot {
|
||||||
|
readonly blfNo: number;
|
||||||
|
readonly value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotContract {
|
||||||
|
readonly reserved: readonly number[];
|
||||||
|
readonly sharedParking: readonly SharedParkingSlot[];
|
||||||
|
readonly ownLine: number;
|
||||||
|
readonly managedStart: number;
|
||||||
|
readonly managedEnd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLOT_CONTRACT: Record<TemplateId, SlotContract> = {
|
||||||
|
"t54w-door-unlock": {
|
||||||
|
reserved: [2],
|
||||||
|
sharedParking: [],
|
||||||
|
ownLine: 1,
|
||||||
|
managedStart: 3,
|
||||||
|
managedEnd: 12,
|
||||||
|
},
|
||||||
|
"t54w-door-unlock-with-sp": {
|
||||||
|
reserved: [2],
|
||||||
|
sharedParking: [
|
||||||
|
{ blfNo: 3, value: "SP1" },
|
||||||
|
{ blfNo: 4, value: "SP2" },
|
||||||
|
{ blfNo: 5, value: "SP3" },
|
||||||
|
],
|
||||||
|
ownLine: 1,
|
||||||
|
managedStart: 6,
|
||||||
|
managedEnd: 15,
|
||||||
|
},
|
||||||
|
"t58w-door-unlock": {
|
||||||
|
reserved: [2, 3, 4],
|
||||||
|
sharedParking: [],
|
||||||
|
ownLine: 1,
|
||||||
|
managedStart: 5,
|
||||||
|
managedEnd: 14,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMPLATE_MATCHERS: { id: TemplateId; needle: string }[] = [
|
||||||
|
{ id: "t54w-door-unlock-with-sp", needle: "yealinkT54W-door-unlock-with-sp" },
|
||||||
|
{ id: "t54w-door-unlock", needle: "yealinkT54W-door-unlock" },
|
||||||
|
{ id: "t58w-door-unlock", needle: "yealinkT58W-door-unlock" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function resolveTemplateId(...candidates: Array<string | undefined | null>): TemplateId | undefined {
|
||||||
|
const haystack = candidates.filter(Boolean).join(" ");
|
||||||
|
if (!haystack) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
for (const matcher of TEMPLATE_MATCHERS) {
|
||||||
|
if (haystack.includes(matcher.needle)) {
|
||||||
|
return matcher.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isManagedSlot(contract: SlotContract, blfNo: number): boolean {
|
||||||
|
return blfNo >= contract.managedStart && blfNo <= contract.managedEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReservedSlot(contract: SlotContract, blfNo: number): boolean {
|
||||||
|
return contract.reserved.includes(blfNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSharedParkingSlot(contract: SlotContract, blfNo: number): boolean {
|
||||||
|
return contract.sharedParking.some((slot) => slot.blfNo === blfNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function managedSlotCount(contract: SlotContract): number {
|
||||||
|
return contract.managedEnd - contract.managedStart + 1;
|
||||||
|
}
|
||||||
107
lambda/blf-sync/three-cx-client.ts
Normal file
107
lambda/blf-sync/three-cx-client.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -237,6 +237,58 @@ export class DoorUnlockStack extends cdk.Stack {
|
||||||
targets: [new targets.LambdaFunction(pollerHandler)],
|
targets: [new targets.LambdaFunction(pollerHandler)],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const threeCxDomainSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||||
|
this, "ThreeCxDomain", "afterhours-shift-manager/3cx-domain"
|
||||||
|
);
|
||||||
|
const threeCxClientIdSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||||
|
this, "ThreeCxClientId", "afterhours-shift-manager/3cx-client-id"
|
||||||
|
);
|
||||||
|
const threeCxClientSecret = secretsmanager.Secret.fromSecretNameV2(
|
||||||
|
this, "ThreeCxClientSecret", "afterhours-shift-manager/3cx-client-secret"
|
||||||
|
);
|
||||||
|
|
||||||
|
const blfSyncHandler = new lambda.Function(this, "BlfSyncHandler", {
|
||||||
|
functionName: "door-unlock-api-blf-sync",
|
||||||
|
runtime: lambda.Runtime.NODEJS_24_X,
|
||||||
|
architecture: lambda.Architecture.ARM_64,
|
||||||
|
handler: "blf-sync-handler.handler",
|
||||||
|
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/blf-sync"), {
|
||||||
|
bundling: {
|
||||||
|
image: lambda.Runtime.NODEJS_24_X.bundlingImage,
|
||||||
|
local: {
|
||||||
|
tryBundle(outputDir: string) {
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
execSync(
|
||||||
|
`esbuild ${path.join(__dirname, "../lambda/blf-sync/blf-sync-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "blf-sync-handler.js")} --external:@aws-sdk/*`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
environment: {
|
||||||
|
THREE_CX_DOMAIN_SECRET: "afterhours-shift-manager/3cx-domain",
|
||||||
|
THREE_CX_CLIENT_ID_SECRET: "afterhours-shift-manager/3cx-client-id",
|
||||||
|
THREE_CX_CLIENT_SECRET_SECRET: "afterhours-shift-manager/3cx-client-secret",
|
||||||
|
DRY_RUN: "false",
|
||||||
|
},
|
||||||
|
timeout: cdk.Duration.seconds(60),
|
||||||
|
memorySize: 256,
|
||||||
|
logRetention: logs.RetentionDays.TWO_MONTHS,
|
||||||
|
});
|
||||||
|
|
||||||
|
threeCxDomainSecret.grantRead(blfSyncHandler);
|
||||||
|
threeCxClientIdSecret.grantRead(blfSyncHandler);
|
||||||
|
threeCxClientSecret.grantRead(blfSyncHandler);
|
||||||
|
|
||||||
|
// 05:00 ET during EDT (09:00 UTC).
|
||||||
|
new events.Rule(this, "BlfSyncSchedule", {
|
||||||
|
ruleName: "door-unlock-api-blf-sync-schedule",
|
||||||
|
schedule: events.Schedule.cron({ minute: "0", hour: "9" }),
|
||||||
|
enabled: true,
|
||||||
|
targets: [new targets.LambdaFunction(blfSyncHandler)],
|
||||||
|
});
|
||||||
|
|
||||||
const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", {
|
const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", {
|
||||||
apiName: "door-unlock-api",
|
apiName: "door-unlock-api",
|
||||||
});
|
});
|
||||||
|
|
@ -385,6 +437,12 @@ export class DoorUnlockStack extends cdk.Stack {
|
||||||
alarmName: "door-unlock-api-lockdown-poller-errors",
|
alarmName: "door-unlock-api-lockdown-poller-errors",
|
||||||
description: "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken",
|
description: "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "BlfSyncErrors",
|
||||||
|
fn: blfSyncHandler,
|
||||||
|
alarmName: "door-unlock-api-blf-sync-errors",
|
||||||
|
description: "door-unlock-api-blf-sync Lambda is erroring — department BLF updates may not be applying",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const spec of alarmSpecs) {
|
for (const spec of alarmSpecs) {
|
||||||
|
|
|
||||||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
||||||
"app": "bin/app.js"
|
"app": "bin/app.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-secrets-manager": "^3.1116.0",
|
||||||
"@aws-sdk/client-ssm": "^3.1116.0",
|
"@aws-sdk/client-ssm": "^3.1116.0",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/source-map-support": "^0.5.10",
|
"@types/source-map-support": "^0.5.10",
|
||||||
|
|
@ -73,6 +74,26 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@aws-sdk/client-secrets-manager": {
|
||||||
|
"version": "3.1119.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1119.0.tgz",
|
||||||
|
"integrity": "sha512-MOjLf+is1MIqPCRoFIHXn6oWvVbnEs6YdM1mscQhGTSNaWbPQhgn/SbNdbGo6yUJgTKCoURQbXKhrbzzMWxH7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/core": "^3.977.9",
|
||||||
|
"@aws-sdk/credential-provider-node": "^3.972.81",
|
||||||
|
"@aws-sdk/types": "^3.974.5",
|
||||||
|
"@smithy/core": "^3.33.3",
|
||||||
|
"@smithy/fetch-http-handler": "^5.7.2",
|
||||||
|
"@smithy/node-http-handler": "^4.11.3",
|
||||||
|
"@smithy/types": "^4.17.2",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@aws-sdk/client-ssm": {
|
"node_modules/@aws-sdk/client-ssm": {
|
||||||
"version": "3.1117.0",
|
"version": "3.1117.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1117.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1117.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test": "tsx --test lambda/blf-sync/*.test.ts",
|
||||||
"cdk": "cdk",
|
"cdk": "cdk",
|
||||||
"synth": "cdk synth",
|
"synth": "cdk synth",
|
||||||
"deploy": "cdk deploy",
|
"deploy": "cdk deploy",
|
||||||
"diff": "cdk diff"
|
"diff": "cdk diff"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-secrets-manager": "^3.1116.0",
|
||||||
"@aws-sdk/client-ssm": "^3.1116.0",
|
"@aws-sdk/client-ssm": "^3.1116.0",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/source-map-support": "^0.5.10",
|
"@types/source-map-support": "^0.5.10",
|
||||||
|
|
|
||||||
21
scripts/blf-sync-local.ts
Normal file
21
scripts/blf-sync-local.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { handler, type BlfSyncEvent } from "../lambda/blf-sync/blf-sync-handler";
|
||||||
|
|
||||||
|
process.env.THREE_CX_DOMAIN_SECRET ??= "afterhours-shift-manager/3cx-domain";
|
||||||
|
process.env.THREE_CX_CLIENT_ID_SECRET ??= "afterhours-shift-manager/3cx-client-id";
|
||||||
|
process.env.THREE_CX_CLIENT_SECRET_SECRET ??= "afterhours-shift-manager/3cx-client-secret";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const event: BlfSyncEvent = {
|
||||||
|
dryRun: !args.includes("--write"),
|
||||||
|
smokeExtension: args.find((a) => a.startsWith("--smoke="))?.slice("--smoke=".length),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.dryRun === false) {
|
||||||
|
process.env.DRY_RUN = "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(event).catch((err) => {
|
||||||
|
console.error(String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -21,5 +21,5 @@
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "cdk.out"]
|
"exclude": ["node_modules", "cdk.out", "**/*.test.ts", "scripts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5189,33 +5189,49 @@ linekey.1.extension = %%PickupValue%%
|
||||||
linekey.1.type = 0
|
linekey.1.type = 0
|
||||||
{ENDIF}
|
{ENDIF}
|
||||||
#Configure Line Key2 - Door Unlock (hardcoded)
|
#Configure Line Key2 - Door Unlock (hardcoded)
|
||||||
|
# do not assign 3CX BLF index 2 — reserved for this URL key
|
||||||
linekey.2.line = 1
|
linekey.2.line = 1
|
||||||
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
||||||
linekey.2.pickup_value = %NULL%
|
linekey.2.pickup_value = %NULL%
|
||||||
linekey.2.type = 17
|
linekey.2.type = 17
|
||||||
linekey.2.label = Unlock Door
|
linekey.2.label = Unlock Door
|
||||||
linekey.2.extension = %NULL%
|
linekey.2.extension = %NULL%
|
||||||
#Configure Line Key3 - Shared Parking SP1 (hardcoded)
|
#Configure Line Key3
|
||||||
linekey.3.line = 1
|
# SP1 is written by door-unlock-api-blf-sync as 3CX BLF index 3
|
||||||
linekey.3.value = SP1
|
{IF blf3}
|
||||||
linekey.3.pickup_value = %NULL%
|
linekey.3.line = %%Line%%
|
||||||
linekey.3.type = 10
|
linekey.3.value = %%type%%
|
||||||
linekey.3.label = SP 1
|
linekey.3.pickup_value = %%PickupValue%%
|
||||||
linekey.3.extension = %NULL%
|
linekey.3.type = %%DKtype%%
|
||||||
#Configure Line Key4 - Shared Parking SP2 (hardcoded)
|
linekey.3.label = %%label%%
|
||||||
linekey.4.line = 1
|
linekey.3.extension = %%PickupValue%%
|
||||||
linekey.4.value = SP2
|
{ELSE}
|
||||||
linekey.4.pickup_value = %NULL%
|
linekey.3.type = 0
|
||||||
linekey.4.type = 10
|
{ENDIF}
|
||||||
linekey.4.label = SP 2
|
#Configure Line Key4
|
||||||
linekey.4.extension = %NULL%
|
# SP2 is written by door-unlock-api-blf-sync as 3CX BLF index 4
|
||||||
#Configure Line Key5 - Shared Parking SP3 (hardcoded)
|
{IF blf4}
|
||||||
linekey.5.line = 1
|
linekey.4.line = %%Line%%
|
||||||
linekey.5.value = SP3
|
linekey.4.value = %%type%%
|
||||||
linekey.5.pickup_value = %NULL%
|
linekey.4.pickup_value = %%PickupValue%%
|
||||||
linekey.5.type = 10
|
linekey.4.type = %%DKtype%%
|
||||||
linekey.5.label = SP 3
|
linekey.4.label = %%label%%
|
||||||
linekey.5.extension = %NULL%
|
linekey.4.extension = %%PickupValue%%
|
||||||
|
{ELSE}
|
||||||
|
linekey.4.type = 0
|
||||||
|
{ENDIF}
|
||||||
|
#Configure Line Key5
|
||||||
|
# SP3 is written by door-unlock-api-blf-sync as 3CX BLF index 5
|
||||||
|
{IF blf5}
|
||||||
|
linekey.5.line = %%Line%%
|
||||||
|
linekey.5.value = %%type%%
|
||||||
|
linekey.5.pickup_value = %%PickupValue%%
|
||||||
|
linekey.5.type = %%DKtype%%
|
||||||
|
linekey.5.label = %%label%%
|
||||||
|
linekey.5.extension = %%PickupValue%%
|
||||||
|
{ELSE}
|
||||||
|
linekey.5.type = 0
|
||||||
|
{ENDIF}
|
||||||
#Configure Line Key6
|
#Configure Line Key6
|
||||||
{IF blf6}
|
{IF blf6}
|
||||||
linekey.6.line = %%Line%%
|
linekey.6.line = %%Line%%
|
||||||
|
|
|
||||||
|
|
@ -5189,6 +5189,7 @@ linekey.1.extension = %%PickupValue%%
|
||||||
linekey.1.type = 0
|
linekey.1.type = 0
|
||||||
{ENDIF}
|
{ENDIF}
|
||||||
#Configure Line Key2 - Door Unlock (hardcoded)
|
#Configure Line Key2 - Door Unlock (hardcoded)
|
||||||
|
# do not assign 3CX BLF index 2 — reserved for this URL key
|
||||||
linekey.2.line = 1
|
linekey.2.line = 1
|
||||||
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
||||||
linekey.2.pickup_value = %NULL%
|
linekey.2.pickup_value = %NULL%
|
||||||
|
|
|
||||||
|
|
@ -3094,6 +3094,7 @@ linekey.1.extension = %%PickupValue%%
|
||||||
linekey.1.type = 0
|
linekey.1.type = 0
|
||||||
{ENDIF}
|
{ENDIF}
|
||||||
#Configure Line Key2 - Door Unlock (hardcoded)
|
#Configure Line Key2 - Door Unlock (hardcoded)
|
||||||
|
# do not assign 3CX BLF index 2 — reserved for this URL key
|
||||||
linekey.2.line = 1
|
linekey.2.line = 1
|
||||||
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__
|
||||||
linekey.2.pickup_value = %NULL%
|
linekey.2.pickup_value = %NULL%
|
||||||
|
|
@ -3101,6 +3102,7 @@ linekey.2.type = 17
|
||||||
linekey.2.label = Unlock Door
|
linekey.2.label = Unlock Door
|
||||||
linekey.2.extension = %NULL%
|
linekey.2.extension = %NULL%
|
||||||
#Configure Line Key3 - Lockdown: Bohemia (hardcoded)
|
#Configure Line Key3 - Lockdown: Bohemia (hardcoded)
|
||||||
|
# do not assign 3CX BLF index 3 — reserved for this URL key
|
||||||
linekey.3.line = 1
|
linekey.3.line = 1
|
||||||
linekey.3.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=bohemia
|
linekey.3.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=bohemia
|
||||||
linekey.3.pickup_value = %NULL%
|
linekey.3.pickup_value = %NULL%
|
||||||
|
|
@ -3108,6 +3110,7 @@ linekey.3.type = 17
|
||||||
linekey.3.label = Lockdown BOH
|
linekey.3.label = Lockdown BOH
|
||||||
linekey.3.extension = %NULL%
|
linekey.3.extension = %NULL%
|
||||||
#Configure Line Key4 - Lockdown: Ronkonkoma (hardcoded)
|
#Configure Line Key4 - Lockdown: Ronkonkoma (hardcoded)
|
||||||
|
# do not assign 3CX BLF index 4 — reserved for this URL key
|
||||||
linekey.4.line = 1
|
linekey.4.line = 1
|
||||||
linekey.4.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=ronkonkoma
|
linekey.4.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=ronkonkoma
|
||||||
linekey.4.pickup_value = %NULL%
|
linekey.4.pickup_value = %NULL%
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue