fix: harden bottom bar timers messages and access

This commit is contained in:
Christian 2026-08-17 19:34:39 +02:00
parent 88f7cec478
commit 9ac5b91f6f
7 changed files with 199 additions and 67 deletions

View File

@ -655,7 +655,7 @@ def _has_boss_access(current_user: dict) -> bool:
(int(current_user_id),),
) or []
names = [str(r.get("name") or "") for r in rows]
tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
tokens = ("admin", "manager", "leder", "chef")
return any(any(token in name for token in tokens) for name in names)

View File

@ -145,12 +145,16 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
m.message_text,
m.requires_manual_ack,
m.created_at,
m.read_at,
receipt.read_at,
receipt.acknowledged_at,
COALESCE(NULLIF(sender.full_name, ''), sender.username, ('Bruger #' || sender.user_id::text)) AS sender_name,
COALESCE(NULLIF(recipient.full_name, ''), recipient.username, ('Bruger #' || recipient.user_id::text)) AS recipient_name
FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = %s
WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS (
@ -162,21 +166,24 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
ORDER BY m.created_at DESC, m.id DESC
LIMIT %s
""",
(int(user_id), int(user_id), int(user_id), safe_limit),
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
) or []
unread_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM bottom_bar_messages
WHERE read_at IS NULL
AND sender_user_id <> %s
FROM bottom_bar_messages m
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = %s
WHERE receipt.read_at IS NULL
AND m.sender_user_id <> %s
AND (
recipient_user_id = %s
OR recipient_user_id IS NULL
m.recipient_user_id = %s
OR m.recipient_user_id IS NULL
)
""",
(int(user_id), int(user_id)),
(int(user_id), int(user_id), int(user_id)),
) or {}
unread_count = _safe_count(unread_row)
@ -197,7 +204,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("read_at") is not None,
"is_acknowledged": row.get("acknowledged_at") is not None,
}
)
@ -215,31 +222,40 @@ def mark_user_messages_read(user_id: Optional[int], partner_user_id: Optional[in
params: List[Any] = [int(user_id)]
if partner_user_id is not None and int(partner_user_id) > 0:
where_clause = """
recipient_user_id = %s
AND read_at IS NULL
AND COALESCE(requires_manual_ack, FALSE) = FALSE
AND sender_user_id <> %s
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
AND m.sender_user_id <> %s
"""
params.append(int(user_id))
where_clause += " AND sender_user_id = %s"
where_clause += " AND m.sender_user_id = %s"
params.append(int(partner_user_id))
else:
where_clause = """
(recipient_user_id = %s OR recipient_user_id IS NULL)
AND read_at IS NULL
AND COALESCE(requires_manual_ack, FALSE) = FALSE
AND sender_user_id <> %s
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
AND m.sender_user_id <> %s
"""
params.append(int(user_id))
row = execute_query_single(
f"""
UPDATE bottom_bar_messages
SET read_at = CURRENT_TIMESTAMP
WHERE {where_clause}
RETURNING COUNT(*) OVER() AS affected_count
WITH eligible AS (
SELECT m.id
FROM bottom_bar_messages m
LEFT JOIN bottom_bar_message_receipts r
ON r.message_id = m.id AND r.user_id = %s
WHERE {where_clause}
AND r.read_at IS NULL
), updated AS (
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at)
SELECT id, %s, CURRENT_TIMESTAMP FROM eligible
ON CONFLICT (message_id, user_id) DO UPDATE
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at)
RETURNING message_id
)
SELECT COUNT(*) AS affected_count FROM updated
""",
tuple(params),
tuple([int(user_id)] + params + [int(user_id)]),
) or {}
return int(row.get("affected_count") or 0)
@ -252,14 +268,18 @@ def acknowledge_message(user_id: Optional[int], message_id: int) -> bool:
ensure_bottom_bar_messages_schema()
row = execute_query_single(
"""
UPDATE bottom_bar_messages
SET read_at = CURRENT_TIMESTAMP
WHERE id = %s
AND recipient_user_id = %s
AND read_at IS NULL
RETURNING id
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at)
SELECT m.id, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM bottom_bar_messages m
WHERE m.id = %s
AND (m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND m.sender_user_id <> %s
ON CONFLICT (message_id, user_id) DO UPDATE
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at),
acknowledged_at = COALESCE(bottom_bar_message_receipts.acknowledged_at, EXCLUDED.acknowledged_at)
RETURNING message_id AS id
""",
(int(message_id), int(user_id)),
(int(user_id), int(message_id), int(user_id), int(user_id)),
) or {}
return bool(row.get("id"))
@ -333,19 +353,13 @@ def _can_view_boss_tab(user_id: Optional[int]) -> bool:
group_names = _get_user_group_names(user_id)
if not group_names:
# Fail-open for authenticated users if group mapping is missing.
return True
return False
leadership_tokens = (
"admin",
"manager",
"leder",
"chef",
"teknik",
"technician",
"support",
"drift",
"it",
)
return any(
any(token in group for token in leadership_tokens)

View File

@ -289,8 +289,10 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses))
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
params.extend(closed_statuses)
if normalized_priority:
query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
if normalized_priority == "urgent":
query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
elif normalized_priority:
query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
params.append(normalized_priority)
if customer_id_int:
query += " AND s.customer_id = %s"
@ -355,8 +357,10 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses))
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
fallback_params.extend(closed_statuses)
if normalized_priority:
fallback_query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s"
if normalized_priority == "urgent":
fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
elif normalized_priority:
fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
fallback_params.append(normalized_priority)
if customer_id_int:
fallback_query += " AND s.customer_id = %s"

View File

@ -1578,7 +1578,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/telefoni.js?v=2.4"></script>
<script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.60"></script>
<script src="/static/js/bottom-bar.js?v=2.61"></script>
<script>
// Dark Mode Toggle Logic
window.BMC_CAN_CLICK_TO_CALL = true;

View File

@ -78,6 +78,33 @@ def _resolve_target_user_id(current_user: Optional[dict], payload_user_id: Any =
return _resolve_current_user_id(current_user)
def _can_view_all_team_timers(current_user: Optional[dict]) -> bool:
"""Restrict company-wide timer visibility to explicit leadership groups."""
if not current_user:
return False
if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")):
return True
user_id = _resolve_current_user_id(current_user)
if not user_id:
return False
rows = execute_query(
"""
SELECT LOWER(COALESCE(g.name, '')) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(user_id,),
) or []
leadership_tokens = ("admin", "manager", "leder", "chef")
return any(
any(token in str(row.get("name") or "") for token in leadership_tokens)
for row in rows
)
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
@ -2800,6 +2827,8 @@ async def list_team_timer_status_v1(
bruger_id = _resolve_current_user_id(current_user)
if not bruger_id:
raise HTTPException(status_code=401, detail="Authentication required")
if scope == "all" and not _can_view_all_team_timers(current_user):
raise HTTPException(status_code=403, detail="Du har ikke rettighed til at se alle medarbejderes timere")
employee_filter = "AND t.medarbejder_id = %s" if scope == "mine" else ""
params = (bruger_id,) if scope == "mine" else ()
rows = execute_query(

View File

@ -0,0 +1,23 @@
-- Per-user delivery state for direct and broadcast bottom-bar messages.
CREATE TABLE IF NOT EXISTS bottom_bar_message_receipts (
message_id INTEGER NOT NULL REFERENCES bottom_bar_messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
read_at TIMESTAMP NULL,
acknowledged_at TIMESTAMP NULL,
PRIMARY KEY (message_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_bottom_bar_message_receipts_user_unread
ON bottom_bar_message_receipts (user_id, read_at, message_id);
-- Preserve the state of existing direct messages. Broadcast read_at cannot be
-- migrated safely because the old schema stored one shared value for everyone.
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at)
SELECT id,
recipient_user_id,
read_at,
CASE WHEN requires_manual_ack THEN read_at ELSE NULL END
FROM bottom_bar_messages
WHERE recipient_user_id IS NOT NULL
ON CONFLICT (message_id, user_id) DO NOTHING;

View File

@ -489,7 +489,9 @@
const localNotes = loadLocalNotes();
const remoteList = Array.isArray(notes.list) ? notes.list : [];
if (!notesApiUnavailable && remoteList.length > 0) {
// An empty response from a healthy API is authoritative. Falling back
// merely because the list is empty resurrects stale/deleted local notes.
if (!notesApiUnavailable) {
return target;
}
@ -768,6 +770,11 @@
document.body.classList.toggle('bottom-bar-expanded', !!expanded);
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
panel.setAttribute('aria-hidden', expanded ? 'false' : 'true');
try {
window.localStorage.setItem('bmc-bottom-bar-expanded', expanded ? '1' : '0');
} catch (_) {
// Storage may be disabled in private browsing.
}
}
function syncBossTabVisibility() {
@ -776,7 +783,11 @@
return;
}
bossBtn.classList.remove('d-none');
const canView = Boolean((((latestSections || {}).boss || {}).can_view));
bossBtn.classList.toggle('d-none', !canView);
if (!canView && activeKey === 'boss') {
activeKey = 'overview';
}
}
function getCounts(sections) {
@ -892,7 +903,7 @@
if (timer.active_count > 0) {
return (timer.list || []).map(t => {
const elapsedText = t.elapsed_hhmmss || (String(t.elapsed || 0) + 's');
return '<div class="d-flex justify-content-between align-items-center gap-3 bb-timer-case-link" data-bb-case-link="' + Number(t.sag_id || 0) + '" role="link" tabindex="0"><div class="min-w-0"><div class="fw-semibold text-truncate"><span class="bb-live-indicator"></span>' + esc(t.desc) + '</div><div class="small text-muted mt-1">Aktiv tid · <span class="font-monospace fw-semibold">' + esc(elapsedText) + '</span></div></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number(t.sag_id || 0) + '">Åbn sag</button><button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill me-1"></i>Stop</button></div></div>';
return '<div class="d-flex justify-content-between align-items-center gap-3 bb-timer-case-link" data-bb-case-link="' + Number(t.sag_id || 0) + '" role="link" tabindex="0"><div class="min-w-0"><div class="fw-semibold text-truncate"><span class="bb-live-indicator"></span>' + esc(t.desc) + '</div><div class="small text-muted mt-1">Aktiv tid · <span class="font-monospace fw-semibold" data-bb-live-elapsed="' + Number(t.id || t.time_entry_id || 0) + '">' + esc(elapsedText) + '</span></div></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number(t.sag_id || 0) + '">Åbn sag</button><button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill me-1"></i>Stop</button></div></div>';
});
}
return ['<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen aktiv timer</strong><div class="small mt-1">Brug “Skift sag” i bundlinjen for at starte arbejdet.</div></div></div>'];
@ -1490,9 +1501,9 @@
if (isOwn && state === 'pending_conversion') action = '<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '"><i class="bi bi-arrow-repeat me-1"></i>Konverter</button>';
if (isOwn && state === 'active') action = '<button class="btn btn-sm btn-danger" data-bb-stop-time="' + timeId + '"><i class="bi bi-stop-fill me-1"></i>Stop</button>';
const elapsedValue = row.elapsed_hhmmss || formatTimerSeconds(row.live_elapsed_seconds || row.elapsed_seconds || row.elapsed);
const elapsed = state === 'active' ? '<span class="font-monospace">' + escapeHtml(elapsedValue) + '</span> · ' : '';
const elapsed = state === 'active' ? '<span class="font-monospace" data-bb-live-elapsed="' + timeId + '">' + escapeHtml(elapsedValue) + '</span> · ' : '';
const employeeName = row.employee_display_name || row.medarbejder_navn || row.user_name || (isOwn ? 'Min timer' : 'Ukendt medarbejder');
return '<div class="bb-timer-work-card" data-bb-case-link="' + sagId + '"><div class="bb-timer-work-main"><span class="badge text-bg-' + label[1] + '">' + label[0] + '</span><strong class="bb-timer-work-title">' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + '</strong><span class="bb-timer-work-meta">Sag #' + sagId + '</span><span class="bb-timer-work-meta">' + elapsed + escapeHtml(employeeName) + '</span></div><div class="bb-timer-work-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' + action + '</div></div>';
return '<div class="bb-timer-work-card" data-bb-case-link="' + sagId + '" role="link" tabindex="0"><div class="bb-timer-work-main"><span class="badge text-bg-' + label[1] + '">' + label[0] + '</span><strong class="bb-timer-work-title">' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + '</strong><span class="bb-timer-work-meta">Sag #' + sagId + '</span><span class="bb-timer-work-meta">' + elapsed + escapeHtml(employeeName) + '</span></div><div class="bb-timer-work-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag" aria-label="Åbn sag #' + sagId + '"><i class="bi bi-box-arrow-up-right"></i></button>' + action + '</div></div>';
}).join('');
}
container.appendChild(list);
@ -1549,6 +1560,11 @@
this.setAttribute('aria-selected', 'true');
activeKey = this.getAttribute('data-bb-tab');
try {
window.localStorage.setItem('bmc-bottom-bar-active-tab', activeKey);
} catch (_) {
// Storage may be disabled in private browsing.
}
renderTabPanel();
const detail = byId('bbCountDetail');
@ -1636,9 +1652,13 @@
}
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
const previousTimer = (((latestSections || {}).timer || {}).active || {});
let timerIdentityChanged = false;
if (payload.event === 'timer_tick') {
const timer = payload.data || {};
timerIdentityChanged = Boolean(previousTimer.active) !== Boolean(timer.active)
|| Number(previousTimer.time_entry_id || 0) !== Number(timer.time_entry_id || 0);
latestSections.timer = latestSections.timer || {};
latestSections.timer.active = timer;
latestSections.timer.active_count = timer.active ? 1 : 0;
@ -1709,6 +1729,14 @@
updateBar(latestSections);
updateMessagesTabBadge();
updateActivityZone();
if (payload.event === 'timer_tick' && !timerIdentityChanged) {
const timer = payload.data || {};
const elapsed = timer.elapsed_hhmmss || formatTimerSeconds(timer.elapsed || 0);
document.querySelectorAll('[data-bb-live-elapsed="' + Number(timer.time_entry_id || 0) + '"]').forEach(function (node) {
node.textContent = elapsed;
});
return;
}
const focusedId = document.activeElement && document.activeElement.id;
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
@ -1771,20 +1799,28 @@
}
async function stopTimer(timeId) {
const safeTimeId = Number(timeId || 0);
if (!(safeTimeId > 0)) return {};
const response = await fetch('/api/v1/timetracking/time/stop', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_id: safeTimeId })
});
if (!response.ok) {
throw new Error(await readApiError(response, 'Kunne ikke stoppe timeren.'));
}
return response.json().catch(function () { return {}; });
}
function stopActiveTimer() {
const active = ((latestSections || {}).timer || {}).active || {};
if (!active.time_entry_id) {
return Promise.resolve();
}
return fetch('/api/v1/timetracking/time/stop', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_id: active.time_entry_id })
}).catch(function (err) {
console.warn('Failed stopping active timer', err);
});
return stopTimer(active.time_entry_id);
}
function pauseActiveTimer() {
@ -2650,7 +2686,7 @@
timerChip.addEventListener('click', function () {
const timer = (((latestSections || {}).timer || {}).active || {});
const sagId = Number(timer.sag_id || 0);
window.location.href = sagId > 0 ? ('/sag/' + sagId) : '/timetracking';
window.location.href = sagId > 0 ? ('/sag/' + sagId + '/v3') : '/timetracking';
});
}
@ -2674,6 +2710,16 @@
}
function bindDynamicActions() {
document.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const caseLink = e.target && e.target.closest('[data-bb-case-link][role="link"]');
if (!caseLink || e.target.closest('button, a, input, select, textarea')) return;
const sagId = Number(caseLink.getAttribute('data-bb-case-link') || 0);
if (sagId > 0) {
e.preventDefault();
openCaseDetail(sagId);
}
});
document.addEventListener('click', function (e) {
const target = e.target;
const timerScopeButton = target && target.closest('[data-bb-timer-scope]');
@ -2993,16 +3039,13 @@
const stopTimeId = Number(btn.getAttribute('data-bb-stop-time') || 0);
if (stopTimeId > 0) {
fetch('/api/v1/timetracking/time/stop', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_id: stopTimeId })
})
btn.disabled = true;
stopTimer(stopTimeId)
.then(fetchBottomBarState)
.then(applyState)
.catch(function (err) {
console.warn('Failed stopping timer from list', err);
btn.disabled = false;
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke stoppe timeren.'));
});
return;
}
@ -3218,7 +3261,7 @@
const taskTitle = task.title || 'Ingen opgave fundet';
const caseId = task.case_id || '-';
const freeMins = data && data.free_time_calculated ? data.free_time_calculated : 0;
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + taskTitle + ' (Sag #' + caseId + ') <span class="badge bg-light text-dark ms-2">' + freeMins + 'm fri</span>';
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + escapeHtml(taskTitle) + ' (Sag #' + escapeHtml(caseId) + ') <span class="badge bg-light text-dark ms-2">' + escapeHtml(freeMins) + 'm fri</span>';
btn.classList.add('btn-success');
btn.classList.remove('btn-primary');
})
@ -3346,7 +3389,20 @@
}
document.addEventListener('DOMContentLoaded', function () {
activeKey = 'overview'; // Default overview state
try {
activeKey = window.localStorage.getItem('bmc-bottom-bar-active-tab') || 'overview';
} catch (_) {
activeKey = 'overview';
}
const allowedTabs = ['overview', 'timer', 'messages', 'tasks', 'notes', 'boss'];
if (allowedTabs.indexOf(activeKey) === -1) activeKey = 'overview';
const selectedTab = document.querySelector('.bb-tab-btn[data-bb-tab="' + activeKey + '"]');
document.querySelectorAll('.bb-tab-btn').forEach(function (button) {
const selected = button === selectedTab;
button.classList.toggle('is-active', selected);
button.setAttribute('aria-selected', selected ? 'true' : 'false');
});
bindChipClicks();
bindChipHoverPreview();
bindSheetToggle();
@ -3355,6 +3411,12 @@
bindSideTabs();
try {
setExpanded(window.localStorage.getItem('bmc-bottom-bar-expanded') === '1');
} catch (_) {
setExpanded(false);
}
startPollingFallback();
connectRealtime();
});