feat(notifications): D.2+D.3 — forum, broadcasty Izby, wydarzenia, cron 24h
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

Rozszerzenie powiadomień o kolejne typy zdarzeń, z symetrycznymi togglami
e-mail i push w /konto/prywatnosc.

Migracje 103 + 104 — 6 nowych kolumn preferencji e-mail + NordaEvent.reminder_24h_sent_at.

Triggery:
- Forum odpowiedź → push do autora wątku (notify_push_forum_reply)
- Forum cytat (> **Imię** napisał(a):) → push + email do cytowanego
  (notify_push/email_forum_quote)
- Admin publikuje aktualność → broadcast push (ON) + email (OFF)
  do aktywnych członków (notify_push/email_announcements)
- Board: utworzenie / publikacja programu / publikacja protokołu
  → broadcast push + opt-in email (notify_push/email_board_meetings)
- Nowe wydarzenie w kalendarzu → broadcast push + email (oba ON)
  (notify_push/email_event_invites)
- Cron scripts/event_reminders_cron.py co godzinę — wydarzenia za 23-25h,
  dla zapisanych (EventAttendee.status != 'declined') push + email,
  znacznik NordaEvent.reminder_24h_sent_at żeby nie dublować.

Email defaults dobrane, by nie zalać inbox: broadcast OFF (announcements,
board, forum_reply), personalne/actionable ON (forum_quote, event_invites,
event_reminders).

Wszystkie nowe e-maile mają jednym-kliknięciem unsubscribe (RFC 8058
+ link w stopce) — unsubscribe_tokens.py rozszerzony o nowe typy.

Cron entry do dodania na prod (osobny krok, bo to edycja crontaba):
  0 * * * * cd /var/www/nordabiznes && venv/bin/python3 scripts/event_reminders_cron.py

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-04-14 18:20:38 +02:00
parent 425f38d0fd
commit c9985ba51a
11 changed files with 591 additions and 6 deletions

View File

@ -33,6 +33,72 @@ import gemini_service
# Logger
logger = logging.getLogger(__name__)
def _broadcast_event_created(db, event):
"""Push + opt-in email broadcast gdy admin utworzył nowe wydarzenie w kalendarzu Izby."""
try:
from blueprints.push.push_service import send_push
from email_service import send_email, _email_v3_wrap
except ImportError:
return 0
url_path = f'/kalendarz/{event.id}'
title = 'Nowe wydarzenie w kalendarzu Izby'
date_str = event.event_date.strftime('%d.%m.%Y') if event.event_date else ''
time_str = str(event.time_start)[:5] if event.time_start else ''
body = f'{event.title} · {date_str}' + (f' {time_str}' if time_str else '')
try:
users = db.query(User).filter(User.is_active == True).all()
except Exception as e:
logger.warning(f"event broadcast: fetch users failed: {e}")
return 0
publisher_id = getattr(current_user, 'id', None)
push_sent = 0
for u in users:
if publisher_id and u.id == publisher_id:
continue
try:
if getattr(u, 'notify_push_event_invites', True) is not False:
send_push(
user_id=u.id,
title=title,
body=body,
url=url_path,
tag=f'event-{event.id}',
)
push_sent += 1
except Exception as e:
logger.debug(f"event push err user={u.id}: {e}")
try:
if u.email and getattr(u, 'notify_email_event_invites', False) is True:
subject = f"Nowe wydarzenie: {event.title[:60]}"
body_text = f"{title}\n\n{event.title}\nTermin: {date_str} {time_str}\nMiejsce: {event.location or '-'}\n\nZobacz: https://nordabiznes.pl{url_path}"
content = (
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{u.name or u.email}</strong>!</p>'
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">{title}:</p>'
f'<h3 style="color:#1e3a8a;">{event.title}</h3>'
f'<p style="color:#475569;line-height:1.6">Termin: <b>{date_str} {time_str}</b><br>'
f'Miejsce: {event.location or "-"}</p>'
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zapisz się / szczegóły</a></p>'
)
body_html = _email_v3_wrap(title, 'Norda Biznes Partner', content)
send_email(
to=[u.email],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='event_broadcast',
user_id=u.id,
recipient_name=u.name,
notification_type='event_invites',
)
except Exception as e:
logger.debug(f"event email err user={u.id}: {e}")
return push_sent
# Polish month names for fees
MONTHS_PL = [
(1, 'Styczen'), (2, 'Luty'), (3, 'Marzec'), (4, 'Kwiecien'),
@ -1335,6 +1401,11 @@ def admin_calendar_new():
db.add(event)
db.commit()
try:
_broadcast_event_created(db, event)
except Exception as e:
logger.warning(f"event broadcast failed: {e}")
flash('Wydarzenie zostało dodane.', 'success')
return redirect(url_for('.admin_calendar'))
except Exception as e:

View File

@ -13,13 +13,86 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, Announcement, SystemRole
from database import SessionLocal, Announcement, SystemRole, User
from utils.decorators import role_required
from utils.helpers import sanitize_html
logger = logging.getLogger(__name__)
def _broadcast_announcement(db, announcement):
"""Broadcast push (+ optional e-mail) do aktywnych użytkowników.
Każdy user jest filtrowany po własnych flagach:
- notify_push_announcements (default TRUE push)
- notify_email_announcements (default FALSE e-mail, opt-in)
Autor (publishing admin) nie dostaje powiadomienia o sobie.
"""
push_sent = 0
try:
from blueprints.push.push_service import send_push
from email_service import send_email, _email_v3_wrap
except ImportError:
return 0
url_path = f'/aktualnosci/{announcement.id}' if hasattr(announcement, 'slug') else f'/aktualnosci/{announcement.id}'
title = announcement.title or 'Nowa aktualność'
excerpt = (announcement.excerpt or '')[:160] if hasattr(announcement, 'excerpt') else ''
try:
users = db.query(User).filter(User.is_active == True).all()
except Exception as e:
logger.warning(f"broadcast: fetch users failed: {e}")
return 0
publisher_id = getattr(current_user, 'id', None)
for u in users:
if publisher_id and u.id == publisher_id:
continue
# Push
try:
if getattr(u, 'notify_push_announcements', True) is not False:
send_push(
user_id=u.id,
title=f'Nowa aktualność Izby',
body=title[:120] if title else 'Opublikowano nową aktualność',
url=url_path,
tag=f'announcement-{announcement.id}',
)
push_sent += 1
except Exception as e:
logger.debug(f"broadcast push err user={u.id}: {e}")
# Email (opt-in)
try:
if u.email and getattr(u, 'notify_email_announcements', False) is True:
subject = f"Aktualność Izby: {title[:60]}"
body_text = f"Nowa aktualność na portalu:\n\n{title}\n\n{excerpt}\n\nZobacz: https://nordabiznes.pl{url_path}"
content = (
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{u.name or u.email}</strong>!</p>'
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">Biuro Izby opublikowało nową aktualność:</p>'
f'<h3 style="color:#1e3a8a;">{title}</h3>'
f'<p style="color:#475569;line-height:1.6">{excerpt}</p>'
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zobacz aktualność</a></p>'
)
body_html = _email_v3_wrap('Nowa aktualność Izby', 'Norda Biznes Partner', content)
send_email(
to=[u.email],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='announcement_broadcast',
user_id=u.id,
recipient_name=u.name,
notification_type='announcements',
)
except Exception as e:
logger.debug(f"broadcast email err user={u.id}: {e}")
return push_sent
def generate_slug(title):
"""
Generate URL-friendly slug from title.
@ -275,8 +348,13 @@ def admin_announcements_publish(id):
title=announcement.title,
category=announcement.category
)
logger.info(f"Sent {notify_count} notifications for announcement: {announcement.title}")
return jsonify({'success': True, 'message': f'Ogłoszenie zostało opublikowane. Wysłano {notify_count} powiadomień.'})
logger.info(f"Sent {notify_count} in-app notifications for announcement: {announcement.title}")
# Broadcast push/e-mail do aktywnych użytkowników (opt-in per flaga)
push_count = _broadcast_announcement(db, announcement)
logger.info(f"Sent {push_count} push notifications for announcement: {announcement.title}")
return jsonify({'success': True, 'message': f'Ogłoszenie zostało opublikowane. Wysłano {notify_count} powiadomień w portalu, {push_count} na urządzenia.'})
except ImportError:
return jsonify({'success': True, 'message': 'Ogłoszenie zostało opublikowane.'})

View File

@ -917,6 +917,12 @@ def konto_prywatnosc():
user.notify_email_classified_question = request.form.get('notify_email_classified_question') == 'on'
user.notify_email_classified_answer = request.form.get('notify_email_classified_answer') == 'on'
user.notify_email_classified_expiry = request.form.get('notify_email_classified_expiry') == 'on'
user.notify_email_forum_reply = request.form.get('notify_email_forum_reply') == 'on'
user.notify_email_forum_quote = request.form.get('notify_email_forum_quote') == 'on'
user.notify_email_announcements = request.form.get('notify_email_announcements') == 'on'
user.notify_email_board_meetings = request.form.get('notify_email_board_meetings') == 'on'
user.notify_email_event_invites = request.form.get('notify_email_event_invites') == 'on'
user.notify_email_event_reminders = request.form.get('notify_email_event_reminders') == 'on'
# Web Push preferences per event type
user.notify_push_messages = request.form.get('notify_push_messages') == 'on'

View File

@ -277,6 +277,11 @@ def meeting_publish_agenda(meeting_id):
meeting.updated_at = datetime.now()
db.commit()
try:
_broadcast_board_meeting(db, meeting, kind='agenda')
except Exception as e:
current_app.logger.warning(f"board broadcast (agenda) failed: {e}")
flash(f'Program posiedzenia {meeting.meeting_identifier} został opublikowany.', 'success')
current_app.logger.info(
f"Meeting agenda published: {meeting.meeting_identifier} by user {current_user.id}"
@ -327,6 +332,11 @@ def meeting_publish_protocol(meeting_id):
meeting.updated_at = datetime.now()
db.commit()
try:
_broadcast_board_meeting(db, meeting, kind='protocol')
except Exception as e:
current_app.logger.warning(f"board broadcast (protocol) failed: {e}")
# Trigger admission workflow in background
user_id = current_user.id
import threading
@ -794,6 +804,11 @@ def _handle_meeting_form(db, meeting=None):
db.add(new_meeting)
db.commit()
try:
_broadcast_board_meeting(db, new_meeting, kind='created')
except Exception as e:
current_app.logger.warning(f"board broadcast (created) failed: {e}")
flash(f'Posiedzenie {new_meeting.meeting_identifier} zostało utworzone.', 'success')
return redirect(url_for('board.meeting_view', meeting_id=new_meeting.id))
@ -856,7 +871,7 @@ def board_fees():
'has_data': False, 'is_child': is_child,
'child_brands': [], 'child_count': 0,
'expected_fees': {}, 'rate_change_month': None,
'parent_months': {},
'parent_months': {}, 'reminder': None,
}
for m in range(1, 13):
@ -893,6 +908,27 @@ def board_fees():
for m in range(1, 13):
company_data['parent_months'][m] = fees.get((company.parent_company_id, m))
# Find last reminder for this company
if not is_child and company_data['has_data']:
has_unpaid = any(
company_data['months'].get(m) and company_data['months'][m].status in ('pending', 'partial', 'overdue')
for m in range(1, 13)
)
if has_unpaid:
from database import PrivateMessage, UserCompany as UC2
company_user_ids = [cu.user_id for cu in db.query(UC2).filter(UC2.company_id == company.id).all()]
if company_user_ids:
last_reminder = db.query(PrivateMessage).filter(
PrivateMessage.recipient_id.in_(company_user_ids),
PrivateMessage.subject.ilike('%przypomnienie o składce%'),
).order_by(PrivateMessage.created_at.desc()).first()
if last_reminder:
company_data['reminder'] = {
'sent_at': last_reminder.created_at,
'is_read': last_reminder.is_read,
'read_at': last_reminder.read_at,
}
parent_fees_data.append(company_data)
# Sort: non-children with data first, then without data, children will be inserted after parents
@ -953,3 +989,88 @@ def board_fees():
)
finally:
db.close()
# =============================================================================
# BROADCAST HELPERS — Web Push + opt-in e-mail dla posiedzeń Rady
# =============================================================================
def _broadcast_board_meeting(db, meeting, kind: str):
"""Broadcast o zdarzeniu Rady do aktywnych członków.
kind:
'created' - utworzono nowe posiedzenie
'agenda' - opublikowano program
'protocol' - opublikowano protokół
"""
titles = {
'created': 'Nowe posiedzenie Rady Izby',
'agenda': 'Opublikowano program posiedzenia Rady',
'protocol': 'Opublikowano protokół z posiedzenia Rady',
}
subj_titles = {
'created': 'Nowe posiedzenie Rady',
'agenda': 'Program posiedzenia Rady',
'protocol': 'Protokół z posiedzenia Rady',
}
title = titles.get(kind, 'Aktualizacja posiedzenia Rady')
subject_prefix = subj_titles.get(kind, 'Posiedzenie Rady')
url_path = f'/rada/posiedzenia/{meeting.id}'
body = f'{meeting.meeting_identifier}' if hasattr(meeting, 'meeting_identifier') else f'Posiedzenie #{meeting.id}'
if meeting.meeting_date:
body += f'{meeting.meeting_date.strftime("%d.%m.%Y")}'
try:
from blueprints.push.push_service import send_push
from email_service import send_email, _email_v3_wrap
except ImportError:
return 0
try:
users = db.query(User).filter(User.is_active == True).all()
except Exception as e:
current_app.logger.warning(f"board broadcast: fetch users failed: {e}")
return 0
publisher_id = getattr(current_user, 'id', None)
push_sent = 0
for u in users:
if publisher_id and u.id == publisher_id:
continue
try:
if getattr(u, 'notify_push_board_meetings', True) is not False:
send_push(
user_id=u.id,
title=title,
body=body,
url=url_path,
tag=f'board-{meeting.id}-{kind}',
)
push_sent += 1
except Exception as e:
current_app.logger.debug(f"board push err user={u.id}: {e}")
try:
if u.email and getattr(u, 'notify_email_board_meetings', False) is True:
subject = f"{subject_prefix}: {body}"
body_text = f"{title}\n\n{body}\n\nZobacz: https://nordabiznes.pl{url_path}"
content = (
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{u.name or u.email}</strong>!</p>'
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">{title}.</p>'
f'<h3 style="color:#1e3a8a;">{body}</h3>'
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zobacz posiedzenie</a></p>'
)
body_html = _email_v3_wrap(title, 'Norda Biznes Partner', content)
send_email(
to=[u.email],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='board_broadcast',
user_id=u.id,
recipient_name=u.name,
notification_type='board_meetings',
)
except Exception as e:
current_app.logger.debug(f"board email err user={u.id}: {e}")
return push_sent

View File

@ -473,6 +473,71 @@ def forum_reply(topic_id):
except Exception as e:
logger.warning(f"Failed to parse mentions: {e}")
# Web Push: autor wątku (forum_reply) + cytowani (forum_quote)
try:
from blueprints.push.push_service import send_push
import re as _re
topic_url = f'/forum/{topic_id}#reply-{reply.id}'
plain = _re.sub(r'<[^>]+>', '', content or '').strip()
# Usuń cytat bloki z podglądu (linie zaczynające się od '>')
plain_lines = [ln for ln in plain.splitlines() if not ln.lstrip().startswith('>')]
clean = ' '.join(plain_lines).strip()
preview = (clean[:80] + '') if len(clean) > 80 else clean
notified_user_ids = set()
# Autor wątku (o ile nie jest sam replierem)
if topic.author_id and topic.author_id != current_user.id:
t_author = db.query(User).filter(User.id == topic.author_id).first()
if t_author and t_author.notify_push_forum_reply is not False:
send_push(
user_id=t_author.id,
title=f'Nowa odpowiedź: {topic.title[:60]}',
body=f'{replier_name}: {preview}' if preview else f'{replier_name} odpowiedział',
url=topic_url,
tag=f'forum-reply-{topic_id}',
)
notified_user_ids.add(t_author.id)
# Cytowani autorzy — pattern "> **Imię Nazwisko** napisał(a):"
quoted_names = set(_re.findall(r'>\s*\*\*([^*\n]+?)\*\*\s*napisał', content))
for qname in quoted_names:
qname = qname.strip()
if not qname:
continue
quoted_users = db.query(User).filter(User.name == qname).all()
if len(quoted_users) != 1:
continue # niejednoznaczne — pomijamy
quoted = quoted_users[0]
if quoted.id == current_user.id or quoted.id in notified_user_ids:
continue
if quoted.notify_push_forum_quote is not False:
send_push(
user_id=quoted.id,
title=f'Zacytowano Twoją wypowiedź: {topic.title[:50]}',
body=f'{replier_name}: {preview}' if preview else f'{replier_name} zacytował',
url=topic_url,
tag=f'forum-quote-{reply.id}-{quoted.id}',
)
notified_user_ids.add(quoted.id)
# Email dla cytowanego (jeśli flag=True, default TRUE dla quote)
if quoted.email and quoted.notify_email_forum_quote is not False:
try:
from utils.notifications import send_forum_reply_email
send_forum_reply_email(
topic_id=topic_id,
topic_title=topic.title,
replier_name=replier_name,
reply_content=content,
subscriber_emails=[{'email': quoted.email, 'name': quoted.name or quoted.email}],
reply_id=reply.id,
)
except Exception as e:
logger.warning(f"Failed to send forum quote email: {e}")
except Exception as e:
logger.warning(f"Forum push trigger error: {e}")
flash('Odpowiedź dodana.', 'success')
return redirect(url_for('.forum_topic', topic_id=topic_id))
finally:

View File

@ -342,6 +342,12 @@ class User(Base, UserMixin):
notify_email_classified_question = Column(Boolean, default=True) # Pytanie pod moim ogłoszeniem B2B
notify_email_classified_answer = Column(Boolean, default=True) # Odpowiedź pod moim pytaniem B2B
notify_email_classified_expiry = Column(Boolean, default=True) # Moje ogłoszenie wygasa za 3 dni
notify_email_forum_reply = Column(Boolean, default=False) # Odpowiedź w moim wątku forum (default OFF — forum subs duplikuje)
notify_email_forum_quote = Column(Boolean, default=True) # Cytat mojego wpisu forum
notify_email_announcements = Column(Boolean, default=False) # Nowa aktualność Izby (broadcast)
notify_email_board_meetings = Column(Boolean, default=False) # Posiedzenia Rady (broadcast)
notify_email_event_invites = Column(Boolean, default=True) # Nowe wydarzenie w kalendarzu
notify_email_event_reminders = Column(Boolean, default=True) # Przypomnienie 24h przed wydarzeniem
# Web Push notification preferences (per event type)
notify_push_messages = Column(Boolean, default=True) # Prywatna wiadomość
@ -2252,6 +2258,7 @@ class NordaEvent(Base):
max_attendees = Column(Integer)
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.now)
reminder_24h_sent_at = Column(DateTime) # Cron znacznik — nie wysyłaj przypomnienia 24h dwa razy
# Źródło danych (tracking)
source = Column(String(255)) # np. 'kalendarz_norda_2026', 'manual', 'api'
@ -2382,6 +2389,7 @@ class EventAttendee(Base):
id = Column(Integer, primary_key=True)
event_id = Column(Integer, ForeignKey('norda_events.id'), nullable=False)
# (właściwość poniżej)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
status = Column(String(20), default='confirmed') # confirmed, maybe, declined
registered_at = Column(DateTime, default=datetime.now)

View File

@ -0,0 +1,13 @@
-- Migration 103: preferencje e-mail dla zdarzeń D.2 (forum + broadcast)
--
-- Defaults dobrane tak, żeby nie zalać inbox użytkowników:
-- forum_reply = FALSE (forum_topic_subscriptions już wysyła maile subskrybentom)
-- forum_quote = TRUE (bezpośrednie, personalne, rzadkie)
-- announcements = FALSE (broadcast informacyjny — user zajrzy na portal)
-- board_meetings = FALSE (broadcast, preferowany push)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS notify_email_forum_reply BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS notify_email_forum_quote BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_email_announcements BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS notify_email_board_meetings BOOLEAN DEFAULT FALSE;

View File

@ -0,0 +1,12 @@
-- Migration 104: email preferences dla wydarzeń (D.3)
--
-- Defaults TRUE bo oba zdarzenia są actionable (wymagają reakcji —
-- zapis, przygotowanie, pamiętanie o terminie).
ALTER TABLE users
ADD COLUMN IF NOT EXISTS notify_email_event_invites BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS notify_email_event_reminders BOOLEAN DEFAULT TRUE;
-- Pole na tracking wysyłki przypomnienia 24h (żeby cron nie wysyłał dwa razy)
ALTER TABLE norda_events
ADD COLUMN IF NOT EXISTS reminder_24h_sent_at TIMESTAMP;

135
scripts/event_reminders_cron.py Executable file
View File

@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Event reminders cron — wysyła przypomnienia push/email 24h przed wydarzeniem.
Uruchomienie (cron co godzinę):
0 * * * * cd /var/www/nordabiznes && venv/bin/python3 scripts/event_reminders_cron.py
Logika:
- Szuka wydarzeń, których event_date+time_start mieści się między now()+23h a now()+25h
- Które jeszcze nie mają reminder_24h_sent_at ustawionego
- Dla każdego: pobiera zapisanych (EventAttendee.status != 'declined')
- Dla każdego zapisanego: sprawdza notify_push_event_reminders i notify_email_event_reminders
- Wysyła push i/lub email
- Oznacza event.reminder_24h_sent_at = now()
"""
import os
import sys
import logging
from datetime import datetime, timedelta, time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
from database import SessionLocal, NordaEvent, EventAttendee, User
def _combine_event_datetime(event):
"""Zwraca datetime startu wydarzenia (data + time_start lub 09:00 jeśli brak)."""
t = event.time_start if event.time_start else time(9, 0)
return datetime.combine(event.event_date, t)
def main():
from email_service import init_email_service
init_email_service()
from blueprints.push.push_service import send_push
from email_service import send_email, _email_v3_wrap
now = datetime.now()
window_start = now + timedelta(hours=23)
window_end = now + timedelta(hours=25)
db = SessionLocal()
try:
candidates = db.query(NordaEvent).filter(
NordaEvent.event_date >= window_start.date(),
NordaEvent.event_date <= window_end.date(),
NordaEvent.reminder_24h_sent_at.is_(None),
).all()
fired = 0
for event in candidates:
event_dt = _combine_event_datetime(event)
if not (window_start <= event_dt <= window_end):
continue
attendees = db.query(EventAttendee).filter(
EventAttendee.event_id == event.id,
EventAttendee.status != 'declined',
).all()
if not attendees:
event.reminder_24h_sent_at = now
continue
time_str = event.time_start.strftime('%H:%M') if event.time_start else ''
date_str = event.event_date.strftime('%d.%m.%Y')
url_path = f'/kalendarz/{event.id}'
title = f'Jutro wydarzenie: {event.title[:60]}'
body = f'{date_str} {time_str} · {event.location or "Miejsce w szczegółach"}'
push_count = 0
email_count = 0
for a in attendees:
user = db.query(User).filter(User.id == a.user_id).first()
if not user or not user.is_active:
continue
try:
if getattr(user, 'notify_push_event_reminders', True) is not False:
send_push(
user_id=user.id,
title=title,
body=body,
url=url_path,
tag=f'event-reminder-{event.id}',
)
push_count += 1
except Exception as e:
logger.warning(f"push err user={user.id}: {e}")
try:
if user.email and getattr(user, 'notify_email_event_reminders', True) is not False:
subject = f"Przypomnienie: {event.title[:60]} jutro o {time_str}"
body_text = f"Przypominamy o wydarzeniu na które jesteś zapisany:\n\n{event.title}\nTermin: {date_str} {time_str}\nMiejsce: {event.location or '-'}\n\nSzczegóły: https://nordabiznes.pl{url_path}"
content = (
f'<p style="margin:0 0 16px;color:#1e293b;font-size:16px;">Cześć <strong>{user.name or user.email}</strong>!</p>'
f'<p style="margin:0 0 20px;color:#475569;font-size:15px;">Przypominamy o wydarzeniu na które jesteś zapisany:</p>'
f'<h3 style="color:#1e3a8a;">{event.title}</h3>'
f'<p style="color:#475569;line-height:1.6">Termin: <b>{date_str} {time_str}</b><br>'
f'Miejsce: {event.location or "-"}</p>'
f'<p style="margin:24px 0;"><a href="https://nordabiznes.pl{url_path}" '
f'style="background:#1e3a8a;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">Zobacz szczegóły</a></p>'
)
body_html = _email_v3_wrap('Przypomnienie o wydarzeniu', 'Norda Biznes Partner', content)
send_email(
to=[user.email],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='event_reminder',
user_id=user.id,
recipient_name=user.name,
notification_type='event_reminders',
)
email_count += 1
except Exception as e:
logger.warning(f"email err user={user.id}: {e}")
event.reminder_24h_sent_at = now
fired += 1
logger.info(f"event={event.id} '{event.title}' — push={push_count}, email={email_count}")
db.commit()
logger.info(f"Done. Fired reminders for {fired} event(s).")
finally:
db.close()
if __name__ == '__main__':
main()

View File

@ -400,6 +400,72 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Odpowiedź w Twoim wątku forum</div>
<div class="setting-description">E-mail gdy ktoś odpowie w temacie, który założyłeś. Domyślnie wyłączone — forum ma już osobny system „Obserwuj temat" który wysyła maile subskrybentom.</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_email_forum_reply" {% if user.notify_email_forum_reply %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Cytat Twojego wpisu forum</div>
<div class="setting-description">E-mail gdy ktoś cytuje lub odpowiada bezpośrednio pod Twoim wpisem</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_email_forum_quote" {% if user.notify_email_forum_quote != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Nowa aktualność / ogłoszenie Izby</div>
<div class="setting-description">E-mail z każdą nową aktualnością publikowaną przez Biuro Izby. Domyślnie wyłączone — aktualności są powiadomieniem push i widoczne w portalu.</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_email_announcements" {% if user.notify_email_announcements %}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">E-mail gdy Rada tworzy posiedzenie, publikuje program lub protokół. Domyślnie wyłączone — powiadomienia push i kalendarz wystarczą.</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_email_board_meetings" {% if user.notify_email_board_meetings %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Nowe wydarzenia w kalendarzu</div>
<div class="setting-description">E-mail gdy w kalendarzu Izby pojawi się nowe wydarzenie</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_email_event_invites" {% if user.notify_email_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">E-mail dzień przed wydarzeniem, na które jesteś zapisany</div>
</div>
<label class="toggle-switch">
<input type="checkbox" name="notify_email_event_reminders" {% if user.notify_email_event_reminders != False %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="settings-card">

View File

@ -15,8 +15,12 @@ NOTIFICATION_TYPE_TO_COLUMN = {
'classified_question': 'notify_email_classified_question',
'classified_answer': 'notify_email_classified_answer',
'classified_expiry': 'notify_email_classified_expiry',
# D.2/D.3 dorzucą: forum_reply, forum_quote, announcements,
# board_meetings, event_invites, event_reminders
'forum_reply': 'notify_email_forum_reply',
'forum_quote': 'notify_email_forum_quote',
'announcements': 'notify_email_announcements',
'board_meetings': 'notify_email_board_meetings',
'event_invites': 'notify_email_event_invites',
'event_reminders': 'notify_email_event_reminders',
}
# Friendly labels dla strony potwierdzenia
@ -25,6 +29,12 @@ NOTIFICATION_TYPE_LABELS = {
'classified_question': 'Pytanie do Twojego ogłoszenia B2B',
'classified_answer': 'Odpowiedź na Twoje pytanie B2B',
'classified_expiry': 'Przypomnienie o wygasającym ogłoszeniu B2B',
'forum_reply': 'Odpowiedź w Twoim wątku forum',
'forum_quote': 'Cytat Twojego wpisu na forum',
'announcements': 'Aktualności Izby',
'board_meetings': 'Posiedzenia Rady Izby',
'event_invites': 'Nowe wydarzenia w kalendarzu Izby',
'event_reminders': 'Przypomnienia 24h przed wydarzeniem',
}