diff --git a/Layout/default/VoiceCallHistory/Index.php b/Layout/default/VoiceCallHistory/Index.php new file mode 100644 index 000000000..d83832ade --- /dev/null +++ b/Layout/default/VoiceCallHistory/Index.php @@ -0,0 +1,88 @@ + self::getUrl("Domain"), + "DASHBOARD_URL" => self::getUrl("Dashboard"), + "MFAPPNAME" => MFAPPNAME_SLUG, + "PAGE_TITLE" => "Domains", + "PATH" => [ + ["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")], + ["text" => "Voice Calls History", "href" => self::getUrl("VoiceCallHistory")] + ], + "VOICE_CALL_HISTORY_API_URL" => self::getUrl("VoiceCallHistory/api"), +]; + +$additionalJS = ["plugins/vue/vue.js", + "plugins/axios/axios.min.js", + "plugins/vue/tt-components/tt-page-title.js", + "plugins/vue/tt-components/tt-table.js", +]; +$additionalCSS = [ + 'plugins/vue/tt-components/css/tt-table.css', +]; + +include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?> + +
+ + + + + +
+ + + + + + + + + + + diff --git a/Layout/default/menu.php b/Layout/default/menu.php index d9abd36ee..29c440775 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -125,7 +125,8 @@ diff --git a/application/VoiceCallHistory/VoiceCallHistory.php b/application/VoiceCallHistory/VoiceCallHistory.php new file mode 100644 index 000000000..e334b4004 --- /dev/null +++ b/application/VoiceCallHistory/VoiceCallHistory.php @@ -0,0 +1,9 @@ +loadMe(); + $this->layout()->set("me", $me); + $this->me = $me; + + if (!$this->me->isAdmin()) { + $this->redirect("dashboard"); + } + + $this->kolmisoftMore = new KolmisoftMore($this->VOICE_PORTAL_HOST, $this->VOICE_PORTAL_API_KEY, $this->VOICE_PORTAL_USERNAME); + + } + + protected function indexAction(): void { + $this->layout()->setTemplate("VoiceCallHistory/Index"); + } + + protected function apiAction() { + $do = $this->request->do; + + if (!$this->me->isAdmin()) { + $this->redirect("dashboard"); + } + + switch ($do) { + case "getCalls": + $return = $this->getCalls(); + break; + case "importCallsFromToday": + $return = $this->importCallsFromToday(); + break; + default: + $return = false; + break; + } + + if (!$return) { + $return = [ + "status" => "error", + "message" => "Invalid request." + ]; + } + + die(json_encode($return)); + } + + private function importCallsFromToday(): array { + $startDate = strtotime(date("Y-m-d 8:00:00")); + $endDate = strtotime(date("Y-m-d 9:00:59")); + + $callHistory = $this->kolmisoftMore->getVoiceCallHistory($startDate, $endDate); + + return VoiceCallHistoryModel::importCallsFromKolmisoft($callHistory); + } + + private function getCalls(): array { + $json = json_decode(file_get_contents('php://input'), true); + + $filters = $json['filters'] ?? []; + $page = $json['pagination']['page'] ?? 1; + $perPage = $json['pagination']['per_page'] ?? 10; + + $calls = VoiceCallHistoryModel::getVoiceCallHistory($filters, $perPage, $perPage * $page - $perPage); + $totalRows = VoiceCallHistoryModel::countVoiceCallHistory($filters); + + return [ + "rows" => $calls, + "pagination" => [ + "page" => $page, + "total_pages" => ceil($totalRows / $perPage), + "per_page" => $perPage, + "total_rows" => intval($totalRows) + ] + ]; + + } + + +} \ No newline at end of file diff --git a/application/VoiceCallHistory/VoiceCallHistoryModel.php b/application/VoiceCallHistory/VoiceCallHistoryModel.php new file mode 100644 index 000000000..59f6f1d67 --- /dev/null +++ b/application/VoiceCallHistory/VoiceCallHistoryModel.php @@ -0,0 +1,146 @@ + $value) { + if (property_exists(get_called_class(), $field)) { + $this->$field = $value; + } + } + } + + /** + * Generate SQL Filter condition (space separated) for a given column. + * + * @param string|null $filterValue The filter value to match against. + * @param string $columnName The name of the column in the database table. + * @return string The SQL condition generated based on the filter value and column name. + */ + public static function generateFilterCondition(?string $filterValue, string $columnName): string { + $sql = ""; + if (!empty($filterValue)) { + $filterItems = explode(" ", $filterValue); + foreach ($filterItems as $item) { + $sql .= " AND `$columnName` LIKE '%" . $item . "%'"; + } + } + + return $sql; + } + + //TODO: combine these two functions into one + public static function importCallsFromKolmisoft($callHistory): array { + { + + $modifiedCallHistory = []; + for ($i = 0; $i < count($callHistory); $i++) { + $call = $callHistory[$i]; + $date = date("Y-m-d H:i:s", strtotime($call["calldate2"] . " UTC")); + $date = new DateTime($date, new DateTimeZone('UTC')); + $date->setTimezone(new DateTimeZone('Europe/Vienna')); + $date = $date->format('Y-m-d H:i:s'); + + $modifiedCall = [ + "uid" => $call["uniqueid"], + "voice_account" => strpos($call["clid"], "Anonymous") !== false ? 'Anonymous' : preg_replace('/[^0-9]/', '', explode(" ", $call["clid"])[0]), + "start" => $date, + "source" => strpos($call["clid"], "nymous") !== false ? 'Anonymous' : str_replace("+", "", $call["src"]), + "destination" => $call["dst"], + "state" => preg_replace('/[^0-9]/', '', explode("(", $call["dispod"])[1]), + "billable" => gettype($call["did"]) === "string" ? 0 : 1, + "duration" => $call["nice_billsec"], + ]; + $modifiedCallHistory[] = $modifiedCall; + } + + $voiceHistoryImport = VoiceCallHistoryModel::importVoiceCallHistory($modifiedCallHistory); + + if ($callHistory === false) { + return [ + "status" => "error", + "message" => "Failed to import calls." + ]; + } + + return [ + "status" => "success", + "message" => $voiceHistoryImport["message"] + ]; + } + } + + public static function importVoiceCallHistory($callHistory): array { + $db = FronkDB::singleton(); + + $sql = /** @lang text */ "INSERT INTO `VoiceCallHistory` (`uid`, `voice_account`, `start`, `source`, `destination`, `state`, `billable`, `duration`) VALUES "; + $values = []; + foreach ($callHistory as $voiceCall) { + $uid = $voiceCall['uid']; + $valueStr = "(" . + ($voiceCall['uid'] === 'Anonymous' ? 'NULL' : "'$uid'") . ", " . + ($voiceCall['voice_account'] === 'Anonymous' ? 'NULL' : "'" . $voiceCall['voice_account']. "'" ) . ", '" . + $voiceCall['start'] . "', '" . + ($voiceCall['source'] === 'Anonymous' ? "NULL" : $voiceCall['source']) . "', '" . + ($voiceCall['destination'] === 'Anonymous' ? "NULL" : $voiceCall['destination'] ) . "', " . + $voiceCall['state'] . ", " . + $voiceCall['billable'] . ", " . + $voiceCall['duration'] . ")"; + + $values[] = $valueStr; + } + + $sql .= implode(", ", $values); + $sql .= " ON DUPLICATE KEY UPDATE duration = VALUES(duration)"; + + $db->query($sql); + + return [ + "message" => "Imported " . count($callHistory) . " calls to the history." + ]; + } + + public static function getSqlFilter($filters): string { + + $sql = ""; + if (isset($filters['start']['from']) && isset($filters['start']['to'])) { + $sql = " AND `start` >= FROM_UNIXTIME(" . $filters['start']['from'] . ") AND `start` <= FROM_UNIXTIME(" . $filters['start']['to'] . ")"; + } + $sql .= isset($filters['end']) ? " AND `start` <= '" . $filters['end'] . "'" : ""; + $sql .= isset($filters['source']) ? self::generateFilterCondition($filters['source'], "source") : ""; + $sql .= isset($filters['destination']) ? self::generateFilterCondition($filters['destination'], "destination") : ""; + $sql .= isset($filters['billable']) ? self::generateFilterCondition($filters['billable'], "billable") : ""; + $sql .= isset($filters['duration']) ? self::generateFilterCondition($filters['duration'], "duration") : ""; + + return $sql; + } + + public static function getVoiceCallHistory($filters, $limit = null, $offset = 0): array { + $db = FronkDB::singleton(); + $sql = "SELECT * FROM `VoiceCallHistory` WHERE 1 " . self::getSqlFilter($filters); + $sql .= $limit === null ? "" : " LIMIT " . $limit . " OFFSET " . $offset; + + $result = $db->query($sql); + $rows = []; + while ($row = $result->fetch_assoc()) { + $rows[] = new VoiceCallHistoryModel($row); + } + + return $rows; + } + + public static function countVoiceCallHistory($filters) { + $db = FronkDB::singleton(); + $sql = "SELECT COUNT(*) as `total_rows` FROM `VoiceCallHistory` WHERE 1 " . self::getSqlFilter($filters); + $result = $db->query($sql); + return $result->fetch_assoc()['total_rows']; + } +} \ No newline at end of file diff --git a/application/VoiceCallHistoryJob/VoiceCallHistoryJob.php b/application/VoiceCallHistoryJob/VoiceCallHistoryJob.php new file mode 100644 index 000000000..6dbe12e62 --- /dev/null +++ b/application/VoiceCallHistoryJob/VoiceCallHistoryJob.php @@ -0,0 +1,9 @@ +loadMe(); + $this->layout()->set("me", $me); + $this->me = $me; + + if (!$this->me->isAdmin()) { + $this->redirect("dashboard"); + } + + $this->kolmisoftMore = new KolmisoftMore($this->VOICE_PORTAL_HOST, $this->VOICE_PORTAL_API_KEY, $this->VOICE_PORTAL_USERNAME); + + } + + protected function indexAction(): void { + $this->layout()->setTemplate("VoiceCallHistoryJob/Index"); + } + + protected function apiAction() { + $do = $this->request->do; + + if (!$this->me->isAdmin()) { + $this->redirect("dashboard"); + } + + switch ($do) { + case "runJobs": + $return = $this->runJobs(); + break; + case "importCallsFromToday": + $return = $this->importCallsFromToday(); + break; + default: + $return = false; + break; + } + + if (!$return) { + $return = [ + "status" => "error", + "message" => "Invalid request." + ]; + } + + die(json_encode($return)); + } + + private function runJobs(): array { + VoiceCallHistoryJobModel::createJobsUntilToday(); + + $jobs = VoiceCallHistoryJobModel::getJobsNotDone(); + + $messages = [ + "success" => [], + "error" => [] + ]; + foreach ($jobs as $job) { + $startDate = strtotime(date("Y-m-d 00:00:00", strtotime($job->date))); + $endDate = strtotime(date("Y-m-d 00:00:00", strtotime($job->date . " +1 day"))); + + $callHistory = $this->kolmisoftMore->getVoiceCallHistory($startDate, $endDate); + + $importedCalls = VoiceCallHistoryModel::importCallsFromKolmisoft($callHistory); + + if ($importedCalls) { + $messages["success"][$job->date] = $importedCalls["message"]; + VoiceCallHistoryJobModel::updateJobStatus($job->id, "success"); + } else { + $messages["error"][$job->date] = "Failed to import calls for job $job->id."; + VoiceCallHistoryJobModel::updateJobStatus($job->id, "failed"); + } + } + + return $messages; + } +} \ No newline at end of file diff --git a/application/VoiceCallHistoryJob/VoiceCallHistoryJobModel.php b/application/VoiceCallHistoryJob/VoiceCallHistoryJobModel.php new file mode 100644 index 000000000..42d540f57 --- /dev/null +++ b/application/VoiceCallHistoryJob/VoiceCallHistoryJobModel.php @@ -0,0 +1,86 @@ + $value) { + if (property_exists(get_called_class(), $field)) { + $this->$field = $value; + } + } + } + + /** + * Generate SQL Filter condition (space separated) for a given column. + * + * @param string|null $filterValue The filter value to match against. + * @param string $columnName The name of the column in the database table. + * @return string The SQL condition generated based on the filter value and column name. + */ + public static function generateFilterCondition(?string $filterValue, string $columnName): string { + $sql = ""; + if (!empty($filterValue)) { + $filterItems = explode(" ", $filterValue); + foreach ($filterItems as $item) { + $sql .= " AND `$columnName` LIKE '%" . $item . "%'"; + } + } + + return $sql; + } + + public static function createJobsUntilToday(): array { + $db = FronkDB::singleton(); + + // $i = first day of the month; $i <= today; $i += 1 day + $values = []; + for ($i = strtotime(date("Y-m-01")); $i <= strtotime(date("Y-m-d")); $i += 86400) { + $values[] = "('" . date("Y-m-d", $i) . "')"; + } + + $valueStr = implode(", ", $values); + + $db->query("INSERT INTO `VoiceCallHistoryJob` (`date`) VALUES $valueStr ON DUPLICATE KEY UPDATE date=VALUES(date)"); + $db->query("UPDATE `VoiceCallHistoryJob` SET `status` = 'created', `finished` = NULL WHERE `date` = '" . date("Y-m-d") . "'"); + + return [ + "message" => "Created " . count($values) . " jobs." + ]; + } + + public static function updateJobStatus($id, $status): array { + $db = FronkDB::singleton(); + + $escapedStatus = $db->escape($status); + $escapedId = $db->escape($id); + + $finished = $status == "success" ? ", `finished` = NOW()" : ""; + + $db->query("UPDATE `VoiceCallHistoryJob` SET `status` = '$escapedStatus' $finished WHERE `id` = $escapedId"); + + return [ + "message" => "Updated job $id status to $status." + ]; + } + + public static function getJobsNotDone(): array { + $db = FronkDB::singleton(); + + $query = $db->query("SELECT * FROM `VoiceCallHistoryJob` WHERE `status` = 'created' OR `status` = 'failed' OR `status` = 'pending' ORDER BY `date`"); + + $items = []; + if($db->num_rows($query)) { + while($data = $db->fetch_object($query)) { + $items[] = new VoiceCallHistoryJobModel($data); + } + } + return $items; + } + +} \ No newline at end of file diff --git a/db/migrations/20240410150500_add_voice_call_history.php b/db/migrations/20240410150500_add_voice_call_history.php new file mode 100644 index 000000000..16a2864f7 --- /dev/null +++ b/db/migrations/20240410150500_add_voice_call_history.php @@ -0,0 +1,42 @@ +getEnvironment() == "thetool") { + + //VoiceCallHistory Table + $voiceCallHistoryTable = $this->table("VoiceCallHistory", ["signed" => true]); + $voiceCallHistoryTable->addColumn("id", "integer", ["identity" => true]); + $voiceCallHistoryTable->addColumn("uid", "string", ["null" => false, "limit" => 255]); + $voiceCallHistoryTable->addColumn("voice_account", "string", ["null" => false, "limit" => 255]); + $voiceCallHistoryTable->addColumn("start", "datetime", ["null" => false]); + $voiceCallHistoryTable->addColumn("source", "string", ["null" => true, "limit" => 255]); + $voiceCallHistoryTable->addColumn("destination", "string", ["null" => true, "limit" => 255]); + $voiceCallHistoryTable->addColumn("state", "integer", ["null" => true]); + $voiceCallHistoryTable->addColumn("billable", "integer", ["null" => false, "limit" => \Phinx\Db\Adapter\MysqlAdapter::INT_TINY]); + $voiceCallHistoryTable->addColumn("duration", "integer", ["null" => false]); + + //VoiceCallHistory Table Indexes + $voiceCallHistoryTable->addIndex(["billable"]); + $voiceCallHistoryTable->addIndex(["voice_account"]); + + //VoiceCallHistoryJob Table + $voiceCallHistoryJobTable = $this->table("VoiceCallHistoryJob", ["signed" => true]); + $voiceCallHistoryJobTable->addColumn("id", "integer", ["identity" => true]); + $voiceCallHistoryJobTable->addColumn("date", "date", ["null" => false]); + $voiceCallHistoryJobTable->addColumn("status", "enum", ["values" => ["created", "pending", "running", "success", "failed"], "default" => "created", "null" => false]); + $voiceCallHistoryJobTable->addColumn("finished", "date", ["null" => true]); + $voiceCallHistoryJobTable->addIndex(["date"], ["unique" => true]); + } + } + + public function down(): void { + if ($this->getEnvironment() == "thetool") { + $this->table("VoiceCallHistory")->drop()->save(); + $this->table("VoiceCallHistoryJob")->drop()->save(); + } + } +} diff --git a/public/plugins/vue/tt-components/tt-table.js b/public/plugins/vue/tt-components/tt-table.js index b0f92ab53..3f44c7ecf 100644 --- a/public/plugins/vue/tt-components/tt-table.js +++ b/public/plugins/vue/tt-components/tt-table.js @@ -17,6 +17,84 @@ * @property {string} class - The CSS class(es) applied to the column. */ +//TODO: export this to its own file +Vue.component('tt-date-range', { + template: ` + + `, props: ['value'], + data() { + return { + inputValue: '', + isInitialized: false, + locale: { + "format": "DD.MM.YYYY HH:mm", + "separator": " - ", + "applyLabel": "Übernehmen", + "cancelLabel": "Abbrechen", + "fromLabel": "Von", + "toLabel": "Bis", + "customRangeLabel": "Benutzerdefiniert", + "weekLabel": "W", + "daysOfWeek": [ + "So", + "Mo", + "Di", + "Mi", + "Do", + "Fr", + "Sa" + ], + "monthNames": [ + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember" + ], + "firstDay": 1 + }, + } + }, + methods: { + initialize() { + if (!this.isInitialized) { + this.isInitialized = true; + $(this.$refs.input).daterangepicker({ + autoUpdateInput: true, + timePicker: true, + timePicker24Hour: true, + locale: this.locale, + }); + + this.$refs.input.click(); + + const _this = this; + $(this.$refs.input).on('apply.daterangepicker', function(ev, picker) { + console.log('now emitting chang', picker.startDate.unix(), picker.endDate.unix()); + _this.$emit('change', { + target: { + value: { + from: picker.startDate.unix() + 7200, + to: picker.endDate.unix() + 7200 + } + } + }); + }); + } + } + }, + beforeDestroy() { + $(this.$refs.input).off('apply.daterangepicker'); + }, +}) + Vue.component('tt-table', { template: `
@@ -65,7 +143,7 @@ Vue.component('tt-table', { @@ -93,6 +171,7 @@ Vue.component('tt-table', { {{ filterOption.text }} +