diff --git a/.security-review/suppressions.json b/.security-review/suppressions.json index e0e6398..4788f67 100644 --- a/.security-review/suppressions.json +++ b/.security-review/suppressions.json @@ -16,6 +16,10 @@ "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." }, + { + "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", "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." diff --git a/README.md b/README.md index 5890599..ef7a837 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ lambda/ ├── unlock/unlock-handler.ts # Unlock Lambda ├── lockdown/lockdown-handler.ts # Lockdown 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) ``` @@ -47,13 +48,14 @@ Instantiates `DoorUnlockStack` with an explicit `stackName` of `seahaven-door-un 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 `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 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` -- Imports of the SSM parameters, the phone-password secret, and the `site-alerts` SNS topic, with the corresponding `grantRead` IAM permissions -- The four per-Lambda CloudWatch error alarms +- 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 five per-Lambda CloudWatch error alarms ### `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 | |----------|-------|-------|----------|---------| | `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 | + +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). diff --git a/lambda/blf-sync/blf-sync-handler.ts b/lambda/blf-sync/blf-sync-handler.ts new file mode 100644 index 0000000..39818eb --- /dev/null +++ b/lambda/blf-sync/blf-sync-handler.ts @@ -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 { + 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(); + 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; +} diff --git a/lambda/blf-sync/blf-sync.test.ts b/lambda/blf-sync/blf-sync.test.ts new file mode 100644 index 0000000..11764bb --- /dev/null +++ b/lambda/blf-sync/blf-sync.test.ts @@ -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 = + '112'; + 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: "", + 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 = + '111112114'; + 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: "", + 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 = + '215'; + 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: "", + 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: "", + 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: "", + 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([]), ""); + assert.equal(blfsEqual("", 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); +}); diff --git a/lambda/blf-sync/blf-xml.ts b/lambda/blf-sync/blf-xml.ts new file mode 100644 index 0000000..f2fcc0d --- /dev/null +++ b/lambda/blf-sync/blf-xml.ts @@ -0,0 +1,63 @@ +export interface BlfEntry { + id: string; + blfNo: number; + blfType: string; + blfTypeId: string; + value: string; +} + +const BLF_TAG = + //]*)(?:\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, """); +} + +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 ``; + } + return `${escapeXml(entry.value)}`; + }) + .join(""); + return `${inner}`; +} + +export function blfsEqual(a: string | undefined | null, b: string): boolean { + return serializeBlfs(parseBlfs(a)) === serializeBlfs(parseBlfs(b)); +} diff --git a/lambda/blf-sync/department.ts b/lambda/blf-sync/department.ts new file mode 100644 index 0000000..163514a --- /dev/null +++ b/lambda/blf-sync/department.ts @@ -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 { + const byDept = new Map(); + 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; +} diff --git a/lambda/blf-sync/merge.ts b/lambda/blf-sync/merge.ts new file mode 100644 index 0000000..a2d2ee8 --- /dev/null +++ b/lambda/blf-sync/merge.ts @@ -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, + }; +} diff --git a/lambda/blf-sync/slot-contract.ts b/lambda/blf-sync/slot-contract.ts new file mode 100644 index 0000000..2a13abb --- /dev/null +++ b/lambda/blf-sync/slot-contract.ts @@ -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 = { + "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): 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; +} diff --git a/lambda/blf-sync/three-cx-client.ts b/lambda/blf-sync/three-cx-client.ts new file mode 100644 index 0000000..b2515f2 --- /dev/null +++ b/lambda/blf-sync/three-cx-client.ts @@ -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 { + 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(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(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(): Promise { + 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>(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>(path, payload); + return { status: patched.status }; + } +} diff --git a/lib/door-unlock-stack.ts b/lib/door-unlock-stack.ts index e8b0efa..7d4b17d 100644 --- a/lib/door-unlock-stack.ts +++ b/lib/door-unlock-stack.ts @@ -237,6 +237,58 @@ export class DoorUnlockStack extends cdk.Stack { 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", { apiName: "door-unlock-api", }); @@ -385,6 +437,12 @@ export class DoorUnlockStack extends cdk.Stack { alarmName: "door-unlock-api-lockdown-poller-errors", 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) { diff --git a/package-lock.json b/package-lock.json index 60dbff0..acb3ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "app": "bin/app.js" }, "devDependencies": { + "@aws-sdk/client-secrets-manager": "^3.1116.0", "@aws-sdk/client-ssm": "^3.1116.0", "@types/node": "^24.13.3", "@types/source-map-support": "^0.5.10", @@ -73,6 +74,26 @@ "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": { "version": "3.1117.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.1117.0.tgz", diff --git a/package.json b/package.json index 12f6677..385ecbe 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ }, "scripts": { "build": "tsc", + "test": "tsx --test lambda/blf-sync/*.test.ts", "cdk": "cdk", "synth": "cdk synth", "deploy": "cdk deploy", "diff": "cdk diff" }, "devDependencies": { + "@aws-sdk/client-secrets-manager": "^3.1116.0", "@aws-sdk/client-ssm": "^3.1116.0", "@types/node": "^24.13.3", "@types/source-map-support": "^0.5.10", diff --git a/scripts/blf-sync-local.ts b/scripts/blf-sync-local.ts new file mode 100644 index 0000000..8eba41a --- /dev/null +++ b/scripts/blf-sync-local.ts @@ -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); +}); diff --git a/tsconfig.json b/tsconfig.json index f50978b..f4c9493 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "resolveJsonModule": true, "esModuleInterop": true }, - "exclude": ["node_modules", "cdk.out"] + "exclude": ["node_modules", "cdk.out", "**/*.test.ts", "scripts"] } diff --git a/yealinkT54W-door-unlock-with-sp.ph.xml b/yealinkT54W-door-unlock-with-sp.ph.xml index 2a290c3..558c023 100644 --- a/yealinkT54W-door-unlock-with-sp.ph.xml +++ b/yealinkT54W-door-unlock-with-sp.ph.xml @@ -5189,33 +5189,49 @@ linekey.1.extension = %%PickupValue%% linekey.1.type = 0 {ENDIF} #Configure Line Key2 - Door Unlock (hardcoded) +# do not assign 3CX BLF index 2 — reserved for this URL key linekey.2.line = 1 linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__ linekey.2.pickup_value = %NULL% linekey.2.type = 17 linekey.2.label = Unlock Door linekey.2.extension = %NULL% -#Configure Line Key3 - Shared Parking SP1 (hardcoded) -linekey.3.line = 1 -linekey.3.value = SP1 -linekey.3.pickup_value = %NULL% -linekey.3.type = 10 -linekey.3.label = SP 1 -linekey.3.extension = %NULL% -#Configure Line Key4 - Shared Parking SP2 (hardcoded) -linekey.4.line = 1 -linekey.4.value = SP2 -linekey.4.pickup_value = %NULL% -linekey.4.type = 10 -linekey.4.label = SP 2 -linekey.4.extension = %NULL% -#Configure Line Key5 - Shared Parking SP3 (hardcoded) -linekey.5.line = 1 -linekey.5.value = SP3 -linekey.5.pickup_value = %NULL% -linekey.5.type = 10 -linekey.5.label = SP 3 -linekey.5.extension = %NULL% +#Configure Line Key3 +# SP1 is written by door-unlock-api-blf-sync as 3CX BLF index 3 +{IF blf3} +linekey.3.line = %%Line%% +linekey.3.value = %%type%% +linekey.3.pickup_value = %%PickupValue%% +linekey.3.type = %%DKtype%% +linekey.3.label = %%label%% +linekey.3.extension = %%PickupValue%% +{ELSE} +linekey.3.type = 0 +{ENDIF} +#Configure Line Key4 +# SP2 is written by door-unlock-api-blf-sync as 3CX BLF index 4 +{IF blf4} +linekey.4.line = %%Line%% +linekey.4.value = %%type%% +linekey.4.pickup_value = %%PickupValue%% +linekey.4.type = %%DKtype%% +linekey.4.label = %%label%% +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 {IF blf6} linekey.6.line = %%Line%% diff --git a/yealinkT54W-door-unlock.ph.xml b/yealinkT54W-door-unlock.ph.xml index 8d6d1f1..b1e7287 100644 --- a/yealinkT54W-door-unlock.ph.xml +++ b/yealinkT54W-door-unlock.ph.xml @@ -5189,6 +5189,7 @@ linekey.1.extension = %%PickupValue%% linekey.1.type = 0 {ENDIF} #Configure Line Key2 - Door Unlock (hardcoded) +# do not assign 3CX BLF index 2 — reserved for this URL key linekey.2.line = 1 linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__ linekey.2.pickup_value = %NULL% diff --git a/yealinkT58W-door-unlock.ph.xml b/yealinkT58W-door-unlock.ph.xml index 698d4d9..73aaa45 100644 --- a/yealinkT58W-door-unlock.ph.xml +++ b/yealinkT58W-door-unlock.ph.xml @@ -3094,6 +3094,7 @@ linekey.1.extension = %%PickupValue%% linekey.1.type = 0 {ENDIF} #Configure Line Key2 - Door Unlock (hardcoded) +# do not assign 3CX BLF index 2 — reserved for this URL key linekey.2.line = 1 linekey.2.value = https://doorunlock.seahaven.com/unlock?token=__DOOR_UNLOCK_TOKEN__ linekey.2.pickup_value = %NULL% @@ -3101,6 +3102,7 @@ linekey.2.type = 17 linekey.2.label = Unlock Door linekey.2.extension = %NULL% #Configure Line Key3 - Lockdown: Bohemia (hardcoded) +# do not assign 3CX BLF index 3 — reserved for this URL key linekey.3.line = 1 linekey.3.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=bohemia linekey.3.pickup_value = %NULL% @@ -3108,6 +3110,7 @@ linekey.3.type = 17 linekey.3.label = Lockdown BOH linekey.3.extension = %NULL% #Configure Line Key4 - Lockdown: Ronkonkoma (hardcoded) +# do not assign 3CX BLF index 4 — reserved for this URL key linekey.4.line = 1 linekey.4.value = https://doorunlock.seahaven.com/lockdown?token=__DOOR_UNLOCK_TOKEN__&profile=ronkonkoma linekey.4.pickup_value = %NULL%