feat(push): Web Push (VAPID + pywebpush) dla prywatnych wiadomości
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Pierwsza iteracja — trigger to nowa wiadomość prywatna. Rollout
fazowany przez PUSH_USER_WHITELIST w .env: pusta = wszyscy, lista
user_id = tylko wymienieni. Ta sama flaga kontroluje widoczność
dzwonka w navbarze (context_processor inject_push_visibility).

Co jest:
- database/migrations/100 — push_subscriptions + notify_push_messages
- database.py — PushSubscription model + relacja na User
- blueprints/push/ — vapid-public-key, subscribe, unsubscribe, test,
  pending-url (iOS PWA), CSRF exempt, auto-prune martwych (410/404/403)
- static/sw.js — push + notificationclick (z iOS fallback przez
  /push/pending-url w Redis, TTL 5 min)
- static/js/push-client.js — togglePush, iOS detection, ?pushdiag=1
- base.html — dzwonek + wpięcie skryptu gated przez push_bell_visible
- message_routes.py — _send_message_push_notifications po emailach
- requirements.txt — pywebpush==2.0.3

Kill switch: PUSH_KILL_SWITCH=1 zatrzymuje wszystkie wysyłki.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-14 16:56:49 +02:00
parent 5583c624cd
commit 6c4db17807
12 changed files with 735 additions and 2 deletions

18
app.py
View File

@ -354,6 +354,24 @@ def inject_audit_access():
return dict(is_audit_owner=is_audit_owner())
@app.context_processor
def inject_push_visibility():
"""Udostępnij szablonom informację, czy dzwonek Web Push ma być widoczny
dla bieżącego użytkownika. Reguła: jeśli PUSH_USER_WHITELIST jest niepusty,
to tylko wymienieni user_id widzą dzwonek. Pusty = wszyscy zalogowani.
"""
if not current_user.is_authenticated:
return {'push_bell_visible': False}
raw = os.getenv('PUSH_USER_WHITELIST', '').strip()
if not raw:
return {'push_bell_visible': True}
try:
whitelist = {int(x) for x in raw.split(',') if x.strip().isdigit()}
return {'push_bell_visible': current_user.id in whitelist}
except Exception:
return {'push_bell_visible': False}
@app.context_processor
def inject_company_context():
"""Inject multi-company context into all templates."""

View File

@ -39,6 +39,18 @@ def register_blueprints(app):
except Exception as e:
logger.error(f"Error registering api blueprint: {e}")
# Push blueprint (Web Push VAPID)
try:
from blueprints.push import bp as push_bp
from blueprints.push.routes import exempt_from_csrf as push_exempt
app.register_blueprint(push_bp)
push_exempt(app)
logger.info("Registered blueprint: push (with CSRF exemption)")
except ImportError as e:
logger.debug(f"Blueprint push not yet available: {e}")
except Exception as e:
logger.error(f"Error registering push blueprint: {e}")
# Community blueprints - register directly (not nested)
# to preserve endpoint names like 'calendar_index' instead of 'community.calendar.calendar_index'
try:

View File

@ -264,6 +264,9 @@ def send_message(conv_id):
# Send email notifications
_send_message_email_notifications(db, conv_id, conversation, message, content)
# Send Web Push notifications
_send_message_push_notifications(db, conv_id, conversation, message, content)
return jsonify(msg_json), 201
except Exception as e:
@ -318,6 +321,44 @@ def _send_message_email_notifications(db, conv_id, conversation, message, conten
logger.error(f"Email notification error: {e}")
def _send_message_push_notifications(db, conv_id, conversation, message, content):
"""Send Web Push notifications to eligible conversation members.
Gate whitelist (PUSH_USER_WHITELIST) siedzi w send_push() tu filtrujemy
tylko członków konwersacji + muted + notify_push_messages.
"""
try:
from blueprints.push.push_service import send_push
members = db.query(ConversationMember).filter(
ConversationMember.conversation_id == conv_id,
ConversationMember.user_id != current_user.id,
ConversationMember.is_muted == False, # noqa: E712
).all()
sender_name = current_user.name or current_user.email.split('@')[0]
title = f'Nowa wiadomość od {sender_name}'
plain = strip_html(content or '')
preview = (plain[:80] + '') if len(plain) > 80 else plain
for m in members:
user = db.query(User).get(m.user_id)
if not user or user.notify_push_messages is False:
continue
send_push(
user_id=user.id,
title=title,
body=preview or '(załącznik)',
url=f'/wiadomosci?conv={conv_id}',
tag=f'conv-{conv_id}',
icon='/static/img/favicon-192.png',
)
except ImportError:
logger.warning("push_service not available, skipping push notifications")
except Exception as e:
logger.error(f"Push notification error: {e}")
# ============================================================
# 3. EDIT MESSAGE
# ============================================================

View File

@ -0,0 +1,10 @@
"""Web Push blueprint — VAPID + pywebpush.
Endpoints pod /push/*. Szczegóły w routes.py i push_service.py.
"""
from flask import Blueprint
bp = Blueprint('push', __name__, url_prefix='/push')
from . import routes # noqa: E402, F401

View File

@ -0,0 +1,184 @@
"""Web Push service — send_push() oraz obsługa whitelist / kill switch.
send_push() nigdy nie rzuca w górę wywołujące (trigger wiadomości) nie może
awaritu z powodu błędu pusha. Wszystkie błędy logowane, martwe subskrypcje
automatycznie kasowane (410/404/403).
"""
import base64
import json
import logging
import os
from datetime import datetime
from database import PushSubscription, SessionLocal
logger = logging.getLogger(__name__)
def _vapid_claims_email():
return 'mailto:' + os.getenv('VAPID_CONTACT_EMAIL', 'noreply@nordabiznes.pl')
def _vapid_private_key():
"""Return VAPID private key jako PEM (preferowane przez pywebpush) albo base64url raw scalar."""
raw = os.getenv('VAPID_PRIVATE_KEY', '').strip()
return raw or None
def _vapid_public_key():
return os.getenv('VAPID_PUBLIC_KEY', '').strip() or None
def _kill_switch_enabled():
return os.getenv('PUSH_KILL_SWITCH', '').strip() in ('1', 'true', 'True', 'yes')
def _parse_whitelist():
raw = os.getenv('PUSH_USER_WHITELIST', '').strip()
if not raw:
return set()
return {int(x) for x in raw.split(',') if x.strip().isdigit()}
def _user_allowed(user_id: int) -> bool:
whitelist = _parse_whitelist()
if not whitelist:
return True
return user_id in whitelist
def send_push(user_id: int, title: str, body: str,
url: str = '/',
icon: str = '/static/img/favicon-192.png',
tag: str | None = None,
data: dict | None = None) -> dict:
"""Wyślij Web Push do wszystkich subskrypcji usera.
Zwraca dict {'sent': int, 'failed': int, 'pruned': int}.
Nigdy nie rzuca wyjątku.
"""
stats = {'sent': 0, 'failed': 0, 'pruned': 0}
if _kill_switch_enabled():
logger.info("push kill switch ON, skipping user=%s", user_id)
return stats
if not _user_allowed(user_id):
return stats # cichy opt-out — faza B: whitelist blokuje resztę userów
private_key = _vapid_private_key()
if not private_key:
logger.warning("VAPID_PRIVATE_KEY not configured, skipping push")
return stats
try:
from pywebpush import webpush, WebPushException
except ImportError:
logger.warning("pywebpush not installed, skipping push")
return stats
payload = {
'title': title,
'body': body,
'url': url,
'icon': icon,
}
if tag:
payload['tag'] = tag
if data:
payload['data'] = data
db = SessionLocal()
try:
subs = db.query(PushSubscription).filter_by(user_id=user_id).all()
if not subs:
return stats
to_prune = []
for sub in subs:
try:
webpush(
subscription_info={
'endpoint': sub.endpoint,
'keys': {'p256dh': sub.p256dh, 'auth': sub.auth},
},
data=json.dumps(payload, ensure_ascii=False),
vapid_private_key=private_key,
vapid_claims={'sub': _vapid_claims_email()},
timeout=10,
)
sub.last_used_at = datetime.now()
stats['sent'] += 1
except WebPushException as e:
status = getattr(e.response, 'status_code', None) if getattr(e, 'response', None) else None
msg = str(e)
if status in (404, 410) or 'InvalidRegistration' in msg or 'expired' in msg.lower():
to_prune.append(sub)
stats['pruned'] += 1
elif status == 403:
logger.warning("push VAPID rejected (403) sub=%s, pruning", sub.id)
to_prune.append(sub)
stats['pruned'] += 1
elif status == 413:
logger.warning("push payload too large sub=%s", sub.id)
stats['failed'] += 1
else:
logger.warning("push transient error sub=%s status=%s err=%s", sub.id, status, msg[:200])
stats['failed'] += 1
except Exception as e:
logger.warning("push unexpected error sub=%s err=%s", sub.id, str(e)[:200])
stats['failed'] += 1
for sub in to_prune:
try:
db.delete(sub)
except Exception:
pass
db.commit()
except Exception as e:
logger.error("push service db error: %s", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
if stats['sent'] or stats['failed'] or stats['pruned']:
logger.info("push sent user=%s sent=%d failed=%d pruned=%d",
user_id, stats['sent'], stats['failed'], stats['pruned'])
return stats
# === iOS pending URL helper (Redis-backed, TTL 5 min) ===
def set_pending_url(user_id: int, url: str) -> bool:
try:
from redis_service import get_redis, is_available
if not is_available():
return False
r = get_redis()
r.setex(f'push:pending_url:{user_id}', 300, url)
return True
except Exception as e:
logger.debug("pending_url set failed: %s", e)
return False
def pop_pending_url(user_id: int) -> str | None:
try:
from redis_service import get_redis, is_available
if not is_available():
return None
r = get_redis()
key = f'push:pending_url:{user_id}'
val = r.get(key)
if val:
r.delete(key)
return val.decode() if isinstance(val, (bytes, bytearray)) else val
return None
except Exception as e:
logger.debug("pending_url pop failed: %s", e)
return None

153
blueprints/push/routes.py Normal file
View File

@ -0,0 +1,153 @@
"""Web Push HTTP endpoints.
GET /push/vapid-public-key publiczny klucz VAPID dla klienta JS
POST /push/subscribe zapisz subskrypcję w DB
POST /push/unsubscribe usuń subskrypcję
POST /push/test self-push (test własnego subskrybenta)
POST /push/pending-url (iOS PWA) zapis URL z notificationclick
GET /push/pending-url odczytaj i wyczyść pending URL
"""
import logging
from datetime import datetime
from flask import jsonify, request
from flask_login import login_required, current_user
from database import SessionLocal, PushSubscription
from extensions import limiter
from . import bp
from .push_service import (
send_push,
set_pending_url,
pop_pending_url,
_vapid_public_key,
)
logger = logging.getLogger(__name__)
def exempt_from_csrf(app):
"""Exempt push routes from CSRF protection (JS fetch from SW + frontend)."""
csrf = app.extensions.get('csrf')
if csrf:
csrf.exempt(bp)
@bp.route('/vapid-public-key', methods=['GET'])
@login_required
@limiter.exempt
def vapid_public_key():
key = _vapid_public_key()
if not key:
return jsonify({'error': 'VAPID not configured'}), 503
return jsonify({'key': key})
@bp.route('/subscribe', methods=['POST'])
@login_required
@limiter.limit('30 per minute')
def subscribe():
payload = request.get_json(silent=True) or {}
endpoint = (payload.get('endpoint') or '').strip()
keys = payload.get('keys') or {}
p256dh = (keys.get('p256dh') or '').strip()
auth = (keys.get('auth') or '').strip()
if not endpoint or not p256dh or not auth:
return jsonify({'error': 'endpoint, p256dh i auth wymagane'}), 400
user_agent = (request.headers.get('User-Agent') or '')[:500]
db = SessionLocal()
try:
existing = db.query(PushSubscription).filter_by(endpoint=endpoint).first()
if existing:
existing.user_id = current_user.id
existing.p256dh = p256dh
existing.auth = auth
existing.user_agent = user_agent
existing.last_used_at = datetime.now()
else:
sub = PushSubscription(
user_id=current_user.id,
endpoint=endpoint,
p256dh=p256dh,
auth=auth,
user_agent=user_agent,
)
db.add(sub)
db.commit()
return jsonify({'ok': True})
except Exception as e:
db.rollback()
logger.error("push subscribe error: %s", e)
return jsonify({'error': 'Błąd zapisu subskrypcji'}), 500
finally:
db.close()
@bp.route('/unsubscribe', methods=['POST'])
@login_required
@limiter.limit('30 per minute')
def unsubscribe():
payload = request.get_json(silent=True) or {}
endpoint = (payload.get('endpoint') or '').strip()
if not endpoint:
return jsonify({'error': 'endpoint wymagany'}), 400
db = SessionLocal()
try:
sub = db.query(PushSubscription).filter_by(
endpoint=endpoint, user_id=current_user.id
).first()
if sub:
db.delete(sub)
db.commit()
return jsonify({'ok': True})
except Exception as e:
db.rollback()
logger.error("push unsubscribe error: %s", e)
return jsonify({'error': 'Błąd'}), 500
finally:
db.close()
@bp.route('/test', methods=['POST'])
@login_required
@limiter.limit('5 per minute')
def test_push():
stats = send_push(
user_id=current_user.id,
title='🔔 Powiadomienia działają',
body='To testowe powiadomienie. Zobaczysz takie okienko kiedy ktoś napisze do Ciebie wiadomość.',
url='/wiadomosci',
tag='push-test',
)
return jsonify({'ok': True, **stats})
@bp.route('/pending-url', methods=['POST'])
@login_required
@limiter.limit('60 per minute')
def pending_url_set():
payload = request.get_json(silent=True) or {}
url = (payload.get('url') or '').strip()
if not url or not url.startswith('/'):
return jsonify({'error': 'URL must be same-origin path'}), 400
set_pending_url(current_user.id, url)
return jsonify({'ok': True})
@bp.route('/pending-url', methods=['GET'])
@login_required
@limiter.exempt
def pending_url_get():
url = pop_pending_url(current_user.id)
return jsonify({'url': url})
@bp.route('/pending-url/clear', methods=['POST'])
@login_required
@limiter.exempt
def pending_url_clear():
pop_pending_url(current_user.id)
return jsonify({'ok': True})

View File

@ -339,12 +339,14 @@ class User(Base, UserMixin):
# Email notification preferences
notify_email_messages = Column(Boolean, default=True) # Email when receiving private message
notify_push_messages = Column(Boolean, default=True) # Web Push when receiving private message
# Relationships
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')
forum_topics = relationship('ForumTopic', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumTopic.author_id')
forum_replies = relationship('ForumReply', back_populates='author', cascade='all, delete-orphan', primaryjoin='User.id == ForumReply.author_id')
forum_subscriptions = relationship('ForumTopicSubscription', back_populates='user', cascade='all, delete-orphan')
push_subscriptions = relationship('PushSubscription', back_populates='user', cascade='all, delete-orphan')
# === ROLE SYSTEM HELPER METHODS ===
@ -595,6 +597,29 @@ class User(Base, UserMixin):
return f'<User {self.email} role={self.role}>'
class PushSubscription(Base):
"""Web Push subscription per user device (desktop, mobile browser, PWA iOS).
Jeden user może mieć wiele subskrypcji po jednej na każde urządzenie/przeglądarkę.
Endpoint unique powtórny subscribe z tej samej przeglądarki aktualizuje istniejący rekord.
"""
__tablename__ = 'push_subscriptions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
endpoint = Column(Text, nullable=False, unique=True)
p256dh = Column(String(255), nullable=False)
auth = Column(String(255), nullable=False)
user_agent = Column(String(500))
created_at = Column(DateTime, default=datetime.now)
last_used_at = Column(DateTime)
user = relationship('User', back_populates='push_subscriptions')
def __repr__(self):
return f'<PushSubscription user={self.user_id} id={self.id}>'
class UserCompanyPermissions(Base):
"""
Delegated permissions for company employees.

View File

@ -0,0 +1,22 @@
-- Migration 100: Web Push subscriptions + user notification flag
--
-- Tabela push_subscriptions: wielokrotne subskrypcje per user (desktop + mobile + PWA iOS).
-- Kolumna users.notify_push_messages analogiczna do notify_email_messages.
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh VARCHAR(255) NOT NULL,
auth VARCHAR(255) NOT NULL,
user_agent VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_push_messages BOOLEAN DEFAULT TRUE;
GRANT ALL ON TABLE push_subscriptions TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE push_subscriptions_id_seq TO nordabiz_app;

View File

@ -50,6 +50,9 @@ geoip2==5.2.0
# Redis
redis==7.1.0
# Web Push (VAPID)
pywebpush==2.0.3
# 2FA
pyotp==2.9.0

195
static/js/push-client.js Normal file
View File

@ -0,0 +1,195 @@
// push-client.js — Web Push subscription flow dla Norda Biznes
//
// Rejestruje subskrypcję pod /push/subscribe, obsługuje dzwonek w navbarze,
// wykrywa iOS PWA, obsługuje diagnostykę ?pushdiag=1, pending-url dla iOS.
(function() {
'use strict';
function log(msg, data) {
try { console.log('[push] ' + msg, data !== undefined ? data : ''); } catch (e) {}
}
function detectIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
function isStandalone() {
return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
window.navigator.standalone === true;
}
function supportsPush() {
return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window);
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const output = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i);
return output;
}
function getBell() { return document.getElementById('pushBellBtn'); }
function setBellState(state) {
const bell = getBell();
if (!bell) return;
bell.dataset.state = state;
const slash = bell.querySelector('.push-disabled-slash');
if (state === 'enabled') {
bell.title = 'Powiadomienia włączone — kliknij, żeby wyłączyć';
if (slash) slash.style.display = 'none';
bell.style.opacity = '1';
} else if (state === 'disabled') {
bell.title = 'Włącz powiadomienia';
if (slash) slash.style.display = '';
bell.style.opacity = '0.55';
} else if (state === 'blocked') {
bell.title = 'Powiadomienia zablokowane w przeglądarce. Zmień w ustawieniach strony.';
if (slash) slash.style.display = '';
bell.style.opacity = '0.4';
} else if (state === 'unsupported') {
bell.title = 'Przeglądarka nie obsługuje powiadomień';
bell.style.opacity = '0.3';
}
}
async function getSubscription() {
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.getSubscription();
}
async function enablePush() {
if (detectIOS() && !isStandalone()) {
alert('Na iPhone powiadomienia działają tylko po dodaniu strony do ekranu początkowego:\n\n1) Dotknij Udostępnij (strzałka na dole)\n2) Wybierz „Do ekranu początkowego"\n3) Uruchom aplikację z ikonki na pulpicie\n4) Zaloguj się i kliknij ponownie dzwoneczek');
return;
}
if (!supportsPush()) {
alert('Twoja przeglądarka nie obsługuje powiadomień.');
setBellState('unsupported');
return;
}
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
setBellState(perm === 'denied' ? 'blocked' : 'disabled');
return;
}
try {
const keyResp = await fetch('/push/vapid-public-key', { credentials: 'include' });
if (!keyResp.ok) throw new Error('vapid-public-key HTTP ' + keyResp.status);
const { key } = await keyResp.json();
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
});
}
const subObj = sub.toJSON();
const resp = await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subObj.endpoint,
keys: subObj.keys,
}),
credentials: 'include',
});
if (!resp.ok) throw new Error('subscribe HTTP ' + resp.status);
setBellState('enabled');
// Welcome push — test że wszystko działa
await fetch('/push/test', { method: 'POST', credentials: 'include' });
} catch (e) {
log('enable error', e);
alert('Nie udało się włączyć powiadomień: ' + (e.message || e));
setBellState('disabled');
}
}
async function disablePush() {
try {
const sub = await getSubscription();
if (sub) {
const endpoint = sub.endpoint;
await sub.unsubscribe();
await fetch('/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
credentials: 'include',
});
}
setBellState('disabled');
} catch (e) {
log('disable error', e);
}
}
window.togglePush = async function(event) {
if (event) event.preventDefault();
const bell = getBell();
if (!bell) return;
const state = bell.dataset.state || 'disabled';
if (state === 'enabled') {
await disablePush();
} else {
await enablePush();
}
};
async function initPushState() {
const bell = getBell();
if (!bell) return;
if (!supportsPush()) {
// iOS Safari bez PWA — przeglądarka w ogóle bez PushManager.
// Dzwonek nadal klikalny, zobaczy komunikat o PWA.
if (detectIOS() && !isStandalone()) {
setBellState('disabled');
return;
}
setBellState('unsupported');
return;
}
if (Notification.permission === 'denied') {
setBellState('blocked');
return;
}
try {
const sub = await getSubscription();
setBellState(sub && Notification.permission === 'granted' ? 'enabled' : 'disabled');
} catch (e) {
setBellState('disabled');
}
}
async function checkPendingUrl() {
// iOS PWA: jeśli otworzono PWA przez klik w powiadomienie, SW mógł zapisać URL
if (!isStandalone()) return;
try {
const resp = await fetch('/push/pending-url', { credentials: 'include' });
if (!resp.ok) return;
const { url } = await resp.json();
if (url && url !== window.location.pathname + window.location.search) {
window.location.href = url;
}
} catch (e) { /* ignore */ }
}
document.addEventListener('DOMContentLoaded', function() {
initPushState();
checkPendingUrl();
if (/[?&]pushdiag=1/.test(window.location.search)) {
log('diagnostics', {
supportsPush: supportsPush(),
iOS: detectIOS(),
standalone: isStandalone(),
permission: (window.Notification && Notification.permission) || 'n/a',
userAgent: navigator.userAgent,
});
}
});
})();

View File

@ -1,5 +1,4 @@
// Norda Biznes Partner — minimal service worker for PWA installability
// No offline caching — just enough for Chrome to offer install prompt
// Norda Biznes Partner — Service Worker with Web Push + iOS pending-url
self.addEventListener('install', function() {
self.skipWaiting();
@ -12,3 +11,54 @@ self.addEventListener('activate', function(event) {
self.addEventListener('fetch', function(event) {
event.respondWith(fetch(event.request));
});
self.addEventListener('push', function(event) {
let payload = {
title: 'Norda Biznes',
body: '',
url: '/',
icon: '/static/img/favicon-192.png',
badge: '/static/img/favicon-192.png',
};
try {
if (event.data) payload = Object.assign(payload, event.data.json());
} catch (e) {
if (event.data) payload.body = event.data.text();
}
const options = {
body: payload.body,
icon: payload.icon,
badge: payload.badge || '/static/img/favicon-192.png',
data: { url: payload.url },
tag: payload.tag || undefined,
renotify: !!payload.tag,
};
event.waitUntil(self.registration.showNotification(payload.title, options));
});
self.addEventListener('notificationclick', function(event) {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil((async function() {
const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of clientList) {
if (client.url.indexOf(targetUrl) !== -1 && 'focus' in client) {
return client.focus();
}
}
// iOS PWA fallback — gdy app zamknięta, zapisz pending URL w Redis
// żeby PWA po starcie mogła pod niego przeskoczyć.
try {
await fetch('/push/pending-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: targetUrl }),
credentials: 'include',
});
} catch (e) { /* ignore */ }
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})());
});

View File

@ -174,6 +174,21 @@
</div>
</li>
{% if push_bell_visible %}
<!-- Web Push toggle -->
<li class="push-toggle">
<button id="pushBellBtn" class="nav-link-with-badge" onclick="togglePush(event)"
aria-label="Powiadomienia push" title="Włącz powiadomienia push" data-state="disabled"
style="opacity:0.55">
<svg class="notifications-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="overflow:visible">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 5a2 2 0 114 0v.341C16.67 6.165 18 8.388 18 11v3.159c0 .538.214 1.055.595 1.436L20 17H4l1.405-1.405A2.032 2.032 0 006 14.159V11c0-2.612 1.33-4.835 4-5.659zM9 17v1a3 3 0 006 0v-1"/>
<line class="push-disabled-slash" x1="4" y1="20" x2="20" y2="4" stroke-width="2"/>
</svg>
</button>
</li>
{% endif %}
<!-- User Menu -->
<li class="user-dropdown">
<button class="user-trigger" onclick="toggleUserMenu(event)">
@ -1163,6 +1178,11 @@
<!-- Scroll Animations (Sprint 4) -->
<script src="{{ url_for('static', filename='js/scroll-animations.js') }}" defer></script>
{% if push_bell_visible %}
<!-- Web Push client -->
<script src="{{ url_for('static', filename='js/push-client.js') }}" defer></script>
{% endif %}
{% block extra_scripts %}{% endblock %}
{% if is_staging %}