diff --git a/blueprints/admin/routes.py b/blueprints/admin/routes.py
index 90b5f99..e8344af 100644
--- a/blueprints/admin/routes.py
+++ b/blueprints/admin/routes.py
@@ -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'
Cześć {u.name or u.email} !
'
+ f'{title}:
'
+ f'{event.title} '
+ f'Termin: {date_str} {time_str} '
+ f'Miejsce: {event.location or "-"}
'
+ f'Zapisz się / szczegóły
'
+ )
+ 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:
diff --git a/blueprints/admin/routes_announcements.py b/blueprints/admin/routes_announcements.py
index ebe8370..cf64b11 100644
--- a/blueprints/admin/routes_announcements.py
+++ b/blueprints/admin/routes_announcements.py
@@ -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'Cześć {u.name or u.email} !
'
+ f'Biuro Izby opublikowało nową aktualność:
'
+ f'{title} '
+ f'{excerpt}
'
+ f'Zobacz aktualność
'
+ )
+ 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.'})
diff --git a/blueprints/auth/routes.py b/blueprints/auth/routes.py
index 49dbabd..0d3520d 100644
--- a/blueprints/auth/routes.py
+++ b/blueprints/auth/routes.py
@@ -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'
diff --git a/blueprints/board/routes.py b/blueprints/board/routes.py
index a25bc17..c6f28a3 100644
--- a/blueprints/board/routes.py
+++ b/blueprints/board/routes.py
@@ -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'Cześć {u.name or u.email} !
'
+ f'{title}.
'
+ f'{body} '
+ f'Zobacz posiedzenie
'
+ )
+ 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
diff --git a/blueprints/forum/routes.py b/blueprints/forum/routes.py
index eb59f3d..5528a77 100644
--- a/blueprints/forum/routes.py
+++ b/blueprints/forum/routes.py
@@ -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:
diff --git a/database.py b/database.py
index 3194940..4a45b88 100644
--- a/database.py
+++ b/database.py
@@ -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)
diff --git a/database/migrations/103_add_d2_email_preferences.sql b/database/migrations/103_add_d2_email_preferences.sql
new file mode 100644
index 0000000..2af3d05
--- /dev/null
+++ b/database/migrations/103_add_d2_email_preferences.sql
@@ -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;
diff --git a/database/migrations/104_add_event_email_preferences.sql b/database/migrations/104_add_event_email_preferences.sql
new file mode 100644
index 0000000..a112e05
--- /dev/null
+++ b/database/migrations/104_add_event_email_preferences.sql
@@ -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;
diff --git a/scripts/event_reminders_cron.py b/scripts/event_reminders_cron.py
new file mode 100755
index 0000000..3f7f9ee
--- /dev/null
+++ b/scripts/event_reminders_cron.py
@@ -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'Cześć {user.name or user.email} !
'
+ f'Przypominamy o wydarzeniu na które jesteś zapisany:
'
+ f'{event.title} '
+ f'Termin: {date_str} {time_str} '
+ f'Miejsce: {event.location or "-"}
'
+ f'Zobacz szczegóły
'
+ )
+ 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()
diff --git a/templates/konto/prywatnosc.html b/templates/konto/prywatnosc.html
index 1c79eb9..111fb61 100644
--- a/templates/konto/prywatnosc.html
+++ b/templates/konto/prywatnosc.html
@@ -400,6 +400,72 @@
+
+
+
+
Odpowiedź w Twoim wątku forum
+
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.
+
+
+
+
+
+
+
+
+
+
Cytat Twojego wpisu forum
+
E-mail gdy ktoś cytuje lub odpowiada bezpośrednio pod Twoim wpisem
+
+
+
+
+
+
+
+
+
+
Nowa aktualność / ogłoszenie Izby
+
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.
+
+
+
+
+
+
+
+
+
+
Posiedzenia Rady Izby
+
E-mail gdy Rada tworzy posiedzenie, publikuje program lub protokół. Domyślnie wyłączone — powiadomienia push i kalendarz wystarczą.
+
+
+
+
+
+
+
+
+
+
Nowe wydarzenia w kalendarzu
+
E-mail gdy w kalendarzu Izby pojawi się nowe wydarzenie
+
+
+
+
+
+
+
+
+
+
Przypomnienie 24h przed wydarzeniem
+
E-mail dzień przed wydarzeniem, na które jesteś zapisany
+
+
+
+
+
+
diff --git a/utils/unsubscribe_tokens.py b/utils/unsubscribe_tokens.py
index cc48259..d62bd7a 100644
--- a/utils/unsubscribe_tokens.py
+++ b/utils/unsubscribe_tokens.py
@@ -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',
}