setTable('department_gates'); } /** * Add a gate * @param departments_o $department The department for the gate * @param bool $is_entrance Whether the gate is an entrance gate (true) or an exit gate (false) * @param bool $is_exit Whether the gate is an exit gate (true) or an entrance gate (false) * @param string $name The name of the gate * @param department_gate_config $config A JSON with the gate config, including the type of gate and any relevant parameters (e.g. for a phone call triggered gate, the config would include the phone number to call and the call duration threshold) * @return department_gates_o * @throws Exception If the object was not created successfully */ public function add(departments_o $department, bool $is_entrance, bool $is_exit, string $name, department_gate_config $config): department_gates_o { global /** @var db $db */ $db; // Sanitize the input $department->requireSelected(); $name = trim($db->escape_string($name)); if (empty($name)) { throw new Exception('Gate name cannot be empty'); } if (!$is_entrance && !$is_exit) { throw new Exception('Gate must be either entrance, exit or both'); } $config->validate(); // Add the object $tmp_id = self::add_object([ 'department' => (int)$department->id, 'is_entrance' => (bool)$is_entrance, 'is_exit' => (bool)$is_exit, 'name' => $name, 'config' => (array)$config->toArray(), ]); $this->id = $tmp_id; self::getObjectProperties(); self::objectChanged(); return $this; } public function getObjectProperties(): void { $this->department = new object_property($this->table, $this->id, 'department', 'int', false); $this->is_entrance = new object_property($this->table, $this->id, 'is_entrance', 'bool', false); $this->is_exit = new object_property($this->table, $this->id, 'is_exit', 'bool', false); $this->name = new object_property($this->table, $this->id, 'name', 'string', false); $this->config = new object_property($this->table, $this->id, 'config', 'json', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false); $this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp', false); $this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp', false); } public function objectChanged(): void { //TODO: Add cache invalidation } /** * Get the gate as an array, including progress details if $include_progress is true * @return array The gate as an array * @throws Exception If the object is not valid or selected. */ public function asArray(): array { $this->requireSelected(); return [ 'id' => (int)$this->id, 'department' => (int)$this->department->value(), 'is_entrance' => (bool)$this->is_entrance->value(), 'is_exit' => (bool)$this->is_exit->value(), 'name' => (string)$this->name->value(), 'config' => (array)$this->config->value(), 'created_at' => (string)$this->created_at->value(), 'updated_at' => (string)$this->updated_at->value(), ]; } /** * @throws Exception * @return department_gates_o[] */ public function getDepartmentGates(int $department_id): array { $gates = []; $department_gates = self::getFieldsWhere( [ 'department' => $department_id, 'deleted_at' => null, ], [ 'id', ], ); foreach ($department_gates as $department_gate) { $gates[] = (new department_gates_o())->select((int)$department_gate['id']); } return $gates; } public function getEntranceGate(int $department_id): ?department_gates_o { $entrance_gate_data = self::getFieldsWhere( [ 'department' => $department_id, 'is_entrance' => true, 'deleted_at' => null, ], [ 'id', ], ); if (empty($entrance_gate_data)) { return null; } return (new department_gates_o())->select((int)$entrance_gate_data[0]['id']); } public function getExitGate(int $department_id): ?department_gates_o { $exit_gate_data = self::getFieldsWhere( [ 'department' => $department_id, 'is_exit' => true, 'deleted_at' => null, ], [ 'id', ], ); if (empty($exit_gate_data)) { return null; } return (new department_gates_o())->select((int)$exit_gate_data[0]['id']); } public static function normalizePhoneCandidate(mixed $candidate): ?array { if (is_array($candidate)) { $phone = $candidate['phone_number'] ?? null; if ($phone !== null) { $country = self::extractCountryCodeFromPhoneNumber((string)$phone); return self::normalizePhone((string)$phone, $country); } return null; } if (is_string($candidate) && trim($candidate) !== '') { return self::normalizePhone($candidate); } return null; } public static function normalizePhone(string $raw, ?int $defaultCountryCode = 45): ?array { $trimmed = trim($raw); if ($trimmed === '') { return null; } $digits = preg_replace('/\D+/', '', $trimmed); if (!is_string($digits) || $digits === '') { return null; } $country = $defaultCountryCode; $phone = $digits; if (str_starts_with($trimmed, '+')) { $extractedCountry = self::extractCountryCodeFromPhoneNumber($trimmed); if ($extractedCountry !== null) { $country = $extractedCountry; $phone = substr($digits, strlen((string)$extractedCountry)); } elseif (strlen($digits) > 8) { $country = (int)substr($digits, 0, 2); $phone = substr($digits, 2); } } elseif ($country !== null && str_starts_with($digits, (string)$country) && strlen($digits) > 8) { $phone = substr($digits, strlen((string)$country)); } if ($country === null || $country <= 0 || $phone === '') { return null; } return [$country, (int)$phone]; } public static function extractCountryCodeFromPhoneNumber(string $phone): ?int { if (str_starts_with($phone, '+45')) { return 45; } if (str_starts_with($phone, '+46')) { return 46; } if (str_starts_with($phone, '+47')) { return 47; } if (str_starts_with($phone, '+358')) { return 358; } if (str_starts_with($phone, '+49')) { return 49; } if (str_starts_with($phone, '+44')) { return 44; } if (str_starts_with($phone, '+1')) { return 1; } return null; } protected function matchesPhoneCallGateConfig(array $config): bool { return strtoupper(trim((string)($config['type'] ?? ''))) === 'PHONE_CALL'; } protected function resolveBirdClient(): bird { return new bird(); } protected function resolveSlackClient(): slack { return new slack(); } protected function resolveEdgeGatewayManager(): edge_gateway_manager { return new edge_gateway_manager(); } /** * @return array> */ public function getPhoneCallDepartmentSummaries(): array { $summaries = []; $gateRows = self::getFieldsWhere( [ 'deleted_at' => null, ], [ 'id', ], ); foreach ($gateRows as $gateRow) { $gate = (new department_gates_o())->select((int)$gateRow['id']); if (!$gate->exists()) { continue; } $config = (array)$gate->config->value(); if (!$this->matchesPhoneCallGateConfig($config)) { continue; } $departmentId = (int)$gate->department->value(); if ($departmentId <= 0) { continue; } if (!isset($summaries[$departmentId])) { $departmentRow = (new departments_o())->getDepartmentById($departmentId); $summaries[$departmentId] = [ 'department_id' => $departmentId, 'department_name' => trim((string)($departmentRow['name'] ?? ('Afdeling ' . $departmentId))), 'order_priority' => (int)($departmentRow['order_priority'] ?? PHP_INT_MAX), 'has_entrance_gate' => false, 'has_exit_gate' => false, ]; } if ((bool)$gate->is_entrance->value()) { $summaries[$departmentId]['has_entrance_gate'] = true; } if ((bool)$gate->is_exit->value()) { $summaries[$departmentId]['has_exit_gate'] = true; } } $summaries = array_values(array_filter($summaries, static function (array $summary): bool { return ($summary['has_entrance_gate'] ?? false) === true || ($summary['has_exit_gate'] ?? false) === true; })); usort($summaries, static function (array $left, array $right): int { $leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX); $rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX); if ($leftPriority !== $rightPriority) { return $leftPriority <=> $rightPriority; } return (int)($left['department_id'] ?? 0) <=> (int)($right['department_id'] ?? 0); }); return $summaries; } public function getEntrancePhoneCallGate(int $department_id): ?department_gates_o { $gates = $this->getDepartmentGates($department_id); foreach ($gates as $gate) { if (!$gate->exists() || !(bool)$gate->is_entrance->value()) { continue; } if ($this->matchesPhoneCallGateConfig((array)$gate->config->value())) { return $gate; } } return null; } public function getExitPhoneCallGate(int $department_id): ?department_gates_o { $gates = $this->getDepartmentGates($department_id); foreach ($gates as $gate) { if (!$gate->exists() || !(bool)$gate->is_exit->value()) { continue; } if ($this->matchesPhoneCallGateConfig((array)$gate->config->value())) { return $gate; } } return null; } public function openGate(): void { $this->requireSelected(); $config = (array)$this->config->value(); if ($this->matchesPhoneCallGateConfig($config)) { $this->openPhoneCallGate($config); return; } if (strtoupper(trim((string)($config['type'] ?? ''))) === 'RELAY') { $this->openRelayBackedGate($config); return; } throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? '')); } /** * @param array $config * @throws Exception */ protected function openPhoneCallGate(array $config): void { if (!isset($config['phone_number'])) { throw new Exception('Phone number is required for PHONE_CALL gate type'); } $normalized = self::normalizePhoneCandidate($config['phone_number']); if ($normalized === null) { throw new Exception('Invalid phone number for PHONE_CALL gate type'); } [$countryCode, $phone] = $normalized; $ringTimeout = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : 30; $client = $this->resolveBirdClient(); try { $client->callGatePreferringFlashCall($countryCode, $phone, $ringTimeout); } catch (\Throwable $e) { $this->resolveSlackClient()->send_message( 'Failed to call gate for phone ' . $countryCode . ' ' . $phone . ': ' . $e->getMessage(), 'Bird Voice Call Webhooks' ); throw new Exception('Failed to open gate relay via phone call', 0, $e); } } /** * @param array $config * @throws Exception */ protected function openRelayBackedGate(array $config): void { $relayId = trim((string)($config['relay_id'] ?? '')); if ($relayId === '') { throw new Exception('relay_id is required for RELAY gate type'); } $departmentId = (int)$this->department->value(); if ($departmentId <= 0) { throw new Exception('Gate department is invalid'); } $pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1; $manager = $this->resolveEdgeGatewayManager(); $manager->dispatchRelaySwitch($departmentId, $relayId, true, [ 'module' => 'department_gates', 'reason' => 'Open relay-backed department gate', 'gate_id' => (int)$this->id, 'gate_name' => (string)$this->name->value(), 'relay_id' => $relayId, 'pulse_seconds' => $pulseSeconds, ]); if ($pulseSeconds > 0) { usleep($pulseSeconds * 1000000); $manager->dispatchRelaySwitch($departmentId, $relayId, false, [ 'module' => 'department_gates', 'reason' => 'Close relay-backed department gate after pulse', 'gate_id' => (int)$this->id, 'gate_name' => (string)$this->name->value(), 'relay_id' => $relayId, 'pulse_seconds' => $pulseSeconds, ]); } } }