feat(push): panel preferencji /konto/prywatnosc + triggery B2B (D.1)
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

Migracja 101 dodaje 8 nowych kolumn notify_push_* na users (wszystkie
default TRUE). Panel preferencji rozszerzony o kartę "Powiadomienia
push (na urządzeniu)" z 3 podsekcjami (interakcje dot. mnie, aktualności
Izby, wydarzenia) — 9 przełączników. "Nowa wiadomość prywatna" świadomie
jest w obu kartach (e-mail + push) — userzy mogą niezależnie wybrać
oba kanały.

Triggery B2B:
- zainteresowanie ogłoszeniem (ClassifiedInterest) → push do autora
  z notify_push_classified_interest
- pytanie do ogłoszenia (ClassifiedQuestion) → push do autora z
  notify_push_classified_question

Fazy D.2 (forum + broadcast) i D.3 (wydarzenia + cron) w kolejnych PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-14 17:41:06 +02:00
parent 31c8272e31
commit 46adb0ece7
5 changed files with 189 additions and 2 deletions

View File

@ -914,6 +914,18 @@ def konto_prywatnosc():
user.contact_prefer_phone = request.form.get('prefer_phone') == 'on' user.contact_prefer_phone = request.form.get('prefer_phone') == 'on'
user.contact_prefer_portal = request.form.get('prefer_portal') == 'on' user.contact_prefer_portal = request.form.get('prefer_portal') == 'on'
user.notify_email_messages = request.form.get('notify_email_messages') == 'on' user.notify_email_messages = request.form.get('notify_email_messages') == 'on'
# Web Push preferences per event type
user.notify_push_messages = request.form.get('notify_push_messages') == 'on'
user.notify_push_classified_interest = request.form.get('notify_push_classified_interest') == 'on'
user.notify_push_classified_question = request.form.get('notify_push_classified_question') == 'on'
user.notify_push_forum_reply = request.form.get('notify_push_forum_reply') == 'on'
user.notify_push_forum_quote = request.form.get('notify_push_forum_quote') == 'on'
user.notify_push_announcements = request.form.get('notify_push_announcements') == 'on'
user.notify_push_board_meetings = request.form.get('notify_push_board_meetings') == 'on'
user.notify_push_event_invites = request.form.get('notify_push_event_invites') == 'on'
user.notify_push_event_reminders = request.form.get('notify_push_event_reminders') == 'on'
db.commit() db.commit()
logger.info(f"Privacy settings updated for user: {user.email}") logger.info(f"Privacy settings updated for user: {user.email}")

View File

@ -521,6 +521,22 @@ def toggle_interest(classified_id):
except Exception as e: except Exception as e:
logger.warning(f"Failed to send classified interest notification: {e}") logger.warning(f"Failed to send classified interest notification: {e}")
# Web Push to classified author (opt-in via notify_push_classified_interest)
try:
author = db.query(User).filter(User.id == classified.author_id).first()
if author and author.notify_push_classified_interest is not False:
from blueprints.push.push_service import send_push
interested_name = current_user.name or current_user.email.split('@')[0]
send_push(
user_id=author.id,
title='Zainteresowanie Twoim ogłoszeniem',
body=f'{interested_name}: „{classified.title}"',
url=f'/tablica/{classified_id}',
tag=f'classified-interest-{classified_id}',
)
except Exception as e:
logger.warning(f"Failed to send classified interest push: {e}")
return jsonify({ return jsonify({
'success': True, 'success': True,
'interested': True, 'interested': True,
@ -612,7 +628,7 @@ def ask_question(classified_id):
classified.updated_at = datetime.now() classified.updated_at = datetime.now()
db.commit() db.commit()
# Notify classified author (in-app + email) # Notify classified author (in-app + email + push)
if classified.author_id != current_user.id: if classified.author_id != current_user.id:
questioner_name = current_user.name or current_user.email.split('@')[0] questioner_name = current_user.name or current_user.email.split('@')[0]
try: try:
@ -623,6 +639,19 @@ def ask_question(classified_id):
send_classified_question_email( send_classified_question_email(
classified_id, classified.title, questioner_name, content, classified_id, classified.title, questioner_name, content,
author.email, author.name or author.email.split('@')[0]) author.email, author.name or author.email.split('@')[0])
# Web Push (opt-in via notify_push_classified_question)
if author and author.notify_push_classified_question is not False:
from blueprints.push.push_service import send_push
import re as _re
preview_plain = _re.sub(r'<[^>]+>', '', content or '').strip()
preview = (preview_plain[:80] + '') if len(preview_plain) > 80 else preview_plain
send_push(
user_id=author.id,
title=f'Nowe pytanie: {classified.title[:60]}',
body=f'{questioner_name}: {preview}' if preview else f'{questioner_name} zadał pytanie',
url=f'/tablica/{classified_id}',
tag=f'classified-question-{classified_id}',
)
except Exception as e: except Exception as e:
logger.warning(f"Failed to send classified question notification: {e}") logger.warning(f"Failed to send classified question notification: {e}")

View File

@ -339,7 +339,17 @@ class User(Base, UserMixin):
# Email notification preferences # Email notification preferences
notify_email_messages = Column(Boolean, default=True) # Email when receiving private message 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
# Web Push notification preferences (per event type)
notify_push_messages = Column(Boolean, default=True) # Prywatna wiadomość
notify_push_classified_interest = Column(Boolean, default=True) # Zainteresowanie ogłoszeniem B2B
notify_push_classified_question = Column(Boolean, default=True) # Pytanie pod ogłoszeniem B2B
notify_push_forum_reply = Column(Boolean, default=True) # Odpowiedź w moim wątku forum
notify_push_forum_quote = Column(Boolean, default=True) # Cytat mojego wpisu forum
notify_push_announcements = Column(Boolean, default=True) # Nowa aktualność Izby
notify_push_board_meetings = Column(Boolean, default=True) # Posiedzenia Rady (utw./program/protokół)
notify_push_event_invites = Column(Boolean, default=True) # Zaproszenie na wydarzenie
notify_push_event_reminders = Column(Boolean, default=True) # Przypomnienie 24h przed wydarzeniem
# Relationships # Relationships
conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan') conversations = relationship('AIChatConversation', back_populates='user', cascade='all, delete-orphan')

View File

@ -0,0 +1,15 @@
-- Migration 101: per-event push notification preferences for users
--
-- 8 nowych kolumn notify_push_* na users (obok istniejących notify_email_messages
-- i notify_push_messages z migracji 100). Wszystkie default TRUE — nowi i istniejący
-- userzy domyślnie otrzymują wszystkie typy. Kontrola w panelu /konto/prywatnosc.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS notify_push_classified_interest BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_push_classified_question BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_push_forum_reply BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_push_forum_quote BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_push_announcements BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_push_board_meetings BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_push_event_invites BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_push_event_reminders BOOLEAN DEFAULT TRUE;

View File

@ -369,6 +369,127 @@
</div> </div>
</div> </div>
<div class="settings-card">
<h2 style="display:flex;align-items:center;gap:8px">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="overflow:visible;color:var(--primary)">
<rect x="7" y="3" width="10" height="18" rx="2"/>
<line x1="11" y1="18" x2="13" y2="18"/>
<path d="M19 8c1 1 1.5 2.5 1.5 4s-0.5 3-1.5 4" stroke-linecap="round"/>
<path d="M21 6c1.5 1.5 2.3 3.8 2.3 6s-0.8 4.5-2.3 6" stroke-linecap="round"/>
</svg>
Powiadomienia push (na urządzeniu)
</h2>
<div style="background:#eff6ff;border-left:3px solid #3b82f6;padding:12px 16px;border-radius:6px;font-size:13px;margin-bottom:var(--spacing-md);line-height:1.5;color:#1e40af">
<b>Jak to działa:</b> aby otrzymywać powiadomienia, w nagłówku strony kliknij ikonę telefonu z falami i zezwól — na każdym urządzeniu (komputer, Android, iPhone) trzeba to wykonać osobno. Poniższe przełączniki decydują, <b>za co</b> chcesz dostawać powiadomienia — niezależnie od urządzenia.
</div>
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-secondary);padding:12px 0 6px;font-weight:600">Interakcje dotyczące mnie</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Nowa wiadomość prywatna</div>
<div class="setting-description">Ktoś napisał do Ciebie w rozmowie prywatnej na portalu</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_messages" {% if user.notify_push_messages != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Zainteresowanie Twoim ogłoszeniem B2B</div>
<div class="setting-description">Ktoś kliknął „Jestem zainteresowany" przy Twoim ogłoszeniu na tablicy B2B</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_classified_interest" {% if user.notify_push_classified_interest != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Pytanie do Twojego ogłoszenia B2B</div>
<div class="setting-description">Ktoś zadał publiczne pytanie pod Twoim ogłoszeniem</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_classified_question" {% if user.notify_push_classified_question != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Odpowiedź w Twoim temacie na forum</div>
<div class="setting-description">Ktoś odpisał w wątku, który założyłeś</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_forum_reply" {% if user.notify_push_forum_reply != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Odpowiedź na Twoją wiadomość (cytat)</div>
<div class="setting-description">Ktoś cytuje lub odpowiada bezpośrednio pod Twoim wpisem na forum</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_forum_quote" {% if user.notify_push_forum_quote != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-secondary);padding:12px 0 6px;font-weight:600">Aktualności Izby</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Nowa aktualność / ogłoszenie Izby</div>
<div class="setting-description">Biuro Izby opublikowało nową aktualność dla wszystkich członków</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_announcements" {% if user.notify_push_announcements != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Posiedzenia Rady Izby</div>
<div class="setting-description">Utworzenie nowego posiedzenia, publikacja programu oraz publikacja protokołu</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_board_meetings" {% if user.notify_push_board_meetings != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div style="font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-secondary);padding:12px 0 6px;font-weight:600">Wydarzenia</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Zaproszenie na wydarzenie</div>
<div class="setting-description">Zostałeś dodany do listy uczestników nowego wydarzenia w kalendarzu Izby</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_event_invites" {% if user.notify_push_event_invites != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Przypomnienie 24h przed wydarzeniem</div>
<div class="setting-description">Automatyczne przypomnienie dzień przed wydarzeniami, na które jesteś zapisany</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_push_event_reminders" {% if user.notify_push_event_reminders != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Zapisz ustawienia</button> <button type="submit" class="btn btn-primary">Zapisz ustawienia</button>
</div> </div>