14 private function db() {
15 return dbx()->get_system_obj(
'dbxDB');
18 private function tpl() {
19 return dbx()->get_system_obj(
'dbxTPL');
22 private function h($value):
string {
23 return htmlspecialchars((
string) $value, ENT_QUOTES,
'UTF-8');
26 private function alert(
string $type,
string $message):
string {
27 return $this->tpl()->get_tpl(
'dbx|alert-' . $type, array(
'msg' => $message));
30 private function schemaReady():
bool {
32 $server =
'dbxContact|dbxContact.db3';
37 $tables =
$db->select_query(
$server,
"SELECT name FROM sqlite_master WHERE type='table' AND name='contact_message' LIMIT 1");
38 if (!is_array($tables) || empty($tables)) {
42 $columns =
$db->select_query(
$server,
'PRAGMA table_info(contact_request)');
44 foreach ((array) $columns as $column) {
45 $names[] = (string) ($column[
'name'] ??
'');
48 return in_array(
'priority', $names,
true)
49 && in_array(
'last_activity_date', $names,
true)
50 && in_array(
'user_hidden', $names,
true);
53 private function statusClass(
string $status):
string {
55 'open' =>
'text-bg-primary',
56 'in_progress' =>
'text-bg-info',
57 'waiting_customer' =>
'text-bg-warning',
58 'answered' =>
'text-bg-success',
59 'closed' =>
'text-bg-secondary',
60 )[$status] ??
'text-bg-light';
63 private function priorityClass(
string $priority):
string {
65 'low' =>
'text-bg-secondary',
66 'normal' =>
'text-bg-primary',
67 'high' =>
'text-bg-warning',
68 'urgent' =>
'text-bg-danger',
69 )[$priority] ??
'text-bg-light';
72 private function counts(): array {
83 private function reportStatusStatsHtml():
string {
84 $counts = $this->counts();
86 .
'<span class="dbx-report-bar-stat ms-3"><span class="badge text-bg-primary">Offen: ' . (int) $counts[
'count_open'] .
'</span></span>'
87 .
'<span class="dbx-report-bar-stat"><span class="badge text-bg-info">In Bearbeitung: ' . (
int) $counts[
'count_progress'] .
'</span></span>'
88 .
'<span class="dbx-report-bar-stat"><span class="badge text-bg-warning">Rueckfragen: ' . (int) $counts[
'count_waiting'] .
'</span></span>'
89 .
'<span class="dbx-report-bar-stat"><span class="badge text-bg-success">Beantwortet: ' . (
int) $counts[
'count_answered'] .
'</span></span>'
90 .
'<span class="dbx-report-bar-stat"><span class="badge text-bg-secondary">Geschlossen: ' . (int) $counts[
'count_closed'] .
'</span></span>';
93 private function frame(
string $content,
string $title =
'Kontaktanfragen und Tickets'):
string {
95 .
'<a class="btn btn-outline-secondary btn-sm" href="?dbx_modul=dbxContact_admin&dbx_run1=install"><i class="bi bi-database-gear"></i> Installation</a>'
96 .
'<a class="btn btn-primary btn-sm" href="?dbx_modul=dbxContact_admin&dbx_run1=list"><i class="bi bi-arrow-clockwise"></i> Aktualisieren</a>';
98 return $this->tpl()->get_tpl(
'dbxContact_admin|ticket-admin-shell', array_merge(
101 'bar_title' => $this->h($title),
102 'bar_icon' =>
'bi-life-preserver',
103 'bar_subtitle' =>
'Anfragen, Antworten, Status und vollstaendiger Verlauf',
104 'bar_title_pre' =>
'',
105 'bar_title_heading_attrs' =>
'',
108 'bar_actions' => $actions,
109 'content' => $content,
114 private function install():
string {
115 $dd =
dbx()->get_system_obj(
'dbxDD');
118 foreach (array(
'contactMessage') as $name) {
119 $dd->sync_dd_to_db(
'dbxContact', $name,
'reset');
121 for ($i = 0; $i < 100; $i++) {
122 $state = $dd->sync_dd_to_db(
'dbxContact', $name,
'apply');
123 if (in_array((
string) ($state[
'status'] ??
''), array(
'finished',
'error'),
true)) {
130 foreach (
$results as $name => $state) {
131 if ((
string) ($state[
'status'] ??
'') !==
'finished') {
132 return $this->frame($this->alert(
'danger',
'Installation von ' . $this->h($name) .
' fehlgeschlagen: ' . $this->h($state[
'message'] ??
'unbekannt')));
136 $name =
'contactRequest';
137 $dd->sync_dd_to_db(
'dbxContact', $name,
'reset');
138 for ($i = 0; $i < 100; $i++) {
139 $state = $dd->sync_dd_to_db(
'dbxContact', $name,
'force');
140 if (in_array((
string) ($state[
'status'] ??
''), array(
'finished',
'error'),
true)) {
146 foreach (
$results as $name => $state) {
147 if ((
string) ($state[
'status'] ??
'') !==
'finished') {
148 return $this->frame($this->alert(
'danger',
'Installation von ' . $this->h($name) .
' fehlgeschlagen: ' . $this->h($state[
'message'] ??
'unbekannt')));
154 foreach ((array) $rows as $ticket) {
156 if (trim((
string) ($ticket[
'last_activity_date'] ??
'')) ===
'') {
157 $last = (string) ($ticket[
'create_date'] ?? date(
'Y-m-d H:i:s'));
160 'last_activity_date' => $last,
161 ), (
int) ($ticket[
'id'] ?? 0), 0, 1, 1, 0);
167 $this->alert(
'success',
'Ticket-Datenmodell installiert.')
168 . ($contentPageId > 0
169 ?
'<div class="alert alert-info mx-3">Die dbxContent-Seite „Meine Anfragen“ ist unter <a href="meine-anfragen">meine-anfragen</a> verfuegbar.</div>'
170 :
'<div class="alert alert-warning mx-3">Die dbxContent-Seite „Meine Anfragen“ konnte nicht automatisch angelegt werden.</div>')
171 .
'<div class="p-3"><a class="btn btn-primary" href="?dbx_modul=dbxContact_admin&dbx_run1=list"><i class="bi bi-list-ul"></i> Tickets anzeigen</a></div>'
175 private function whereForReport(
string $search,
string $status,
string $priority):
string {
181 $clauses[] =
"status = '" .
$db->escape($status,
$server) .
"'";
184 $clauses[] =
"priority = '" .
$db->escape($priority,
$server) .
"'";
186 if ($search !==
'') {
188 $clauses[] =
"(subject LIKE '%" . $needle .
"%' OR name LIKE '%" . $needle .
"%' OR email LIKE '%" . $needle .
"%' OR message LIKE '%" . $needle .
"%')";
191 return implode(
' AND ', $clauses);
194 private function decorateRows(array $rows): array {
197 foreach ($rows as $row) {
198 $rid = (int) ($row[
'id'] ?? 0);
201 $row[
'status_view'] =
'<span class="badge ' . $this->statusClass($status) .
'">' . $this->h(
dbxContactTicket::statusLabel($status)) .
'</span>';
202 $row[
'priority_view'] =
'<span class="badge ' . $this->priorityClass($priority) .
'">' . $this->h(
dbxContactTicket::priorityLabel($priority)) .
'</span>';
203 $row[
'user_view'] = (int) ($row[
'uid'] ?? 0) > 0 ?
'#' . (int) $row[
'uid'] :
'Gast';
204 $row[
'hidden_view'] = (int) ($row[
'user_hidden'] ?? 0) === 1 ?
'<span class="badge text-bg-dark">Benutzer geloescht</span>' :
'';
206 $row[
'action'] = $this->tpl()->get_tpl(
'dbxContact_admin|ticket-row-action', array(
'rid' => $rid));
212 private function deleteConfirmText(
int $rid,
int $messageCount): array {
213 $hasThread = $messageCount > 0;
216 'title' =>
'<i class=\'bi bi-trash\'></i> Kontaktanfrage loeschen',
217 'question' =>
'Kontaktanfrage #' . $rid .
' wirklich loeschen?',
219 ?
'<small>Zu dieser Anfrage gehoert ein Verlauf mit ' . $messageCount .
' Nachricht(en). Der gesamte Thread wird geloescht.</small>'
220 :
'<small>Zu dieser Anfrage ist kein separater Verlauf vorhanden. Die Anfrage wird endgueltig geloescht.</small>',
225 if (!is_array($data) || (string) ($data[
'type'] ??
'') !==
'delete') {
229 $rid = (int) ($data[
'record'][
'id'] ?? $data[
'data'][
'rid'] ?? 0);
230 $messageCount = max(0, (
int) ($data[
'record'][
'message_count'] ?? 0));
231 $confirm = $this->deleteConfirmText($rid, $messageCount);
232 $data[
'data'][
'action'] =
'?dbx_modul=dbxContact_admin&dbx_run1=delete&rid=' . $rid;
233 $data[
'data'][
'confirm_title'] = $confirm[
'title'];
234 $data[
'data'][
'confirm'] = $confirm[
'question'];
235 $data[
'data'][
'confirm_hint'] = $confirm[
'hint'];
236 $data[
'data'][
'tooltip'] =
'Anfrage loeschen';
241 private function listTickets(
string $success =
'',
string $error =
'', ?
bool $withFrame =
null): string {
242 if ($withFrame === null) {
243 $withFrame = (int)
dbx()->get_system_var(
'dbx_ajax', 0,
'int') !== 1;
247 $report =
dbx()->get_system_obj(
'dbxReport');
248 $report->init(
'contact-ticket-report',
'dbxContact_admin|ticket-report');
250 $report->_action =
'?dbx_modul=dbxContact_admin&dbx_run1=list';
251 $report->_pages =
true;
252 $report->_create_row_select =
false;
253 $report->_create_row_edit =
false;
254 $report->_create_row_delete =
true;
255 $report->_table_buttons =
'left';
256 $report->_msg_confirm_delete =
'Kontaktanfrage loeschen?';
257 $report->_but_pagination = 7;
258 $report->_msg_info =
'';
259 $report->add_rep(
'report_extra_stats', $this->reportStatusStatsHtml());
260 if ($success !==
'') {
261 $report->_msg_success = $success;
264 $report->_msg_err = $error;
266 $report->set_callback_owner($this);
267 $report->set_callback(
'row_action_data',
'contactTicketReportRowActionData');
268 $report->set_tabel_tpl(
'tpl_row_delete',
'modul|ticket-row-delete');
269 $report->create_selection_fields(
'dbxContact_admin|rpt-ticket-selection');
271 $search = trim((
string) $report->get_fld_val(
'dbx_rwhere',
'',
'sqlsearch|max=100'));
272 $status = (string) $report->get_fld_val(
'dbx_rstatus',
'all',
'parameter');
273 $priority = (string) $report->get_fld_val(
'dbx_rpriority',
'all',
'parameter');
274 $rowsPerPage = max(10, min(100, (
int) $report->get_fld_val(
'dbx_rrows', 30,
'int')));
275 $position = max(0, (
int) $report->get_fld_val(
'dbx_rpos', 0,
'int'));
276 $sort = (string) $report->get_fld_val(
'dbx_rsort',
'last_activity_date',
'parameter');
277 $direction = strtoupper((
string) $report->get_fld_val(
'dbx_rdesc',
'DESC',
'parameter')) ===
'ASC' ?
'ASC' :
'DESC';
278 if (!in_array($sort, array(
'last_activity_date',
'create_date',
'id',
'status',
'priority',
'subject'),
true)) {
279 $sort =
'last_activity_date';
282 $where = $this->whereForReport($search, $status, $priority);
283 $cols = array(
'id',
'create_date',
'last_activity_date',
'status',
'priority',
'uid',
'name',
'email',
'subject',
'user_hidden');
284 $report->_rflds = array(
286 'last_activity_date' =>
'Letzte Aktivitaet',
287 'status_view' =>
'Status',
288 'priority_view' =>
'Prioritaet',
289 'user_view' =>
'Benutzer',
292 'subject' =>
'Betreff',
294 'action' =>
'Aktion',
296 $report->_rpt_format = array(
297 'last_activity_date' =>
'php-datetime-usr',
298 'status_view' =>
'html',
299 'priority_view' =>
'html',
300 'hidden_view' =>
'html',
303 $report->_rrows = $rowsPerPage;
304 $report->_rpos = $position;
308 $report->_rdata = $this->decorateRows(is_array($rows) ? $rows : array());
310 $content = $report->run();
311 return $withFrame ? $this->frame($content) : $content;
314 private function deleteTicketRecord(
int $rid): bool {
315 if ($rid <= 0 || !dbxContactTicket::ticket($this->db(), $rid)) {
322 if (
$db->begin(dbxContactTicket::DD_TICKET) === 1) {
323 $messagesOk =
$db->delete(dbxContactTicket::DD_MESSAGE, array(
'ticket_id' => $rid), 0, 1);
324 if ($messagesOk === 1 ||
$db->count(dbxContactTicket::DD_MESSAGE, array(
'ticket_id' => $rid)) === 0) {
325 $ok =
$db->delete(dbxContactTicket::DD_TICKET, $rid, 0, 1);
328 $db->commit(dbxContactTicket::DD_TICKET);
330 $db->rollback(dbxContactTicket::DD_TICKET);
337 private function timeline(array $ticket): string {
339 dbxContactTicket::ensureInitialMessage(
$db, $ticket);
342 foreach (dbxContactTicket::messages(
$db, (
int) $ticket[
'id'],
true) as $message) {
343 $internal = (string) ($message[
'visibility'] ??
'public') ===
'internal';
344 $authorType = (string) ($message[
'author_type'] ??
'system');
345 $statusTo = trim((
string) ($message[
'status_to'] ??
''));
346 $html .= $this->tpl()->get_tpl(
'dbxContact_admin|ticket-message', array(
347 'message_class' => $internal ?
'border-warning bg-warning-subtle' : ($authorType ===
'admin' ?
'border-primary bg-primary-subtle' :
'bg-body-tertiary'),
348 'message_icon' => $internal ?
'bi-lock' : ($authorType ===
'admin' ?
'bi-headset' : ($authorType ===
'requester' ?
'bi-person' :
'bi-gear')),
349 'author_label' => $internal ?
'Interne Notiz' : ($authorType ===
'admin' ?
'Support' : ($authorType ===
'requester' ?
'Anfragender' :
'System')),
350 'create_date' => $this->h($message[
'create_date'] ??
''),
351 'body' => nl2br($this->h($message[
'body'] ??
'')),
352 'visibility_badge' => $internal ?
'<span class="badge text-bg-warning">Nur intern</span>' :
'',
353 'status_badge' => $statusTo !==
'' ?
'<span class="badge ' . $this->statusClass($statusTo) .
'">Status: ' . $this->h(dbxContactTicket::statusLabel($statusTo)) .
'</span>' :
'',
354 'mail_badge' => (
int) ($message[
'mail_sent'] ?? 0) === 1 ?
'<span class="badge text-bg-success"><i class="bi bi-envelope-check"></i> E-Mail versendet</span>' :
'',
358 return $html !==
'' ? $html :
'<div class="text-muted">Noch keine Meldungen vorhanden.</div>';
361 private function mailProfileOptions(array $extra = array()): array {
362 $profile = trim((string)
dbx()->get_config(
'dbxContact',
'mail_profile'));
363 if ($profile !==
'') {
364 $extra[
'mail_profile'] = $profile;
369 private function sendReplyMail(array $ticket,
string $body): bool {
370 $to = trim((string) ($ticket[
'email'] ??
''));
375 $from = trim((
string)
dbx()->get_config(
'dbxContact',
'mail_from'));
376 $fromName = trim((
string)
dbx()->get_config(
'dbxContact',
'mail_from_name'));
377 $fromParam = $from !==
'' ? array(
'email' => $from,
'name' => $fromName) :
'';
378 $subject =
'Antwort zu Ticket #' . (int) $ticket[
'id'] .
': ' . (
string) ($ticket[
'subject'] ??
'Kontaktanfrage');
379 $html = $this->tpl()->get_tpl(
'dbxContact|mail-contact-reply', array(
380 'subject' => $this->h($ticket[
'subject'] ??
''),
381 'body' => nl2br($this->h($body)),
383 $text =
"Antwort zu Ticket #" . (int) $ticket[
'id'] .
"\n\n" . $body;
385 return (
bool)
dbx()->send_mail(
392 $this->mailProfileOptions(array(
'text' => $text))
396 private function replyForm(array $ticket): string {
397 $rid = (int) $ticket[
'id'];
398 $form =
dbx()->get_system_obj(
'dbxForm');
399 $form->init(
'contact-ticket-reply',
'ticket-reply-form');
400 $form->_dd = dbxContactTicket::DD_MESSAGE;
401 $form->_fd =
'dbxContact_admin|ticket-reply';
402 $form->_action =
'?dbx_modul=dbxContact_admin&dbx_run1=ticket&rid=' . $rid;
403 $form->_data = array(
404 'status' => dbxContactTicket::normalizeStatus((
string) ($ticket[
'status'] ??
'open')),
405 'priority' => dbxContactTicket::normalizePriority((
string) ($ticket[
'priority'] ??
'normal')),
406 'visibility' =>
'public',
410 $form->add_module_bar(
'Antwort oder interne Notiz',
'bi-reply',
'Der gesamte Verlauf bleibt am Ticket erhalten');
411 $form->add_rep(
'bar_title_pre',
'');
412 $form->add_rep(
'bar_title_heading_attrs',
'');
413 $form->add_rep(
'bar_middle',
'');
414 $form->add_rep(
'bar_actions',
'');
415 $form->add_rep(
'bar_extra',
'');
416 $form->add_rep(
'rid', $rid);
417 $messageCount = max(0, (
int) $this->db()->count(dbxContactTicket::DD_MESSAGE, array(
'ticket_id' => $rid)));
418 $confirm = $this->deleteConfirmText($rid, $messageCount);
419 $form->add_rep(
'delete_action',
'?dbx_modul=dbxContact_admin&dbx_run1=delete&rid=' . $rid);
420 $form->add_rep(
'delete_confirm_title', $confirm[
'title']);
421 $form->add_rep(
'delete_confirm', $confirm[
'question']);
422 $form->add_rep(
'delete_confirm_hint', $confirm[
'hint']);
423 $form->_msg_info =
'Oeffentliche Antworten koennen per E-Mail versendet werden. Interne Notizen bleiben nur in der Administration sichtbar.';
424 $form->add_fld(
'status');
425 $form->add_fld(
'priority');
426 $form->add_fld(
'visibility');
427 $form->add_fld(
'body');
428 $form->add_fld(
'send_mail');
430 if ($form->submit() && !$form->errors()) {
431 $status = dbxContactTicket::normalizeStatus((
string) $form->get_post(
'status',
'answered',
'parameter|max=24'));
432 $priority = dbxContactTicket::normalizePriority((
string) $form->get_post(
'priority',
'normal',
'parameter|max=16'));
433 $visibility = (string) $form->get_post(
'visibility',
'public',
'parameter|max=16') ===
'internal' ?
'internal' :
'public';
434 $body = trim((
string) $form->get_post_data(
'body',
'',
'*'));
435 $sendMail = (int) $form->get_post(
'send_mail', 0,
'int') === 1 && $visibility ===
'public' && dbxContactConfig::mailOnReply();
436 $oldStatus = dbxContactTicket::normalizeStatus((
string) ($ticket[
'status'] ??
'open'));
439 $messageId = dbxContactTicket::addMessage(
$db, $rid, array(
440 'author_uid' => (
int)
dbx()->user(),
441 'author_type' =>
'admin',
442 'message_type' => $visibility ===
'internal' ?
'note' :
'reply',
443 'visibility' => $visibility,
445 'status_from' => $oldStatus,
446 'status_to' => $status,
449 if ($messageId > 0) {
452 'priority' => $priority,
453 'assigned_uid' => (
int)
dbx()->user(),
454 'closed_date' => $status ===
'closed' ? date(
'Y-m-d H:i:s') :
'',
456 dbxContactTicket::touch(
$db, $rid, $values);
460 $mailOk = $this->sendReplyMail($ticket, $body);
462 $db->update(dbxContactTicket::DD_MESSAGE, array(
464 'mail_sent_date' => date(
'Y-m-d H:i:s'),
465 ), $messageId, 0, 1, 1, 1);
469 if ($sendMail && !$mailOk) {
470 $form->_msg_warning =
'Nachricht und Status wurden gespeichert, die E-Mail konnte jedoch nicht versendet werden.';
472 $form->_msg_success = $visibility ===
'internal'
473 ?
'Interne Notiz und Status wurden gespeichert.'
474 :
'Antwort und Status wurden gespeichert.' . ($mailOk ?
' Die E-Mail wurde versendet.' :
'');
476 $form->_data[
'body'] =
'';
478 $form->_msg_error =
'Die Nachricht konnte nicht gespeichert werden.';
485 private function ticket(): string {
486 $rid = (int)
dbx()->get_modul_var(
'rid', 0,
'int');
487 $ticket = dbxContactTicket::ticket($this->db(), $rid);
489 return $this->frame($this->alert(
'warning',
'Ticket nicht gefunden.'));
492 $replyForm = $this->replyForm($ticket);
493 $ticket = dbxContactTicket::ticket($this->db(), $rid);
494 $status = dbxContactTicket::normalizeStatus((
string) ($ticket[
'status'] ??
'open'));
495 $priority = dbxContactTicket::normalizePriority((
string) ($ticket[
'priority'] ??
'normal'));
496 $content = $this->tpl()->get_tpl(
'dbxContact_admin|ticket-detail', array(
498 'subject' => $this->h($ticket[
'subject'] ??
''),
499 'name' => $this->h($ticket[
'name'] ??
''),
500 'email' => $this->h($ticket[
'email'] ??
''),
501 'email_attr' => $this->h($ticket[
'email'] ??
''),
502 'phone' => $this->h(trim((
string) ($ticket[
'phone'] ??
'')) ?:
'-'),
503 'create_date' => $this->h($ticket[
'create_date'] ??
''),
504 'uid' => (
int) ($ticket[
'uid'] ?? 0) > 0 ?
'#' . (
int) $ticket[
'uid'] :
'Gast',
505 'status_label' => $this->h(dbxContactTicket::statusLabel($status)),
506 'status_class' => $this->statusClass($status),
507 'priority_label' => $this->h(dbxContactTicket::priorityLabel($priority)),
508 'priority_class' => $this->priorityClass($priority),
509 'user_visibility' => (
int) ($ticket[
'user_hidden'] ?? 0) === 1 ?
'Vom Benutzer ausgeblendet' :
'Im Benutzerkonto sichtbar',
510 'timeline' => $this->timeline($ticket),
511 'reply_form' => $replyForm,
514 return $this->frame($content,
'Ticket #' . $rid);
517 private function deleteTicket(): string {
518 $rid = (int)
dbx()->get_modul_var(
'rid', 0,
'int');
519 $withFrame = (int)
dbx()->get_system_var(
'dbx_ajax', 0,
'int') !== 1;
520 if ($this->deleteTicketRecord($rid)) {
521 return $this->listTickets(
'Kontaktanfrage #' . $rid .
' und der zugehoerige Verlauf wurden geloescht.',
'', $withFrame);
524 return $this->listTickets(
'',
'Kontaktanfrage #' . $rid .
' konnte nicht geloescht werden.', $withFrame);
528 $run =
dbx()->get_modul_var(
'dbx_run1',
'list',
'parameter');
529 if ($run ===
'install') {
530 return $this->install();
532 if (!$this->schemaReady()) {
533 return $this->alert(
'warning',
'Das Ticket-Datenmodell ist noch nicht installiert.')
534 .
'<div class="p-3"><a class="btn btn-primary" href="?dbx_modul=dbxContact_admin&dbx_run1=install"><i class="bi bi-database-gear"></i> Jetzt installieren</a></div>';
536 if ($run ===
'ticket' || $run ===
'reply') {
537 return $this->ticket();
539 if ($run ===
'delete') {
540 return $this->deleteTicket();
542 return $this->listTickets();