Compare commits

...

24 Commits
v2.3 ... main

Author SHA1 Message Date
Christian
70a01db422 Default API workers to 1 to keep telefoni websocket events reliable 2026-06-11 12:53:52 +02:00
Christian
9dfa7ca936 Restore VOIP popup by allowing cookie-auth websocket fallback 2026-06-11 12:38:40 +02:00
Christian
96f4a36724 Handle missing supplier_invoices.sag_id in supplier invoice listing 2026-06-11 09:45:11 +02:00
Christian
1e84ba267c Fix sag status constraint to support active case statuses 2026-06-11 09:37:41 +02:00
Christian
f4bc2828e8 Fix case status save on v3 detail by binding inline onchange fallback 2026-06-11 09:34:02 +02:00
Christian
fd8f4d6d88 Fix case status update: use Body() to properly parse JSON request body for PATCH endpoints 2026-06-11 01:38:55 +02:00
Christian
5f2452f222 Fix email routing and contact search 2026-06-11 01:12:25 +02:00
Christian
592ed8640d feat(telefoni): enhance call handling with external number normalization and case linking 2026-06-09 00:05:28 +02:00
Christian
c019a0367b fix(mission): show project-level todo tasks in mission control detail 2026-05-18 07:26:09 +02:00
Christian
071d926781 fix(telefoni): skip initial auto-refresh when SSR rows exist 2026-05-18 07:07:51 +02:00
Christian
94f6735ed5 fix(telefoni): preserve SSR call rows when client refresh fails 2026-05-18 07:00:52 +02:00
Christian
ef8e68fc16 fix(telefoni): remove raw_payload dependency from call list queries 2026-05-17 23:16:31 +02:00
Christian
468814ca8d feat(deploy): add fast code-only update script with guardrails and docs 2026-05-17 23:05:22 +02:00
Christian
08f40977f9 fix(telefoni): keep SSR call rows when initial API refresh is empty 2026-05-17 09:44:28 +02:00
Christian
e162ee3fe1 fix(telefoni): render initial calls server-side on log page 2026-05-17 09:36:09 +02:00
Christian
e0c4e138d6 fix(settings): update SQL console access to require any permission instead of superadmin 2026-05-16 19:46:31 +02:00
Christian
1b6b37e96e feat(settings): add read-only SQL console with diagnostic presets 2026-05-16 13:40:58 +02:00
Christian
c5478b7e29 fix(telefoni): show legacy call history when telefoni_opkald is empty 2026-05-16 13:23:56 +02:00
Christian
6a68aecafa fix(telefoni): accept callbacks via db whitelist and internal fallback 2026-05-16 13:16:35 +02:00
Christian
8e5b3cf3d2 fix(telefoni): restore corrupted log template to resolve blank page 2026-05-16 12:18:59 +02:00
Christian
d0ec639de0 fix(telefoni): restore template and add clear load/error status banner 2026-05-16 12:10:59 +02:00
Christian
3bc4472525 fix(telefoni): add explicit load/error status and stronger empty-state fallback 2026-05-16 11:06:48 +02:00
Christian
97a4a2435c fix(telefoni): add SSR fallback rows for non-empty log page 2026-05-16 10:52:36 +02:00
Christian
0ed450451d fix(contacts): stabilize contacts pagination and company enrichment 2026-05-16 10:28:05 +02:00
32 changed files with 1888 additions and 81 deletions

View File

@ -258,6 +258,32 @@ crontab -e
## 🔄 Opdatering til Ny Version
### Valg Af Update-metode
| Situation | Brug metode | Hvorfor |
|---|---|---|
| Små kodeændringer i `app/*` eller `main.py` | `./update_fast.sh --ref <ref>` | Hurtig update uden ny release-tag/pakke |
| Ændringer i `migrations/*` | `./updateto.sh <version>` | Kræver kontrolleret release + migrations-flow |
| Ændringer i `requirements.txt` eller `Dockerfile` | `./updateto.sh <version>` | Kræver fuld image-build og versionsstyring |
| Ændringer i `docker-compose*.yml`, scripts eller `.env` | `./updateto.sh <version>` | Drift/infra-konfiguration skal deployes fuldt |
| Når du er i tvivl | `./updateto.sh <version>` | Sikreste og mest forudsigelige metode |
Hurtig start for fast mode:
```bash
# Tjek først scope
./update_fast.sh --ref main --dry-run --allow-prod
# Kør update
./update_fast.sh --ref <commit-eller-tag> --allow-prod
```
Rollback i fast mode:
```bash
./update_fast.sh --rollback <backup-id> --allow-prod
```
### På din Mac:
```bash

View File

@ -22,6 +22,34 @@ cd /srv/podman/bmc_hub_v1.0
./updateto.sh v1.3.16
```
## Fast small update (kode-only, uden ny release tag)
Brug denne metode til meget små ændringer i `app/*` eller `main.py`, hvor du ikke vil lave en fuld release-pakke.
```bash
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Download/refresh fast update script
curl -O https://g.bmcnetworks.dk/ct/bmc_hub/raw/branch/main/update_fast.sh
chmod +x update_fast.sh
# Tjek først hvad der ændres (anbefalet)
./update_fast.sh --ref main --dry-run --allow-prod
# Kør fast update (eksempel: specifik commit)
./update_fast.sh --ref 08f4097 --allow-prod
```
Vigtigt:
- `update_fast.sh` er kun til kode/templates/static ændringer i fast scope.
- Hvis der er ændringer i migrationer, dependencies, docker/compose eller env: brug `./updateto.sh`.
- Rollback kan køres med backup-id:
```bash
./update_fast.sh --rollback 20260517-142155 --allow-prod
```
## Manuel deployment (hvis scriptet ikke virker)
```bash

19
RELEASE_NOTES_v2.3.1.md Normal file
View File

@ -0,0 +1,19 @@
# v2.3.1 — 16. maj 2026
## Fix: contacts pagination and company enrichment
- **Hotfix:** Contacts showing too few rows (contacts pagination bug)
- **Fix:** File `app/contacts/backend/router_simple.py` to stabilize pagination and company enrichment
## Contacts list
- Fixed bug where contacts list showed too few rows (pagination issue)
- Stabilized company enrichment data for contacts
## File changed
- `app/contacts/backend/router_simple.py`
## Affected versions
- v2.3.1

5
RELEASE_NOTES_v2.3.2.md Normal file
View File

@ -0,0 +1,5 @@
# RELEASE_NOTES_v2.3.2
**Date:** 16. maj 2026
**Summary:** Telefoni page no longer appears empty due to SSR fallback rows in `app/modules/telefoni/templates/log.html`

16
RELEASE_NOTES_v2.3.3.md Normal file
View File

@ -0,0 +1,16 @@
# Release Notes: v2.3.3
**Date:** 16. maj 2026
## New Features
### Telefoni Status Banner
A new status banner was added to `app/modules/telefoni/templates/log.html` to show explicit load/error/ready states:
- **`telefoni_status == "error"`** → "Telefoni: Error" with "Failed to load telefoni data."
- **`telefoni_status == "loading"`** → "Telefoni: Loading..." with "Fetching telefoni data."
- **`telefoni_status == "ready"`** → "Telefoni: Ready" with "Telefoni data loaded successfully."
- **default (no status)** → "Telefoni: No data" with a stronger fallback message: "Telefoni is not available. Try again later or check the network."
This improves visibility of telefoni data availability and provides clearer error messages.

12
RELEASE_NOTES_v2.3.4.md Normal file
View File

@ -0,0 +1,12 @@
# Release Notes: v2.3.4 — 16. maj 2026
## Changes
- **Restore telefoni template integrity** after an accidental regression.
- **Add explicit telefoniStatus load/error/empty/success banner** to improve user feedback.
- File: `app/modules/telefoni/templates/log.html`
## Fixes
- Fixed accidental regression in `app/modules/telefoni/templates/log.html` that corrupted template integrity.
- Added explicit `telefoniStatus` load/error/empty/success banner to improve user feedback.

17
RELEASE_NOTES_v2.3.5.md Normal file
View File

@ -0,0 +1,17 @@
# Release Notes - BMC Hub v2.3.5
**Release Date:** 16. maj 2026
**Release Tag:** `v2.3.5`
## Hotfix
- Restored `app/modules/telefoni/templates/log.html` from known good state (`v2.3.2`) after template corruption in `v2.3.4`.
- Fixes production issue where `/telefoni` rendered blank.
## Affected File
- `app/modules/telefoni/templates/log.html`
## Notes
- This is a corrective patch release intended to be deployed immediately.

6
RELEASE_NOTES_v2.3.6.md Normal file
View File

@ -0,0 +1,6 @@
# Release Notes v2.3.6 — 16. maj 2026
## Telefoni Callback Fix
- telefoni callbacks now use both env and DB whitelist
- added internal 172.16.0.0/12 fallback acceptance
- added migration `migrations/186_telefoni_ip_whitelist_setting.sql`

10
RELEASE_NOTES_v2.3.7.md Normal file
View File

@ -0,0 +1,10 @@
# Release Notes: v2.3.7
**Date:** 16. maj 2026
## Changes
- fallback to mission_call_state when telefoni_opkald is empty
- legacy rows shown read-only in telefoni UI
## Files changed
- app/modules/telefoni/backend/router.py
- app/modules/telefoni/templates/log.html

9
RELEASE_NOTES_v2.3.8.md Normal file
View File

@ -0,0 +1,9 @@
# Release Notes: v2.3.8
**Date:** 16. maj 2026
## Summary
- Added secure read-only SQL Console page under /settings/sql
- Added superadmin-protected execute endpoint /settings/sql/execute (SELECT/WITH only)
- Added SQL Console nav link in settings
- Added preset query buttons for telefoni/mission diagnostics
- files: app/settings/backend/views.py, app/settings/frontend/sql_console.html, app/settings/frontend/settings.html

View File

@ -9,7 +9,7 @@ from typing import List, Dict, Optional
from datetime import datetime, date, timedelta
from decimal import Decimal
from pathlib import Path
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single, table_has_column
from app.core.config import settings
from app.services.economic_service import get_economic_service
from app.services.ollama_service import ollama_service
@ -710,8 +710,29 @@ async def list_supplier_invoices(
params.append(vendor_id)
if sag_id:
query += " AND si.sag_id = %s"
params.append(sag_id)
if table_has_column("supplier_invoices", "sag_id"):
query += " AND si.sag_id = %s"
params.append(sag_id)
elif (
table_has_column("supplier_invoice_relations", "supplier_invoice_id")
and table_has_column("supplier_invoice_relations", "relation_type")
and table_has_column("supplier_invoice_relations", "relation_id")
):
query += """
AND EXISTS (
SELECT 1
FROM supplier_invoice_relations sir
WHERE sir.supplier_invoice_id = si.id
AND sir.relation_type = 'sag'
AND sir.relation_id = %s
)
"""
params.append(sag_id)
else:
logger.warning(
"⚠️ supplier invoice sag filter requested, but no schema link available (sag_id column/relation table missing)"
)
query += " AND 1 = 0"
if overdue_only:
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"

View File

@ -88,8 +88,26 @@ async def get_contacts(
params = []
if search:
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
where_clauses.append(
"""
(
c.first_name ILIKE %s
OR c.last_name ILIKE %s
OR c.email ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
)
"""
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like])
if is_active is not None:
where_clauses.append("c.is_active = %s")

View File

@ -113,38 +113,80 @@ async def get_contacts(
params = []
if search:
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
where_clauses.append(
"""
(
c.first_name ILIKE %s
OR c.last_name ILIKE %s
OR c.email ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR c.user_company ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
)
"""
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like, like])
if is_active is not None:
where_clauses.append("c.is_active = %s")
params.append(is_active)
if customer_id is not None:
where_clauses.append(
"EXISTS (SELECT 1 FROM contact_companies cc WHERE cc.contact_id = c.id AND cc.customer_id = %s)"
)
params.append(customer_id)
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
# Count total (needs alias c for consistency)
count_query = f"SELECT COUNT(*) as count FROM contacts c {where_sql}"
# Count total (distinct id for consistency with optional filters/joins)
count_query = f"SELECT COUNT(DISTINCT c.id) as count FROM contacts c {where_sql}"
count_result = execute_query(count_query, tuple(params))
total = count_result[0]['count'] if count_result else 0
# Get contacts with company info
query = f"""
# Step 1: Fetch contacts only (stable pagination)
contacts_query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at,
COUNT(DISTINCT cc.customer_id) as company_count,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) as company_names
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
FROM contacts c
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
LEFT JOIN customers cu ON cc.customer_id = cu.id
{where_sql}
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
ORDER BY company_count DESC, c.last_name, c.first_name, c.id
ORDER BY c.last_name, c.first_name, c.id
LIMIT %s OFFSET %s
"""
params.extend([limit, offset])
contacts = execute_query(query, tuple(params))
contacts_params = list(params)
contacts_params.extend([limit, offset])
contacts = execute_query(contacts_query, tuple(contacts_params)) or []
# Step 2: Enrich page contacts with aggregated company info
if contacts:
contact_ids = [row["id"] for row in contacts]
placeholders = ",".join(["%s"] * len(contact_ids))
companies_query = f"""
SELECT
cc.contact_id,
COUNT(DISTINCT cc.customer_id) AS company_count,
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
FROM contact_companies cc
LEFT JOIN customers cu ON cc.customer_id = cu.id
WHERE cc.contact_id IN ({placeholders})
GROUP BY cc.contact_id
"""
company_rows = execute_query(companies_query, tuple(contact_ids)) or []
company_map = {row["contact_id"]: row for row in company_rows}
for contact in contacts:
info = company_map.get(contact["id"])
contact["company_count"] = int(info["company_count"]) if info and info.get("company_count") is not None else 0
contact["company_names"] = info.get("company_names") if info and info.get("company_names") else []
return {
"total": total,

View File

@ -953,7 +953,7 @@ async function loadContacts() {
totalContacts = data.total;
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
displayContacts(currentContactsData);
updatePagination(data.total);
updatePagination(data.total, currentContactsData.length);
} catch (error) {
if (error.name === 'AbortError') {
@ -1182,11 +1182,12 @@ function persistTablePreferences() {
}
}
function updatePagination(total) {
const start = currentPage * pageSize + 1;
const end = Math.min((currentPage + 1) * pageSize, total);
function updatePagination(total, rowsOnPage = 0) {
const safeRowsOnPage = Number.isFinite(Number(rowsOnPage)) ? Math.max(0, Number(rowsOnPage)) : 0;
const start = total > 0 ? (currentPage * pageSize + 1) : 0;
const end = total > 0 ? Math.min(currentPage * pageSize + safeRowsOnPage, total) : 0;
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
document.getElementById('showingStart').textContent = start;
document.getElementById('showingEnd').textContent = end;
document.getElementById('totalCount').textContent = total;

View File

@ -595,6 +595,37 @@ class MissionService:
(project_id,),
) or []
project_open_todo_count = 0
project_open_todo_titles: list[str] = []
if MissionService._table_exists("sag_todo_steps"):
project_todo_row = execute_query_single(
"""
SELECT
COUNT(*) FILTER (
WHERE t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
) AS open_todo_count,
ARRAY_REMOVE(
ARRAY_AGG(
CASE
WHEN t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
THEN t.title
END
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
),
NULL
) AS open_todo_titles
FROM sag_todo_steps t
WHERE t.sag_id = %s
""",
(project_id,),
) or {}
project_open_todo_count = int(project_todo_row.get("open_todo_count") or 0)
titles_raw = project_todo_row.get("open_todo_titles") or []
if isinstance(titles_raw, list):
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
if not tasks and MissionService._table_exists("sag_relationer"):
@ -667,6 +698,8 @@ class MissionService:
"milestones": [dict(row) for row in milestones],
"blockers": [dict(row) for row in blockers],
"tasks": [dict(row) for row in tasks],
"project_open_todo_count": project_open_todo_count,
"project_open_todo_titles": project_open_todo_titles,
}
@staticmethod

View File

@ -805,6 +805,16 @@
padding: 0.45rem 0.8rem;
}
.mc-day-actions {
grid-template-columns: 1fr;
}
.mc-day-touch-btn {
min-height: 56px;
font-size: 1rem;
padding: 0.55rem 0.82rem;
}
.mc-case-link,
.mc-email-link {
display: inline-flex;
@ -829,6 +839,46 @@
cursor: not-allowed;
}
.mc-day-actions {
margin-top: 0.52rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
}
.mc-day-touch-btn {
border: 1px solid rgba(126, 194, 239, 0.45);
border-radius: 10px;
background: rgba(15, 76, 117, 0.45);
color: #dff3ff;
font-size: 0.82rem;
font-weight: 800;
min-height: 44px;
padding: 0.42rem 0.58rem;
touch-action: manipulation;
}
.mc-day-touch-btn.secondary {
border-color: rgba(157, 181, 210, 0.35);
background: rgba(255, 255, 255, 0.05);
color: #e3f1ff;
}
.mc-day-touch-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mc-day-modal-body {
display: grid;
gap: 0.75rem;
}
.mc-day-modal-meta {
font-size: 0.86rem;
color: var(--mc-text-muted);
}
.mc-day-agents {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -1125,6 +1175,50 @@
</div>
</div>
<div class="modal fade" id="dayCaseQuickModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="dayCaseQuickTitle">Sag detaljer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body mc-day-modal-body">
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vaelg en sag fra listen.</div>
<div>
<label for="dayCaseQuickUser" class="form-label">Ansvarlig medarbejder</label>
<select id="dayCaseQuickUser" class="form-select"></select>
</div>
<div>
<label for="dayCaseQuickGroup" class="form-label">Gruppe</label>
<select id="dayCaseQuickGroup" class="form-select"></select>
</div>
<div class="row g-2">
<div class="col-md-6">
<label for="dayCaseQuickStart" class="form-label">Start</label>
<input id="dayCaseQuickStart" type="datetime-local" class="form-control">
</div>
<div class="col-md-6">
<label for="dayCaseQuickDeadline" class="form-label">Deadline</label>
<input id="dayCaseQuickDeadline" type="datetime-local" class="form-control">
</div>
</div>
<div>
<label for="dayCaseQuickDesc" class="form-label">Beskrivelse</label>
<textarea id="dayCaseQuickDesc" class="form-control" rows="4" readonly></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" id="dayCaseQuickSaveBtn">Gem aendringer</button>
</div>
</div>
</div>
</div>
<div id="view-calls" class="mc-view">
<div class="mc-view-grid">
<div class="mc-card">
@ -1234,6 +1328,8 @@
renderedCameraUrl: null,
renderedCameraMode: null,
selectedProjectId: null,
selectedDayCaseId: null,
dayCaseModal: null,
};
function escapeHtml(str) {
@ -1328,6 +1424,10 @@
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
const milestones = Array.isArray(detail?.milestones) ? detail.milestones : [];
const blockers = Array.isArray(detail?.blockers) ? detail.blockers : [];
const projectOpenTodoCount = Number(detail?.project_open_todo_count || 0);
const projectOpenTodoTitles = Array.isArray(detail?.project_open_todo_titles)
? detail.project_open_todo_titles.map((item) => String(item || '').trim()).filter(Boolean)
: [];
const grouped = { todo: [], doing: [], done: [] };
tasks.forEach((task) => {
@ -1339,6 +1439,7 @@
kpis.innerHTML = [
{ label: 'Opgaver', value: tasks.length },
{ label: 'Projekt todo', value: projectOpenTodoCount },
{ label: 'Milepæle', value: milestones.length },
{ label: 'Blockers', value: blockers.length },
{ label: 'Deadline', value: formatShortDate(detail?.ended_at || detail?.deadline) },
@ -1355,11 +1456,29 @@
{ key: 'done', label: 'Lukket' },
];
const projectTodoPreview = projectOpenTodoTitles.slice(0, 5)
.map((title) => `<div>• ${escapeHtml(title)}</div>`)
.join('');
const projectTodoMore = Math.max(projectOpenTodoCount - Math.min(projectOpenTodoTitles.length, 5), 0);
const projectTodoCard = projectOpenTodoCount > 0
? `
<div class="mc-kanban-card" style="border-left:3px solid #0f4c75;">
<div class="mc-kanban-title">Projekt todo (${projectOpenTodoCount})</div>
<div class="mc-kanban-meta">
${projectTodoPreview || '<div>-</div>'}
${projectTodoMore > 0 ? `<div>+${projectTodoMore} flere</div>` : ''}
</div>
</div>
`
: '';
board.innerHTML = laneMeta.map((lane) => {
const laneTasks = grouped[lane.key] || [];
const extraCard = lane.key === 'todo' ? projectTodoCard : '';
return `
<div class="mc-kanban-col">
<h6>${escapeHtml(lane.label)} (${laneTasks.length})</h6>
<h6>${escapeHtml(lane.label)} (${laneTasks.length + (lane.key === 'todo' && projectOpenTodoCount > 0 ? 1 : 0)})</h6>
${extraCard}
${laneTasks.length ? laneTasks.map((task) => `
<div class="mc-kanban-card">
<div class="mc-kanban-title">#${Number(task.id || 0)} ${escapeHtml(task.titel || task.title || 'Uden titel')}</div>
@ -1408,6 +1527,14 @@
return Number.isFinite(parsed) ? parsed : null;
}
function toDatetimeLocalValue(value) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function truncateText(value, maxLength = 180) {
const raw = String(value || '').trim();
if (!raw) return '';
@ -1415,6 +1542,23 @@
return `${raw.slice(0, maxLength - 1)}...`;
}
function getDayCaseById(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return null;
const source = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
const found = source.find((item) => Number(item.id || 0) === id);
return found || null;
}
function getOrCreateDayCaseModal() {
if (state.dayCaseModal) return state.dayCaseModal;
const el = document.getElementById('dayCaseQuickModal');
if (!el || !window.bootstrap) return null;
state.dayCaseModal = new bootstrap.Modal(el);
return state.dayCaseModal;
}
function getEmailHref(emailId) {
const id = Number(emailId || 0);
if (!Number.isFinite(id) || id <= 0) return '/emails';
@ -2013,7 +2157,12 @@
<select class="mc-day-select" id="assignGroup-${caseId}">
${groupSelectOptions}
</select>
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel</button>
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel begge</button>
</div>
<div class="mc-day-actions">
<button type="button" class="mc-day-touch-btn" onclick="quickAssignUser(${caseId})">Tildel medarbejder</button>
<button type="button" class="mc-day-touch-btn" onclick="quickAssignGroup(${caseId})">Tildel gruppe</button>
<button type="button" class="mc-day-touch-btn secondary" onclick="openDayCaseQuick(${caseId})">Mere info</button>
</div>
</div>
`;
@ -2122,7 +2271,154 @@
}
}
async function patchDayCase(caseId, payload, button, buttonText = 'Gemmer...') {
if (!payload || typeof payload !== 'object') return;
const btn = button || null;
const originalText = btn ? btn.textContent : null;
if (btn) {
btn.disabled = true;
btn.textContent = buttonText;
}
try {
const res = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || `HTTP ${res.status}`);
}
await loadInitialState();
} catch (error) {
alert(`Kunne ikke opdatere sag: ${error?.message || 'ukendt fejl'}`);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText || 'Gem';
}
}
}
async function quickAssignUser(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById(`assignUser-${id}`);
const userId = toOptionalInt(userSelect?.value);
if (userId === null) {
alert('Vaelg en medarbejder foerst.');
return;
}
const card = document.getElementById(`dayCase-${id}`);
const button = card?.querySelector('.mc-day-actions .mc-day-touch-btn');
await patchDayCase(id, { ansvarlig_bruger_id: userId }, button, 'Tildeler...');
}
async function quickAssignGroup(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const groupSelect = document.getElementById(`assignGroup-${id}`);
const groupId = toOptionalInt(groupSelect?.value);
if (groupId === null) {
alert('Vaelg en gruppe foerst.');
return;
}
const card = document.getElementById(`dayCase-${id}`);
const buttons = card?.querySelectorAll('.mc-day-actions .mc-day-touch-btn');
const button = buttons && buttons.length > 1 ? buttons[1] : null;
await patchDayCase(id, { assigned_group_id: groupId }, button, 'Tildeler...');
}
function openDayCaseQuick(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const item = getDayCaseById(id);
if (!item) {
alert('Kunne ikke finde sag i Dagen-listen.');
return;
}
const title = document.getElementById('dayCaseQuickTitle');
const meta = document.getElementById('dayCaseQuickMeta');
const userSelect = document.getElementById('dayCaseQuickUser');
const groupSelect = document.getElementById('dayCaseQuickGroup');
const startInput = document.getElementById('dayCaseQuickStart');
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
const descInput = document.getElementById('dayCaseQuickDesc');
if (!title || !meta || !userSelect || !groupSelect || !startInput || !deadlineInput || !descInput) {
return;
}
state.selectedDayCaseId = id;
title.textContent = `#${id} ${item.titel || 'Uden titel'}`;
meta.textContent = `${item.customer_name || 'Ukendt kunde'} • Status: ${item.status || '-'} • Type: ${item.case_type || item.template_key || '-'}`;
descInput.value = String(item.beskrivelse || '').trim();
const userOptions = [
'<option value="">Ingen medarbejder</option>',
...(state.assignmentUsers || []).map((user) => (`
<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>
`)),
];
const groupOptions = [
'<option value="">Ingen gruppe</option>',
...(state.assignmentGroups || []).map((group) => (`
<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>
`)),
];
userSelect.innerHTML = userOptions.join('');
groupSelect.innerHTML = groupOptions.join('');
userSelect.value = Number(item.ansvarlig_bruger_id || 0) > 0 ? String(Number(item.ansvarlig_bruger_id)) : '';
groupSelect.value = Number(item.assigned_group_id || 0) > 0 ? String(Number(item.assigned_group_id)) : '';
startInput.value = toDatetimeLocalValue(item.start_date);
deadlineInput.value = toDatetimeLocalValue(item.deadline);
const modal = getOrCreateDayCaseModal();
if (modal) modal.show();
}
async function saveDayCaseQuick() {
const id = Number(state.selectedDayCaseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById('dayCaseQuickUser');
const groupSelect = document.getElementById('dayCaseQuickGroup');
const startInput = document.getElementById('dayCaseQuickStart');
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
const saveBtn = document.getElementById('dayCaseQuickSaveBtn');
if (!userSelect || !groupSelect || !startInput || !deadlineInput) return;
const payload = {
ansvarlig_bruger_id: toOptionalInt(userSelect.value),
assigned_group_id: toOptionalInt(groupSelect.value),
start_date: startInput.value ? new Date(startInput.value).toISOString() : null,
deadline: deadlineInput.value ? new Date(deadlineInput.value).toISOString() : null,
};
await patchDayCase(id, payload, saveBtn, 'Gemmer...');
const modal = getOrCreateDayCaseModal();
if (modal) modal.hide();
}
window.quickAssignCase = quickAssignCase;
window.quickAssignUser = quickAssignUser;
window.quickAssignGroup = quickAssignGroup;
window.openDayCaseQuick = openDayCaseQuick;
function renderEnvironmentReadings() {
const container = document.getElementById('environmentReadings');
@ -2427,6 +2723,11 @@
});
}
document.getElementById('dayCaseQuickSaveBtn')?.addEventListener('click', () => {
saveDayCaseQuick();
resetIdleTimer();
});
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
document.addEventListener(name, resetIdleTimer, { passive: true });
});

View File

@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column
@ -1273,7 +1273,7 @@ async def delete_todo_step(step_id: int):
raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id:int}")
async def update_sag(sag_id: int, updates: dict):
async def update_sag(sag_id: int, updates: dict = Body(...)):
"""Update a case."""
try:
# Check if case exists
@ -2892,7 +2892,7 @@ async def get_sale_item(sag_id: int, item_id: int):
@router.patch("/sag/{sag_id}/sale-items/{item_id}")
async def update_sale_item(sag_id: int, item_id: int, updates: dict):
async def update_sale_item(sag_id: int, item_id: int, updates: dict = Body(...)):
"""Update a sale item for a case."""
try:
check = execute_query(

View File

@ -3461,7 +3461,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;" onchange="saveCaseStatusFromTopbar()">
{% for st in status_options %}
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
{% endfor %}
@ -3708,11 +3708,6 @@
});
};
bindChange('topbarStatusSelect', async (el) => {
await patchCase({ status: el.value || 'åben' });
location.reload();
});
bindChange('topbarTypeSelect', async (el) => {
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
location.reload();

View File

@ -23,6 +23,7 @@ from .utils import (
digits_only,
extract_extension,
ip_in_whitelist,
normalize_external_number,
is_outbound_call,
normalize_e164,
phone_suffix_8,
@ -122,11 +123,23 @@ def _is_internal_bmc_ip(client_ip: str) -> bool:
return ip_obj in ipaddress.ip_network("172.16.31.0/24")
def _is_internal_bmc_supernet_ip(client_ip: str) -> bool:
if not client_ip:
return False
try:
ip_obj = ipaddress.ip_address(client_ip)
except ValueError:
return False
return ip_obj in ipaddress.ip_network("172.16.0.0/12")
def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip()
accepted_tokens = {s for s in (env_secret, db_secret) if s}
whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
env_whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip()
db_whitelist = (_get_setting_value("telefoni_ip_whitelist", "") or "").strip()
whitelist = ",".join([part for part in (env_whitelist, db_whitelist) if part])
client_ip = _get_client_ip(request)
path = request.url.path
@ -174,6 +187,15 @@ def _validate_yealink_request(request: Request, token: Optional[str]) -> None:
else:
logger.info(" Telefoni callback whitelist not configured path=%s ip=%s", path, client_ip)
# Safety fallback: allow callbacks from internal BMC network range.
if _is_internal_bmc_supernet_ip(client_ip):
logger.warning(
"⚠️ Telefoni callback accepted via internal supernet fallback path=%s ip=%s",
path,
client_ip,
)
return
logger.warning("❌ Telefoni callback forbidden path=%s ip=%s", path, client_ip)
raise HTTPException(status_code=403, detail="Forbidden")
@ -259,8 +281,9 @@ async def yealink_established(
if candidate:
ekstern_raw = candidate
break
ekstern_e164 = normalize_e164(ekstern_raw)
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
ekstern_normalized = normalize_external_number(ekstern_raw)
ekstern_e164 = normalize_e164(ekstern_normalized or ekstern_raw)
ekstern_value = ekstern_normalized or ((ekstern_raw or "").strip() or None)
user_ids = TelefoniService.find_user_by_extension(local_extension)
@ -675,9 +698,7 @@ async def list_calls(
t.direction,
t.ekstern_nummer,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
NULLIF(TRIM(t.ekstern_nummer), '')
) AS display_number,
t.intern_extension,
t.kontakt_id,
@ -714,8 +735,74 @@ async def list_calls(
"""
params.extend([limit, offset])
rows = execute_query(query, tuple(params))
return rows or []
rows = execute_query(query, tuple(params)) or []
for row in rows:
display_raw = row.get("display_number") or row.get("ekstern_nummer")
repaired = normalize_external_number(display_raw)
if repaired:
row["display_number"] = repaired
if rows:
return rows
# Fallback: legacy mission call history (read-only rows) for environments
# where historical calls were stored before telefoni_opkald was populated.
if user_id is not None:
return []
legacy_where = []
legacy_params = []
if date_from:
legacy_where.append("m.started_at >= %s")
legacy_params.append(date_from)
if date_to:
legacy_where.append("m.started_at <= %s")
legacy_params.append(date_to)
legacy_where_sql = ("WHERE " + " AND ".join(legacy_where)) if legacy_where else ""
legacy_query = f"""
SELECT
-ROW_NUMBER() OVER (ORDER BY m.started_at DESC, m.call_id) AS id,
m.call_id AS callid,
NULL::INTEGER AS bruger_id,
CASE
WHEN LOWER(COALESCE(m.state, '')) IN ('outbound', 'udgaaende') THEN 'outbound'
ELSE 'inbound'
END AS direction,
m.caller_number AS ekstern_nummer,
m.caller_number AS display_number,
NULL::VARCHAR AS intern_extension,
NULL::INTEGER AS kontakt_id,
NULL::INTEGER AS sag_id,
m.started_at,
m.ended_at,
CASE
WHEN m.started_at IS NOT NULL AND m.ended_at IS NOT NULL
THEN GREATEST(EXTRACT(EPOCH FROM (m.ended_at - m.started_at))::int, 0)
ELSE NULL
END AS duration_sec,
m.updated_at AS created_at,
NULL::VARCHAR AS username,
NULL::VARCHAR AS full_name,
COALESCE(NULLIF(TRIM(m.contact_name), ''), NULL) AS contact_name,
COALESCE(NULLIF(TRIM(m.company_name), ''), NULL) AS contact_company,
NULL::VARCHAR AS sag_titel,
'legacy_mission'::VARCHAR AS source
FROM mission_call_state m
{legacy_where_sql}
ORDER BY m.started_at DESC, m.call_id
LIMIT %s OFFSET %s
"""
legacy_params.extend([limit, offset])
try:
legacy_rows = execute_query(legacy_query, tuple(legacy_params)) or []
except Exception:
legacy_rows = []
if without_case:
return [r for r in legacy_rows if not r.get("sag_id")]
return legacy_rows
@router.patch("/telefoni/calls/{call_id}")

View File

@ -39,6 +39,39 @@ def normalize_e164(number: Optional[str]) -> Optional[str]:
return None
def normalize_external_number(number: Optional[str]) -> Optional[str]:
"""Normalize external numbers and recover malformed concatenated callback values.
E.164 allows max 15 digits after '+'. If payload contains longer values,
we treat it as malformed concatenation and recover from the right-most part.
"""
if not number:
return None
raw = number.strip().replace(" ", "").replace("-", "")
if not raw:
return None
d = digits_only(raw)
if not d:
return None
if len(d) > 15:
# Malformed concatenation observed from callbacks.
# Prefer the leading Danish segment (user-confirmed cases like 53125928...),
# then fall back to right-most recovery.
if len(d) >= 10 and d[:2] == "45":
return "+" + d[:10]
if len(d) >= 8 and d[0] in "23456789":
return "+45" + d[:8]
if len(d) >= 10 and d[-10:].startswith("45"):
return "+" + d[-10:]
if len(d) >= 8:
return "+45" + d[-8:]
return normalize_e164(raw) or raw
def phone_suffix_8(number: Optional[str]) -> Optional[str]:
d = digits_only(number)
if len(d) < 8:

View File

@ -4,6 +4,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.core.database import execute_query
from app.modules.telefoni.backend.utils import normalize_external_number
logger = logging.getLogger(__name__)
router = APIRouter()
@ -20,9 +21,7 @@ async def telefoni_log_page(request: Request):
t.id,
t.direction,
COALESCE(
NULLIF(TRIM(t.ekstern_nummer), ''),
NULLIF(TRIM(t.raw_payload->>'caller'), ''),
NULLIF(TRIM(t.raw_payload->>'callee'), '')
NULLIF(TRIM(t.ekstern_nummer), '')
) AS display_number,
t.started_at,
t.duration_sec,
@ -42,6 +41,11 @@ async def telefoni_log_page(request: Request):
""",
(),
) or []
for row in initial_calls:
display_raw = row.get("display_number")
repaired = normalize_external_number(display_raw)
if repaired:
row["display_number"] = repaired
except Exception as e:
logger.warning("⚠️ Could not load initial telefoni calls for SSR fallback: %s", e)

View File

@ -59,7 +59,83 @@
</tr>
</thead>
<tbody id="telefoniRows">
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
{% if initial_calls and initial_calls|length > 0 %}
{% for call in initial_calls %}
<tr
data-call-id="{{ call.id }}"
data-direction="{{ call.direction or '' }}"
data-display-number="{{ call.display_number or '' }}"
data-ekstern-nummer="{{ call.display_number or '' }}"
data-started-at="{{ call.started_at or '' }}"
data-ended-at="{{ call.ended_at or '' }}"
data-duration-sec="{{ call.duration_sec if call.duration_sec is not none else '' }}"
data-kontakt-id="{{ call.kontakt_id if call.kontakt_id is not none else '' }}"
data-contact-name="{{ call.contact_name or '' }}"
data-sag-id="{{ call.sag_id if call.sag_id is not none else '' }}"
data-sag-titel="{{ call.sag_titel or '' }}"
data-full-name="{{ call.full_name or '' }}"
data-username="{{ call.username or '' }}"
>
<td>{{ call.started_at or '-' }}</td>
<td>{{ call.full_name or call.username or '-' }}</td>
<td>{% if call.direction == 'outbound' %}Udgående{% else %}Indgående{% endif %}</td>
<td>
{% if call.display_number %}
<div class="d-flex gap-2 align-items-center flex-wrap">
<span>{{ call.display_number }}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="callViaYealink('{{ call.display_number|replace("'", "\\'") }}')">Ring op</button>
</div>
{% else %}
-
{% endif %}
</td>
<td>
{% if call.kontakt_id %}
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="/contacts/{{ call.kontakt_id }}">{{ (call.contact_name or ('Kontakt #' ~ call.kontakt_id))|trim }}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal({{ call.id }})" title="Skift kontakt / opret kontakt">
<i class="bi bi-person-gear"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact({{ call.id }})" title="Fjern kontakt-link">
<i class="bi bi-x-circle"></i>
</button>
</div>
{% else %}
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal({{ call.id }})" title="Tilknyt eller opret kontakt">
<i class="bi bi-person-plus"></i>
</button>
</div>
{% endif %}
</td>
<td>
{% if call.sag_id %}
<div class="d-flex gap-2 align-items-center flex-wrap">
<a href="/sag/{{ call.sag_id }}/v3">{{ call.sag_titel or ('Sag #' ~ call.sag_id) }}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase({{ call.id }})" title="Skift sag-link">
<i class="bi bi-link-45deg"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase({{ call.id }})" title="Fjern sag-link">
<i class="bi bi-x-circle"></i>
</button>
</div>
{% else %}
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="/sag/new?telefoni_opkald_id={{ call.id }}{% if call.kontakt_id %}&contact_id={{ call.kontakt_id }}{% endif %}" title="Opret ny sag">
<i class="bi bi-folder-plus"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase({{ call.id }})" title="Tilknyt eksisterende sag">
<i class="bi bi-link-45deg"></i>
</button>
</div>
{% endif %}
</td>
<td class="text-end">{{ call.duration_sec if call.duration_sec is not none else '-' }}</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
{% endif %}
</tbody>
</table>
</div>
@ -177,6 +253,45 @@ function escapeHtml(str) {
.replaceAll("'", '&#039;');
}
function normalizeDisplayNumber(value) {
const raw = String(value || '').trim();
if (!raw) return '';
// Internal SIP identifiers should not be shown as external caller numbers.
if (/^sip:/i.test(raw)) {
const sipDigits = raw.replace(/\D+/g, '');
if (sipDigits.length <= 6) return '';
}
const digits = raw.replace(/\D+/g, '');
if (!digits) return raw;
// E.164 max is 15 digits (excluding +). Longer values are malformed/concatenated.
if (digits.length > 15) {
if (digits.length >= 10 && digits.slice(0, 2) === '45') {
return `+${digits.slice(0, 10)}`;
}
if (digits.length >= 8 && /[2-9]/.test(digits.charAt(0))) {
return `+45${digits.slice(0, 8)}`;
}
if (digits.length >= 10 && digits.slice(-10).startsWith('45')) {
return `+${digits.slice(-10)}`;
}
if (digits.length >= 8) {
return `+45${digits.slice(-8)}`;
}
}
if (raw.startsWith('+')) {
return digits.length >= 9 ? `+${digits}` : raw;
}
if (digits.length === 8) return `+45${digits}`;
if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`;
if (digits.length >= 9) return `+${digits}`;
return raw;
}
let telefoniCurrentUserId = null;
const telefoniCallMap = new Map();
const linkSagState = {
@ -820,9 +935,84 @@ async function loadUsers() {
}
}
async function loadCalls() {
function hasExistingCallRows(tbody) {
return Boolean(tbody && tbody.querySelector('tr[data-call-id]'));
}
function hydrateCallMapFromSsrRows() {
const rows = document.querySelectorAll('#telefoniRows tr[data-call-id]');
if (!rows.length) return;
rows.forEach((row) => {
const callId = Number(row.dataset.callId);
if (!Number.isInteger(callId) || callId <= 0) return;
const toIntOrNull = (value) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
};
const durationRaw = String(row.dataset.durationSec || '').trim();
const durationSec = durationRaw === '' ? null : Number(durationRaw);
telefoniCallMap.set(callId, {
id: callId,
direction: String(row.dataset.direction || '').trim() || null,
display_number: normalizeDisplayNumber(String(row.dataset.displayNumber || '').trim()) || null,
ekstern_nummer: normalizeDisplayNumber(String(row.dataset.eksternNummer || '').trim()) || null,
started_at: String(row.dataset.startedAt || '').trim() || null,
ended_at: String(row.dataset.endedAt || '').trim() || null,
duration_sec: Number.isFinite(durationSec) ? durationSec : null,
kontakt_id: toIntOrNull(row.dataset.kontaktId),
contact_name: String(row.dataset.contactName || '').trim() || null,
sag_id: toIntOrNull(row.dataset.sagId),
sag_titel: String(row.dataset.sagTitel || '').trim() || null,
full_name: String(row.dataset.fullName || '').trim() || null,
username: String(row.dataset.username || '').trim() || null,
});
});
}
function cacheInitialSsrRows(tbody) {
if (!tbody) return;
if (tbody.dataset.initialRowsCached === '1') return;
tbody.dataset.initialRowsHtml = tbody.innerHTML;
tbody.dataset.initialRowsCached = '1';
}
function restoreInitialSsrRows(tbody) {
if (!tbody) return false;
const initialHtml = String(tbody.dataset.initialRowsHtml || '').trim();
if (!initialHtml) return false;
if (!initialHtml.includes('data-call-id')) return false;
tbody.innerHTML = initialHtml;
tbody.classList.remove('d-none');
tbody.style.display = '';
tbody.style.visibility = '';
tbody.style.opacity = '';
return true;
}
function hasActiveCallFilters() {
const userId = document.getElementById('filterUser')?.value;
const from = document.getElementById('filterFrom')?.value;
const to = document.getElementById('filterTo')?.value;
const withoutCase = document.getElementById('filterWithoutCase')?.checked;
return Boolean(userId || from || to || withoutCase);
}
async function loadCalls(options = {}) {
const preserveOnEmpty = Boolean(options.preserveOnEmpty);
const skipLoadingState = Boolean(options.skipLoadingState);
const tbody = document.getElementById('telefoniRows');
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</td></tr>';
cacheInitialSsrRows(tbody);
const hadRowsBeforeLoad = hasExistingCallRows(tbody);
const noActiveFilters = !hasActiveCallFilters();
if (!skipLoadingState) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</td></tr>';
}
const userId = document.getElementById('filterUser').value;
const from = document.getElementById('filterFrom').value;
@ -839,23 +1029,47 @@ async function loadCalls() {
const res = await fetch('/api/v1/telefoni/calls?' + qs.toString(), { credentials: 'include' });
if (!res.ok) {
const t = await res.text();
if (preserveOnEmpty && hadRowsBeforeLoad && noActiveFilters) {
console.warn('Telefoni: bevarer SSR-rækker efter tom/fejlende auto-refresh');
return;
}
if (preserveOnEmpty && noActiveFilters && restoreInitialSsrRows(tbody)) {
console.warn('Telefoni: gendannede SSR-rækker efter API-fejl under auto-refresh');
return;
}
tbody.innerHTML = `<tr><td colspan="7" class="text-danger small">Fejl: ${escapeHtml(t)}</td></tr>`;
return;
}
const rows = await res.json();
telefoniCallMap.clear();
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
(rows || []).forEach(r => {
const fixedDisplay = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
const fixedExternal = normalizeDisplayNumber(r.ekstern_nummer || r.display_number || '');
r.display_number = fixedDisplay || null;
r.ekstern_nummer = fixedExternal || null;
telefoniCallMap.set(Number(r.id), r);
});
if (!rows || rows.length === 0) {
if (preserveOnEmpty && hadRowsBeforeLoad && noActiveFilters) {
console.warn('Telefoni: API returnerede 0 rækker, bevarer eksisterende SSR-visning');
return;
}
if (preserveOnEmpty && noActiveFilters && restoreInitialSsrRows(tbody)) {
console.warn('Telefoni: gendannede SSR-rækker efter tom API-respons');
return;
}
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const callId = Number(r.id);
const canMutateCall = Number.isInteger(callId) && callId > 0;
const started = r.started_at ? new Date(r.started_at) : null;
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
const userTxt = escapeHtml(r.full_name || r.username || '-');
const dirTxt = r.direction === 'outbound' ? 'Udgående' : 'Indgående';
const numberRaw = (r.display_number || r.ekstern_nummer || '').trim();
const numberRaw = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
const numTxt = numberRaw
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<span>${escapeHtml(numberRaw)}</span>
@ -866,33 +1080,41 @@ async function loadCalls() {
const contactHtml = r.kontakt_id
? `<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})">Skift</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${Number(r.id)})">Fjern</button>
${canMutateCall
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Skift kontakt / opret kontakt"><i class="bi bi-person-gear"></i></button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${callId})" title="Fjern kontakt-link"><i class="bi bi-x-circle"></i></button>`
: '<span class="badge bg-light text-muted border">Historik</span>'}
</div>
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
: `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})" title="Vælg kontakt/firma">
<i class="bi bi-three-dots"></i>
</button>`;
: (canMutateCall
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Tilknyt eller opret kontakt">
<i class="bi bi-person-plus"></i>
</button>`
: '<span class="text-muted small">Historisk opkald</span>');
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
const numberForTitle = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
const createQs = new URLSearchParams();
if (r.kontakt_id) createQs.set('contact_id', String(r.kontakt_id));
createQs.set('telefoni_opkald_id', String(r.id));
if (canMutateCall) createQs.set('telefoni_opkald_id', String(callId));
createQs.set('title', `Telefonsamtale ${numberForTitle || 'ukendt nummer'}`);
const sagHtml = r.sag_id
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<a href="/sag/${r.sag_id}/v3">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${Number(r.id)})">Skift link</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${Number(r.id)})">Fjern link</button>
${canMutateCall
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})" title="Skift sag-link"><i class="bi bi-link-45deg"></i></button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${callId})" title="Fjern sag-link"><i class="bi bi-x-circle"></i></button>`
: '<span class="badge bg-light text-muted border">Historik</span>'}
</div>`
: `<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="/sag/new?${createQs.toString()}">Opret sag</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${Number(r.id)})">Link sag</button>
</div>`;
: (canMutateCall
? `<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="/sag/new?${createQs.toString()}" title="Opret ny sag"><i class="bi bi-folder-plus"></i></a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})" title="Tilknyt eksisterende sag"><i class="bi bi-link-45deg"></i></button>
</div>`
: '<span class="text-muted small">Ingen sag</span>');
return `
<tr>
<tr data-call-id="${callId}">
<td>${escapeHtml(dateTxt)}</td>
<td>${userTxt}</td>
<td>${escapeHtml(dirTxt)}</td>
@ -905,6 +1127,14 @@ async function loadCalls() {
}).join('');
} catch (e) {
console.error('Failed loading calls', e);
if (preserveOnEmpty && hadRowsBeforeLoad && noActiveFilters) {
console.warn('Telefoni: bevarer SSR-rækker efter exception i auto-refresh');
return;
}
if (preserveOnEmpty && noActiveFilters && restoreInitialSsrRows(tbody)) {
console.warn('Telefoni: gendannede SSR-rækker efter exception i auto-refresh');
return;
}
tbody.innerHTML = '<tr><td colspan="7" class="text-danger small">Kunne ikke hente opkald</td></tr>';
}
}
@ -963,6 +1193,9 @@ async function unlinkCase(callId) {
}
document.addEventListener('DOMContentLoaded', async () => {
const telefoniRows = document.getElementById('telefoniRows');
cacheInitialSsrRows(telefoniRows);
hydrateCallMapFromSsrRows();
initLinkContactModalEvents();
initLinkSagModalEvents();
const userFilter = document.getElementById('filterUser');
@ -981,7 +1214,15 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('filterFrom').addEventListener('change', loadCalls);
document.getElementById('filterTo').addEventListener('change', loadCalls);
document.getElementById('filterWithoutCase').addEventListener('change', loadCalls);
await loadCalls();
// Keep SSR rows on first paint when they exist; avoid replacing visible data
// with an empty state due to transient API/auth/cache issues in production.
if (hasExistingCallRows(telefoniRows)) {
console.warn('Telefoni: springer initial auto-refresh over, SSR-rækker vises');
return;
}
await loadCalls({ preserveOnEmpty: true, skipLoadingState: true });
});
</script>
{% endblock %}

View File

@ -1156,11 +1156,13 @@ class EmailWorkflowService:
if sag_id_from_tag:
if sag_id and sag_id != sag_id_from_tag:
logger.warning(
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using thread match.",
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using SAG tag.",
email_id,
sag_id,
sag_id_from_tag
)
sag_id = sag_id_from_tag
routing_source = 'sag_tag'
elif not sag_id:
sag_id = sag_id_from_tag
routing_source = 'sag_tag'

View File

@ -5,16 +5,18 @@ Settings Frontend Views
from datetime import datetime
from pathlib import Path
import re
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from app.core.config import settings
from app.core.database import get_db_connection, release_db_connection, execute_query_single
from app.core.database import get_db_connection, release_db_connection, execute_query_single, execute_query
from app.core.auth_dependencies import require_any_permission
router = APIRouter()
templates = Jinja2Templates(directory="app")
sql_console_access = require_any_permission("users.manage", "system.admin")
CREATE_TABLE_RE = re.compile(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
@ -33,6 +35,13 @@ SKIP_COLUMN_LINE_RE = re.compile(
re.IGNORECASE,
)
SQL_COMMENT_BLOCK_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
SQL_COMMENT_LINE_RE = re.compile(r"--[^\n]*")
SQL_FORBIDDEN_RE = re.compile(
r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|comment|copy|vacuum|analyze|refresh|merge)\b",
re.IGNORECASE,
)
def _strip_sql_comments(sql: str) -> str:
sql = re.sub(r"/\*.*?\*/", "", sql, flags=re.DOTALL)
@ -291,6 +300,67 @@ class MigrationExecution(BaseModel):
file_name: str
class SqlConsoleRequest(BaseModel):
query: str
limit: int = 200
def _sanitize_and_validate_sql(sql: str) -> str:
raw = (sql or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="SQL query is required")
cleaned = SQL_COMMENT_BLOCK_RE.sub("", raw)
cleaned = SQL_COMMENT_LINE_RE.sub("", cleaned).strip()
if not cleaned:
raise HTTPException(status_code=400, detail="SQL query is empty after removing comments")
lowered = cleaned.lower()
if not (lowered.startswith("select") or lowered.startswith("with")):
raise HTTPException(status_code=400, detail="Only SELECT/WITH queries are allowed")
single_statement = cleaned.rstrip(";").strip()
if ";" in single_statement:
raise HTTPException(status_code=400, detail="Only one SQL statement is allowed")
if SQL_FORBIDDEN_RE.search(single_statement):
raise HTTPException(status_code=400, detail="Query contains forbidden SQL keywords")
return single_statement
@router.get("/settings/sql", response_class=HTMLResponse, tags=["Frontend"])
async def sql_console_page(request: Request, _current_user: dict = Depends(sql_console_access)):
return templates.TemplateResponse(
"settings/frontend/sql_console.html",
{
"request": request,
"title": "SQL Console",
},
)
@router.post("/settings/sql/execute", tags=["Frontend"])
async def execute_sql_console_query(payload: SqlConsoleRequest, _current_user: dict = Depends(sql_console_access)):
query = _sanitize_and_validate_sql(payload.query)
limit = payload.limit if isinstance(payload.limit, int) else 200
if limit < 1:
limit = 1
if limit > 1000:
limit = 1000
wrapped_query = f"SELECT * FROM ({query}) AS bmc_sql_console LIMIT %s"
rows = execute_query(wrapped_query, (limit,)) or []
columns = list(rows[0].keys()) if rows else []
return {
"columns": columns,
"rows": rows,
"row_count": len(rows),
"limit": limit,
}
@router.get("/settings/migrations/status", tags=["Frontend"])
def migration_statuses():
"""Check migration files against current schema and return per-file color status."""

View File

@ -125,6 +125,9 @@
<a class="nav-link" href="/settings/migrations">
<i class="bi bi-database me-2"></i>DB Migrationer
</a>
<a class="nav-link" href="/settings/sql">
<i class="bi bi-terminal me-2"></i>SQL Console
</a>
</nav>
</div>
</div>

View File

@ -0,0 +1,189 @@
{% extends "shared/frontend/base.html" %}
{% block title %}SQL Console - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="mb-1"><i class="bi bi-database me-2"></i>SQL Console</h2>
<div class="text-muted small">Read-only SQL for superadmin. Kun SELECT/WITH queries.</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Presets</label>
<div class="d-flex flex-wrap gap-2" id="sqlPresets">
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="serverTime">Server time</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="telefoniLatest">Telefoni latest</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="telefoniCount">Telefoni count</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="missionLatest">Mission latest</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-preset="sourceCompare">Source compare</button>
</div>
</div>
<div class="mb-3">
<label for="sqlQuery" class="form-label">SQL query</label>
<textarea id="sqlQuery" class="form-control" rows="8" spellcheck="false" placeholder="SELECT * FROM telefoni_opkald ORDER BY started_at DESC"></textarea>
</div>
<div class="row g-3 align-items-end">
<div class="col-md-2">
<label for="sqlLimit" class="form-label">Limit</label>
<input id="sqlLimit" type="number" class="form-control" value="200" min="1" max="1000" />
</div>
<div class="col-md-10 d-flex gap-2 justify-content-end">
<button id="runSqlBtn" class="btn btn-primary">
<i class="bi bi-play-fill me-1"></i>Run Query
</button>
</div>
</div>
<div id="sqlStatus" class="alert alert-info py-2 mt-3 mb-0">Ready</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped align-middle mb-0">
<thead id="sqlResultHead"></thead>
<tbody id="sqlResultBody">
<tr><td class="text-muted">No results yet</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const SQL_PRESETS = {
serverTime: {
query: 'SELECT now() AS server_time',
limit: 50,
},
telefoniLatest: {
query: `SELECT id, callid, direction, ekstern_nummer, started_at, ended_at, duration_sec\nFROM telefoni_opkald\nORDER BY started_at DESC\nLIMIT 50`,
limit: 50,
},
telefoniCount: {
query: 'SELECT COUNT(*) AS total_calls, MAX(started_at) AS latest_started_at FROM telefoni_opkald',
limit: 50,
},
missionLatest: {
query: `SELECT call_id, state, caller_number, contact_name, company_name, started_at, ended_at\nFROM mission_call_state\nORDER BY started_at DESC\nLIMIT 50`,
limit: 50,
},
sourceCompare: {
query: `WITH telefoni AS (\n SELECT 'telefoni_opkald'::text AS source, COUNT(*)::bigint AS total_calls, MAX(started_at) AS latest_started_at\n FROM telefoni_opkald\n), mission AS (\n SELECT 'mission_call_state'::text AS source, COUNT(*)::bigint AS total_calls, MAX(started_at) AS latest_started_at\n FROM mission_call_state\n)\nSELECT * FROM telefoni\nUNION ALL\nSELECT * FROM mission`,
limit: 50,
},
};
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderResults(data) {
const head = document.getElementById('sqlResultHead');
const body = document.getElementById('sqlResultBody');
const columns = Array.isArray(data.columns) ? data.columns : [];
const rows = Array.isArray(data.rows) ? data.rows : [];
if (columns.length === 0) {
head.innerHTML = '';
body.innerHTML = '<tr><td class="text-muted">No rows returned</td></tr>';
return;
}
head.innerHTML = '<tr>' + columns.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr>';
if (rows.length === 0) {
body.innerHTML = `<tr><td colspan="${columns.length}" class="text-muted">No rows returned</td></tr>`;
return;
}
body.innerHTML = rows.map((row) => {
return '<tr>' + columns.map((c) => {
const value = row[c];
if (value === null || value === undefined) return '<td class="text-muted">NULL</td>';
if (typeof value === 'object') return `<td>${escapeHtml(JSON.stringify(value))}</td>`;
return `<td>${escapeHtml(String(value))}</td>`;
}).join('') + '</tr>';
}).join('');
}
async function runSqlQuery() {
const query = (document.getElementById('sqlQuery').value || '').trim();
const limitInput = Number(document.getElementById('sqlLimit').value || 200);
const limit = Number.isFinite(limitInput) ? limitInput : 200;
const status = document.getElementById('sqlStatus');
const button = document.getElementById('runSqlBtn');
status.className = 'alert alert-info py-2 mt-3 mb-0';
status.textContent = 'Running query...';
button.disabled = true;
try {
const response = await fetch('/settings/sql/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ query, limit }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail || 'Failed to run query';
status.className = 'alert alert-danger py-2 mt-3 mb-0';
status.textContent = message;
return;
}
const data = await response.json();
renderResults(data);
status.className = 'alert alert-success py-2 mt-3 mb-0';
status.textContent = `Rows returned: ${data.row_count} (limit ${data.limit})`;
} catch (error) {
status.className = 'alert alert-danger py-2 mt-3 mb-0';
status.textContent = 'Network or server error while running query';
} finally {
button.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
const queryEl = document.getElementById('sqlQuery');
const limitEl = document.getElementById('sqlLimit');
document.getElementById('runSqlBtn').addEventListener('click', runSqlQuery);
queryEl.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
runSqlQuery();
}
});
document.querySelectorAll('#sqlPresets [data-preset]').forEach((button) => {
button.addEventListener('click', () => {
const key = button.getAttribute('data-preset');
const preset = SQL_PRESETS[key];
if (!preset) return;
queryEl.value = preset.query;
limitEl.value = String(preset.limit || 50);
queryEl.focus();
});
});
queryEl.value = SQL_PRESETS.serverTime.query;
limitEl.value = String(SQL_PRESETS.serverTime.limit);
});
</script>
{% endblock %}

View File

@ -1267,7 +1267,7 @@ window.addEventListener('unhandledrejection', function(event) {
<script src="/static/js/tag-picker.js?v=2.2"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/telefoni.js?v=2.3"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bug-report.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.23"></script>

View File

@ -536,12 +536,18 @@ if __name__ == "__main__":
log_level="info"
)
else:
api_workers_raw = os.getenv("API_WORKERS", "1").strip()
try:
api_workers = max(1, int(api_workers_raw))
except ValueError:
api_workers = 1
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=False,
workers=2,
workers=api_workers,
timeout_keep_alive=65,
access_log=True,
log_level="info"

View File

@ -0,0 +1,6 @@
-- Migration 186: Telefoni callback IP whitelist setting
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('telefoni_ip_whitelist', '', 'telefoni', 'CSV med tilladte callback IP/CIDR til Yealink callbacks', 'string', false)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,48 @@
-- Migration 190: Align sag_sager status constraint with current case status model
-- Fixes PATCH /api/v1/sag/{id} failures when using statuses beyond 'åben'/'lukket'.
DO $$
DECLARE
constraint_row RECORD;
BEGIN
-- Drop legacy check constraints on sag_sager.status regardless of their generated name.
FOR constraint_row IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'sag_sager'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%status%'
LOOP
EXECUTE format('ALTER TABLE public.sag_sager DROP CONSTRAINT IF EXISTS %I', constraint_row.conname);
END LOOP;
END $$;
UPDATE public.sag_sager
SET status = CASE
WHEN lower(trim(status)) IN ('aaben', 'open') THEN 'åben'
WHEN lower(trim(status)) IN ('i_gang', 'in_progress', 'under behandling') THEN 'under behandling'
WHEN lower(trim(status)) IN ('on_hold', 'waiting', 'afventer') THEN 'afventer'
WHEN lower(trim(status)) IN ('resolved', 'løst') THEN 'løst'
WHEN lower(trim(status)) IN ('closed', 'afsluttet', 'lukket') THEN 'lukket'
ELSE status
END;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'sag_sager'
AND c.conname = 'sag_sager_status_check'
) THEN
ALTER TABLE public.sag_sager
ADD CONSTRAINT sag_sager_status_check
CHECK (status IN ('åben', 'under behandling', 'afventer', 'løst', 'lukket'));
END IF;
END $$;

View File

@ -143,6 +143,7 @@
<div class="d-flex gap-2 mt-3">
${openContactBtn}
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
</div>
</div>
`;
@ -153,7 +154,30 @@
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
toastEl.addEventListener('click', (e) => {
async function patchCallCase(caseId) {
const parsedCaseId = Number(caseId);
if (!Number.isInteger(parsedCaseId) || parsedCaseId <= 0) {
throw new Error('Ugyldigt sag-ID');
}
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
throw new Error('Mangler call_id');
}
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sag_id: parsedCaseId })
});
if (!res.ok) {
const t = await res.text();
throw new Error(t || `HTTP ${res.status}`);
}
return parsedCaseId;
}
toastEl.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.getAttribute('data-action');
@ -168,6 +192,28 @@
qs.set('telefoni_opkald_id', String(callId));
window.location.href = `/sag/new?${qs.toString()}`;
}
if (action === 'link-case') {
const answer = window.prompt('Indtast eksisterende sag-ID, som opkaldet skal linkes til:');
if (answer === null) return;
const caseId = Number(String(answer).trim());
if (!Number.isInteger(caseId) || caseId <= 0) {
window.alert('Ugyldigt sag-ID');
return;
}
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = 'Gemmer...';
try {
const linkedCaseId = await patchCallCase(caseId);
window.location.href = `/sag/${linkedCaseId}/v3`;
} catch (err) {
window.alert(`Kunne ikke linke sag: ${err?.message || 'ukendt fejl'}`);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
});
}
@ -180,18 +226,16 @@
}
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
const token = getToken();
if (!token) {
scheduleReconnect();
return;
}
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`;
// Fallback to cookie-auth websocket when token is HttpOnly and cannot be read by JS.
const url = token
? `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`
: `${proto}://${window.location.host}/api/v1/telefoni/ws`;
ws = new WebSocket(url);
ws.onopen = () => console.log('📞 Telefoni WS connected');

515
update_fast.sh Executable file
View File

@ -0,0 +1,515 @@
#!/bin/bash
# Fast code-only update for BMC Hub (DEV/PROD)
#
# Purpose:
# - Deploy small app code/template/static changes without creating a new tag/package.
# - Keep full release workflow in updateto.sh for dependency/runtime/migration changes.
#
# Safety model:
# - Allowed paths: app/* and main.py
# - Disallowed: migrations, Docker/compose, requirements, env files, deploy scripts.
set -euo pipefail
PODMAN_COMPOSE_FILE="docker-compose.prod.yml"
STATE_DIR=".fast-update"
STATE_FILE="${STATE_DIR}/current_ref"
LAST_BACKUP_FILE="${STATE_DIR}/last_backup"
TARGET_REF=""
DRY_RUN=false
ALLOW_PROD=false
ROLLBACK_ID=""
usage() {
cat <<'EOF'
Usage:
./update_fast.sh --ref <git-ref> [--dry-run] [--allow-prod]
./update_fast.sh --rollback <backup-id> [--allow-prod]
Examples:
./update_fast.sh --ref main --dry-run
./update_fast.sh --ref 08f4097
./update_fast.sh --rollback 20260517-142155
Notes:
- Fast mode is ONLY for code/template/static updates in app/* and main.py.
- For migrations, dependencies, Docker/compose, or env changes: use ./updateto.sh.
- On production hosts, --allow-prod is required.
EOF
}
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
echo "Error: do not run as root. Use the normal rootless podman user."
exit 1
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--ref)
TARGET_REF="${2:-}"
shift 2
;;
--dry-run)
DRY_RUN=true
shift
;;
--allow-prod)
ALLOW_PROD=true
shift
;;
--rollback)
ROLLBACK_ID="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option: $1"
usage
exit 1
;;
esac
done
if [ -n "$ROLLBACK_ID" ] && [ -n "$TARGET_REF" ]; then
echo "Error: use either --ref or --rollback, not both."
exit 1
fi
if [ -z "$ROLLBACK_ID" ] && [ -z "$TARGET_REF" ]; then
echo "Error: missing required option --ref <git-ref> or --rollback <backup-id>."
usage
exit 1
fi
if [ ! -f ".env" ]; then
echo "Error: .env not found in $(pwd)"
exit 1
fi
if [ ! -f "$PODMAN_COMPOSE_FILE" ]; then
echo "Error: $PODMAN_COMPOSE_FILE not found in $(pwd)"
exit 1
fi
load_env_file() {
local env_file="$1"
local line=""
local trimmed=""
local key=""
local value=""
local first_char=""
local last_char=""
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
trimmed="${line#"${line%%[![:space:]]*}"}"
if [ -z "$trimmed" ] || [[ "$trimmed" == \#* ]]; then
continue
fi
if [[ "$line" != *=* ]]; then
echo "Error: invalid line in .env: $line"
exit 1
fi
key="${line%%=*}"
value="${line#*=}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "Error: invalid .env key: $key"
exit 1
fi
if [ "${#value}" -ge 2 ]; then
first_char="${value:0:1}"
last_char="${value: -1}"
if { [ "$first_char" = '"' ] && [ "$last_char" = '"' ]; } || { [ "$first_char" = "'" ] && [ "$last_char" = "'" ]; }; then
value="${value:1:${#value}-2}"
fi
fi
export "$key=$value"
done < "$env_file"
}
load_env_file .env
get_current_ip() {
local ip=""
if ip=$(hostname -I 2>/dev/null | awk '{print $1}'); then
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
fi
if command -v ipconfig >/dev/null 2>&1; then
ip=$(ipconfig getifaddr en0 2>/dev/null || true)
if [ -z "$ip" ]; then
ip=$(ipconfig getifaddr en1 2>/dev/null || true)
fi
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
fi
echo "unknown"
}
CURRENT_IP="$(get_current_ip)"
CURRENT_DIR=$(pwd)
IS_PROD=false
if [[ "$CURRENT_IP" == "172.16.31.183" ]] || [[ "$CURRENT_DIR" == "/srv/podman/bmc_hub_v2" ]] || [[ "$CURRENT_DIR" == "/srv/podman/bmc_hub_v1.0" ]]; then
IS_PROD=true
fi
if [ "$IS_PROD" = true ] && [ "$ALLOW_PROD" != true ]; then
echo "Error: production host detected. Re-run with --allow-prod to continue."
exit 1
fi
if [ "$IS_PROD" = true ] && [ "$DRY_RUN" != true ]; then
echo "Production safety check:"
echo " host ip: $CURRENT_IP"
echo " cwd: $CURRENT_DIR"
read -r -p "Type FAST-UPDATE to continue: " CONFIRM
if [ "$CONFIRM" != "FAST-UPDATE" ]; then
echo "Aborted."
exit 1
fi
fi
mkdir -p "$STATE_DIR/backups"
GITEA_BASE="${GITEA_URL:-https://g.bmcnetworks.dk}"
REPO="${GITHUB_REPO:-ct/bmc_hub}"
API_PORT="${API_PORT:-8000}"
url_encode() {
python3 - "$1" <<'PY'
import sys
from urllib.parse import quote
print(quote(sys.argv[1], safe=""))
PY
}
fetch_url_to_file() {
local url="$1"
local out_file="$2"
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token ${GITHUB_TOKEN}" "$url" -o "$out_file"
else
curl -fsSL "$url" -o "$out_file"
fi
}
build_changed_from_compare_json() {
local compare_file="$1"
local out_file="$2"
python3 - "$compare_file" > "$out_file" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as f:
data = json.load(f)
files = data.get("files") or []
for item in files:
status = str(item.get("status") or "")
filename = str(item.get("filename") or "")
prev = str(item.get("previous_filename") or "")
print(f"{status}\t{filename}\t{prev}")
PY
}
build_changed_from_local_git() {
local base_ref="$1"
local target_ref="$2"
local out_file="$3"
if ! git rev-parse --verify "$base_ref" >/dev/null 2>&1; then
return 1
fi
if ! git rev-parse --verify "$target_ref" >/dev/null 2>&1; then
return 1
fi
git diff --name-status "$base_ref".."$target_ref" \
| awk 'BEGIN{OFS="\t"}
/^A\t/ {print "added", $2, ""}
/^M\t/ {print "modified", $2, ""}
/^D\t/ {print "deleted", $2, ""}
/^R[0-9]*\t/ {print "renamed", $3, $2}
/^C[0-9]*\t/ {print "copied", $3, $2}
/^T\t/ {print "type_changed", $2, ""}
/^U\t/ {print "unmerged", $2, ""}
/^X\t/ {print "unknown", $2, ""}
/^B\t/ {print "broken", $2, ""}' > "$out_file"
}
is_disallowed_path() {
local path="$1"
case "$path" in
migrations/*|Dockerfile|requirements.txt|docker-compose.yml|docker-compose.prod.yml|.env|.env.*|updateto.sh|update_fast.sh|scripts/*)
return 0
;;
*)
return 1
;;
esac
}
is_allowed_path() {
local path="$1"
case "$path" in
app/*|main.py)
return 0
;;
*)
return 1
;;
esac
}
health_check() {
local attempts=30
local i=1
while [ "$i" -le "$attempts" ]; do
if curl -fsS "http://localhost:${API_PORT}/health" >/dev/null 2>&1; then
return 0
fi
echo "Waiting for API health (${i}/${attempts})"
sleep 2
i=$((i + 1))
done
return 1
}
rebuild_api_latest() {
RELEASE_VERSION=latest podman-compose -f "$PODMAN_COMPOSE_FILE" build api
RELEASE_VERSION=latest podman-compose -f "$PODMAN_COMPOSE_FILE" up -d --no-deps api
}
restore_backup() {
local backup_id="$1"
local backup_dir="${STATE_DIR}/backups/${backup_id}"
local manifest="${backup_dir}/manifest.tsv"
if [ ! -d "$backup_dir" ]; then
echo "Error: backup id not found: $backup_id"
exit 1
fi
if [ ! -f "$manifest" ]; then
echo "Error: manifest missing: $manifest"
exit 1
fi
echo "Restoring backup: ${backup_id}"
while IFS=$'\t' read -r status path existed; do
[ -z "$path" ] && continue
mkdir -p "$(dirname "$path")"
if [ "$existed" = "1" ]; then
cp "${backup_dir}/original/${path}" "$path"
else
rm -f "$path"
fi
done < "$manifest"
rebuild_api_latest
if ! health_check; then
echo "Error: health check failed after rollback."
exit 1
fi
if [ -f "${backup_dir}/meta.env" ]; then
# shellcheck disable=SC1090
source "${backup_dir}/meta.env"
if [ -n "${BASE_REF:-}" ]; then
echo "$BASE_REF" > "$STATE_FILE"
fi
fi
echo "Rollback completed: ${backup_id}"
}
if [ -n "$ROLLBACK_ID" ]; then
restore_backup "$ROLLBACK_ID"
exit 0
fi
BASE_REF=""
if [ -f "$STATE_FILE" ]; then
BASE_REF="$(cat "$STATE_FILE")"
elif [ -n "${RELEASE_VERSION:-}" ] && [ "${RELEASE_VERSION}" != "latest" ]; then
BASE_REF="$RELEASE_VERSION"
elif [ -n "${FAST_BASE_REF:-}" ]; then
BASE_REF="$FAST_BASE_REF"
else
echo "Error: cannot determine base ref."
echo "Set RELEASE_VERSION in .env, or create ${STATE_FILE}, or provide FAST_BASE_REF env var."
exit 1
fi
echo "Fast update"
echo " base ref: $BASE_REF"
echo " target ref: $TARGET_REF"
BASE_ENC="$(url_encode "$BASE_REF")"
TARGET_ENC="$(url_encode "$TARGET_REF")"
COMPARE_URL="${GITEA_BASE}/api/v1/repos/${REPO}/compare/${BASE_ENC}...${TARGET_ENC}"
TMP_COMPARE="$(mktemp)"
TMP_CHANGED="$(mktemp)"
trap 'rm -f "$TMP_COMPARE" "$TMP_CHANGED"' EXIT
COMPARE_SOURCE=""
if fetch_url_to_file "$COMPARE_URL" "$TMP_COMPARE"; then
build_changed_from_compare_json "$TMP_COMPARE" "$TMP_CHANGED"
COMPARE_SOURCE="gitea-api"
else
if [ -d ".git" ] && build_changed_from_local_git "$BASE_REF" "$TARGET_REF" "$TMP_CHANGED"; then
COMPARE_SOURCE="local-git"
echo "Warning: compare API unavailable, using local git diff fallback."
else
echo "Error: could not fetch compare data from: $COMPARE_URL"
echo "Hint: ensure GITHUB_TOKEN is set, refs exist, or run in a git clone where both refs are available."
exit 1
fi
fi
echo "Compare source: ${COMPARE_SOURCE}"
if [ ! -s "$TMP_CHANGED" ]; then
echo "No changed files between ${BASE_REF} and ${TARGET_REF}."
exit 0
fi
declare -a DISALLOWED_FILES=()
declare -a NOT_ALLOWED_FILES=()
declare -a UNSUPPORTED_STATUS=()
while IFS=$'\t' read -r status file_path prev_path; do
[ -z "$file_path" ] && continue
case "$status" in
added|modified)
;;
*)
UNSUPPORTED_STATUS+=("${status}: ${file_path}")
continue
;;
esac
if is_disallowed_path "$file_path"; then
DISALLOWED_FILES+=("$file_path")
continue
fi
if ! is_allowed_path "$file_path"; then
NOT_ALLOWED_FILES+=("$file_path")
continue
fi
done < "$TMP_CHANGED"
if [ "${#UNSUPPORTED_STATUS[@]}" -gt 0 ]; then
echo "Error: unsupported change types in fast mode:"
for item in "${UNSUPPORTED_STATUS[@]}"; do
echo " - $item"
done
echo "Use ./updateto.sh for these changes."
exit 1
fi
if [ "${#DISALLOWED_FILES[@]}" -gt 0 ]; then
echo "Error: disallowed files changed for fast mode:"
for file_path in "${DISALLOWED_FILES[@]}"; do
echo " - $file_path"
done
echo "Use ./updateto.sh for this update."
exit 1
fi
if [ "${#NOT_ALLOWED_FILES[@]}" -gt 0 ]; then
echo "Error: files outside fast-mode scope changed:"
for file_path in "${NOT_ALLOWED_FILES[@]}"; do
echo " - $file_path"
done
echo "Fast mode only allows app/* and main.py. Use ./updateto.sh instead."
exit 1
fi
echo "Changed files in fast scope:"
while IFS=$'\t' read -r status file_path prev_path; do
[ -z "$file_path" ] && continue
echo " - ${status}: ${file_path}"
done < "$TMP_CHANGED"
if [ "$DRY_RUN" = true ]; then
echo "Dry-run complete. No files updated."
exit 0
fi
BACKUP_ID="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="${STATE_DIR}/backups/${BACKUP_ID}"
MANIFEST="${BACKUP_DIR}/manifest.tsv"
mkdir -p "${BACKUP_DIR}/original"
while IFS=$'\t' read -r status file_path prev_path; do
[ -z "$file_path" ] && continue
mkdir -p "$(dirname "$file_path")"
existed=0
if [ -f "$file_path" ]; then
mkdir -p "${BACKUP_DIR}/original/$(dirname "$file_path")"
cp "$file_path" "${BACKUP_DIR}/original/${file_path}"
existed=1
fi
printf "%s\t%s\t%s\n" "$status" "$file_path" "$existed" >> "$MANIFEST"
file_enc="$(url_encode "$file_path")"
raw_url="${GITEA_BASE}/api/v1/repos/${REPO}/raw/${file_enc}?ref=${TARGET_ENC}"
tmp_file="${file_path}.tmp.fast"
fetch_url_to_file "$raw_url" "$tmp_file"
mv "$tmp_file" "$file_path"
done < "$TMP_CHANGED"
cat > "${BACKUP_DIR}/meta.env" <<EOF
BASE_REF=${BASE_REF}
TARGET_REF=${TARGET_REF}
BACKUP_ID=${BACKUP_ID}
EOF
echo "Building and restarting API container only"
rebuild_api_latest
if ! health_check; then
echo "Error: health check failed after fast update. Starting automatic rollback."
restore_backup "$BACKUP_ID"
echo "Rollback completed. Fast update aborted."
exit 1
fi
echo "$TARGET_REF" > "$STATE_FILE"
echo "$BACKUP_ID" > "$LAST_BACKUP_FILE"
echo "Fast update complete"
echo " backup id: $BACKUP_ID"
echo " deployed: $TARGET_REF"
echo " state file: $STATE_FILE"