feat(forum): Add email notifications for replies + custom tooltips
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

- Email notifications sent to topic subscribers when new reply posted
- Auto-subscribe users when they reply to a topic
- Custom CSS tooltip on "seen by" avatars (replaces native title)
- GET /forum/<id>/unsubscribe endpoint for email unsubscribe links
- Clean up ROADMAP.md (remove unimplemented priorities, add RBAC/Slack)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-06 04:10:47 +01:00
parent f2fc1b89ec
commit 5e77ede9fa
4 changed files with 211 additions and 418 deletions

View File

@ -23,7 +23,8 @@ from utils.notifications import (
create_forum_reaction_notification, create_forum_reaction_notification,
create_forum_solution_notification, create_forum_solution_notification,
create_forum_report_notification, create_forum_report_notification,
parse_mentions_and_notify parse_mentions_and_notify,
send_forum_reply_email
) )
# Constants # Constants
@ -348,17 +349,34 @@ def forum_reply(topic_id):
topic.updated_at = datetime.now() topic.updated_at = datetime.now()
db.commit() db.commit()
# Send notifications to subscribers (except the replier) # Auto-subscribe replier to topic (if not already subscribed)
try: try:
subscriptions = db.query(ForumTopicSubscription).filter( existing_sub = db.query(ForumTopicSubscription).filter(
ForumTopicSubscription.topic_id == topic_id,
ForumTopicSubscription.user_id == current_user.id
).first()
if not existing_sub:
db.add(ForumTopicSubscription(
topic_id=topic_id,
user_id=current_user.id,
notify_email=True,
notify_app=True
))
db.commit()
except Exception as e:
logger.warning(f"Failed to auto-subscribe replier: {e}")
# Send in-app notifications to subscribers (except the replier)
replier_name = current_user.name or current_user.email.split('@')[0]
try:
app_subs = db.query(ForumTopicSubscription).filter(
ForumTopicSubscription.topic_id == topic_id, ForumTopicSubscription.topic_id == topic_id,
ForumTopicSubscription.user_id != current_user.id, ForumTopicSubscription.user_id != current_user.id,
ForumTopicSubscription.notify_app == True ForumTopicSubscription.notify_app == True
).all() ).all()
subscriber_ids = [s.user_id for s in subscriptions] subscriber_ids = [s.user_id for s in app_subs]
if subscriber_ids: if subscriber_ids:
replier_name = current_user.name or current_user.email.split('@')[0]
create_forum_reply_notification( create_forum_reply_notification(
topic_id=topic_id, topic_id=topic_id,
topic_title=topic.title, topic_title=topic.title,
@ -369,6 +387,35 @@ def forum_reply(topic_id):
except Exception as e: except Exception as e:
logger.warning(f"Failed to send reply notifications: {e}") logger.warning(f"Failed to send reply notifications: {e}")
# Send email notifications to subscribers with notify_email=True
try:
email_subs = db.query(ForumTopicSubscription).filter(
ForumTopicSubscription.topic_id == topic_id,
ForumTopicSubscription.user_id != current_user.id,
ForumTopicSubscription.notify_email == True
).all()
if email_subs:
subscriber_emails = []
for sub in email_subs:
user = db.query(User).filter(User.id == sub.user_id).first()
if user and user.email:
subscriber_emails.append({
'email': user.email,
'name': user.name or user.email.split('@')[0]
})
if subscriber_emails:
send_forum_reply_email(
topic_id=topic_id,
topic_title=topic.title,
replier_name=replier_name,
reply_content=content,
subscriber_emails=subscriber_emails
)
except Exception as e:
logger.warning(f"Failed to send email notifications: {e}")
# Parse @mentions and send notifications # Parse @mentions and send notifications
try: try:
author_name = current_user.name or current_user.email.split('@')[0] author_name = current_user.name or current_user.email.split('@')[0]
@ -1014,6 +1061,32 @@ def unsubscribe_from_topic(topic_id):
db.close() db.close()
@bp.route('/forum/<int:topic_id>/unsubscribe', methods=['GET'])
@login_required
def unsubscribe_from_email(topic_id):
"""Unsubscribe from topic via email link (GET, requires login)"""
db = SessionLocal()
try:
subscription = db.query(ForumTopicSubscription).filter(
ForumTopicSubscription.topic_id == topic_id,
ForumTopicSubscription.user_id == current_user.id
).first()
topic = db.query(ForumTopic).filter(ForumTopic.id == topic_id).first()
topic_title = topic.title if topic else f"#{topic_id}"
if subscription:
db.delete(subscription)
db.commit()
flash(f'Przestales obserwowac watek: {topic_title}', 'success')
else:
flash('Nie obserwujesz tego watku.', 'info')
return redirect(url_for('.forum_topic', topic_id=topic_id))
finally:
db.close()
@bp.route('/forum/report', methods=['POST']) @bp.route('/forum/report', methods=['POST'])
@login_required @login_required
def report_content(): def report_content():

View File

@ -9,357 +9,12 @@ Główne instrukcje znajdują się w [CLAUDE.md](../CLAUDE.md).
| Funkcjonalność | Status | Data | | Funkcjonalność | Status | Data |
|----------------|--------|------| |----------------|--------|------|
| Social Media Audit | ✅ Wdrożone | 2026-01-09 | | Social Media Audit | Wdrożone | 2026-01-09 |
| News Monitoring | ✅ Wdrożone | 2025-12-29 | | News Monitoring | Wdrożone | 2025-12-29 |
| Katalog firm | ✅ Wdrożone | 2025-11-23 | | Katalog firm | Wdrożone | 2025-11-23 |
| Chat AI (NordaGPT) | ✅ Wdrożone | 2025-11-23 | | Chat AI (NordaGPT) | Wdrożone | 2025-11-23 |
| RBAC (Role-Based Access Control) | Wdrożone | 2026-02-05 |
--- | Slack MCP Integration | Wdrożone | 2026-02-06 |
## Priorytet 1: Social Media Integration (Posts/Events)
**Status:** Planowane
**Cel:** Pobieranie postów i wydarzeń z Social Media firm
### Źródła danych
- Facebook Pages firm członkowskich (posty, wydarzenia)
- LinkedIn Company Pages
- Google My Business (recenzje, posty)
### Wymagania techniczne
- Facebook Graph API (wymaga App Review dla pages_read_engagement)
- LinkedIn Marketing API (wymaga partnera lub OAuth)
- Google Business Profile API
### Typy zdarzeń do importu
- `social_post` - posty z social media
- `social_event` - wydarzenia z Facebooka
- `review` - nowe recenzje Google
---
## Priorytet 2: News Monitoring (Google/Brave API)
**Status:** ✅ Wdrożone (2025-12-29)
### Źródła danych
- Wzmianki o firmach w mediach lokalnych/branżowych
- Artykuły prasowe
- Komunikaty branżowe
### Typy zdarzeń
- `news_mention` - wzmianka w mediach
- `press_release` - komunikat prasowy
- `award` - nagroda/wyróżnienie
---
## Priorytet 3: Zarząd i Wspólnicy (rejestr.io)
**Status:** Planowane
**Cel:** Wyświetlanie osób powiązanych z firmą bezpośrednio na stronie profilu
### Dane do pobrania z rejestr.io
- Zarząd (Prezes, Wiceprezes, Członkowie Zarządu)
- Prokurenci
- Wspólnicy z % udziałów
- Beneficjenci rzeczywiści
- Linki do profili osób (powiązania z innymi firmami)
### Wymagania techniczne
- Tabela `company_people` (company_id, name, role, shares_percent, person_url)
- Scraper Playwright (już mamy bazę w `analyze_connections.py`)
- Sekcja w `company_detail.html` po "Informacje prawne i biznesowe"
### Przykład wyświetlania
```
👥 ZARZĄD I WSPÓLNICY
┌─────────────────────────────────────────┐
│ 👔 Jan Kowalski - Prezes Zarządu │
│ 👔 Anna Nowak - Członek Zarządu │
│ 💼 Firma XYZ Sp. z o.o. - 60% udziałów │
│ 💼 Jan Kowalski - 40% udziałów │
└─────────────────────────────────────────┘
```
### Korzyści
- Widoczne powiązania między firmami Norda Biznes
- Ułatwiony networking (kto zna kogo)
- Transparentność struktury właścicielskiej
---
## Priorytet 4: System rekomendacji i zdjęć
**Status:** Planowane
**Cel:** Umożliwienie firmom członkowskim wzajemnego polecania się oraz prezentacji zdjęć
### Funkcje
- Rekomendacje między firmami (kto poleca kogo)
- Galeria zdjęć firmy (realizacje, zespół, biuro)
- Wyświetlanie na profilu firmy
---
## Priorytet 5: Status członkostwa i płatności
**Status:** Planowane
**Cel:** Śledzenie statusu członkostwa i składek miesięcznych
### Funkcje
- Status członka (aktywny, zawieszony, były członek)
- Informacja o opłaconych składkach
- Historia płatności
- Przypomnienia o zaległościach (dla admina)
---
## Priorytet 6: System powiadomień
**Status:** Planowane
**Cel:** Powiadamianie użytkowników o istotnych zdarzeniach bez konieczności logowania
**Źródło:** Feedback od członków Norda Biznes (Janusz Masiak, Angelika Piechocka) - 2026-01-22
### Kanały powiadomień
| Kanał | Zastosowanie | Technologia | Koszt |
|-------|--------------|-------------|-------|
| **Email** | Standardowe powiadomienia | SendGrid / Mailgun / SMTP | ~$15/mies (10k maili) |
| **SMS** | Pilne/ważne powiadomienia | SMSAPI.pl | ~0.07 zł/SMS |
| **Push** | Natychmiastowe (PWA) | Web Push API | Darmowe |
### Typy powiadomień
| Typ | Email | SMS | Push | Domyślnie |
|-----|:-----:|:---:|:----:|:---------:|
| Nowe wydarzenia Norda Biznes | ✅ | ⚪ | ✅ | Email+Push |
| Nowe aktualności i newsy | ✅ | ❌ | ✅ | Email |
| Wiadomości od innych firm | ✅ | ⚪ | ✅ | Email+Push |
| Przypomnienia o spotkaniach | ✅ | ✅ | ✅ | Wszystkie |
| Newsletter tygodniowy | ✅ | ❌ | ❌ | Wyłączone |
**Legenda:** ✅ dostępne domyślnie, ⚪ dostępne opcjonalnie, ❌ niedostępne
### Panel preferencji powiadomień
Użytkownik w profilu będzie mógł:
- Włączyć/wyłączyć poszczególne typy powiadomień
- Wybrać preferowane kanały (email, SMS, push)
- Ustawić "ciche godziny" (np. brak SMS po 21:00)
- Zapisać numer telefonu do SMS
### Wymagania techniczne
```python
# Nowa tabela: notification_preferences
class NotificationPreference(Base):
__tablename__ = 'notification_preferences'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), unique=True)
# Kanały
email_enabled = Column(Boolean, default=True)
sms_enabled = Column(Boolean, default=False)
push_enabled = Column(Boolean, default=True)
phone_number = Column(String(15)) # Format: +48XXXXXXXXX
# Typy powiadomień
events_email = Column(Boolean, default=True)
events_sms = Column(Boolean, default=False)
news_email = Column(Boolean, default=True)
messages_email = Column(Boolean, default=True)
messages_push = Column(Boolean, default=True)
reminders_sms = Column(Boolean, default=True)
newsletter = Column(Boolean, default=False)
# Ciche godziny
quiet_hours_start = Column(Time) # np. 21:00
quiet_hours_end = Column(Time) # np. 08:00
# Nowa tabela: notification_log (audit)
class NotificationLog(Base):
__tablename__ = 'notification_log'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'))
channel = Column(String(10)) # email, sms, push
type = Column(String(30)) # event, news, message, reminder
subject = Column(String(200))
sent_at = Column(DateTime, default=datetime.utcnow)
status = Column(String(20)) # sent, failed, bounced
```
### Integracje
- **Email:** Flask-Mail + SendGrid API (fallback: SMTP)
- **SMS:** SMSAPI.pl (polska firma, dobra dokumentacja)
- **Push:** Web Push API + pywebpush (wymaga PWA - Priorytet 7)
---
## Priorytet 7: PWA (Progressive Web App)
**Status:** Planowane
**Cel:** Umożliwienie "instalacji" NordaBiznes na telefonie jako aplikacji
**Źródło:** Feedback od Angeliki Piechockiej - 2026-01-22
### Korzyści PWA vs natywna aplikacja
| Aspekt | PWA | Natywna aplikacja |
|--------|-----|-------------------|
| Koszt rozwoju | Niski | Wysoki (iOS + Android) |
| Czas wdrożenia | 2-4 tygodnie | 3-6 miesięcy |
| Aktualizacje | Natychmiastowe | Wymaga publikacji w Store |
| Instalacja | "Dodaj do ekranu" | App Store / Google Play |
| Push notifications | ✅ (Android, iOS 16.4+) | ✅ |
| Dostęp offline | Ograniczony | Pełny |
| Kamera/GPS | ✅ | ✅ |
### Wymagania techniczne
1. **manifest.json** - metadane aplikacji (nazwa, ikony, kolory)
2. **Service Worker** - cache, offline, push notifications
3. **HTTPS** - już mamy (Let's Encrypt)
4. **Ikony** - różne rozmiary (192x192, 512x512)
### Przykład manifest.json
```json
{
"name": "NordaBiznes - Katalog Firm",
"short_name": "NordaBiznes",
"description": "Platforma networkingowa członków Norda Biznes",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#6c5ce7",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
```
### Fazy wdrożenia
1. **Faza 1:** Podstawowy PWA (manifest + service worker cache)
2. **Faza 2:** Push notifications (integracja z Priorytetem 6)
3. **Faza 3:** Offline mode (cache kluczowych stron)
### Zależności
- Push notifications w PWA wymagają Service Worker
- Priorytet 6 (System powiadomień) i Priorytet 7 (PWA) są powiązane
- Rekomendacja: wdrażać równolegle
---
## Priorytet 8: System ogłoszeń i aktualności
**Status:** Planowane
**Cel:** Umożliwienie komunikacji z członkami Norda Biznes - ogłoszenia, aktualności, ważne informacje
**Źródło:** Potrzeba rozesłania informacji o bazie noclegowej ARP dla elektrowni jądrowej - 2026-01-26
### Problem
Obecnie brak możliwości:
- Publikowania ogłoszeń dla członków na stronie www
- Wysyłania masowych komunikatów do wszystkich członków
- Targetowania komunikatów (np. tylko branża noclegowa)
### Funkcjonalności
#### Panel admina (`/admin/announcements`)
- Tworzenie nowych ogłoszeń (tytuł, treść, obrazek, link)
- Ustawianie daty publikacji i wygaśnięcia
- Wybór grupy docelowej (wszyscy / wybrane kategorie firm)
- Podgląd przed publikacją
- Historia wysłanych ogłoszeń
#### Strona publiczna (`/aktualnosci`)
- Lista aktualnych ogłoszeń
- Archiwum starszych ogłoszeń
- Filtrowanie po kategorii
- RSS feed (opcjonalnie)
#### Integracja z powiadomieniami (Priorytet 6)
- Automatyczne wysyłanie email do członków przy nowym ogłoszeniu
- Push notification (PWA)
- Opcja "wyślij teraz" vs "tylko opublikuj na stronie"
### Wymagania techniczne
```python
# Nowa tabela: announcements
class Announcement(Base):
__tablename__ = 'announcements'
id = Column(Integer, primary_key=True)
title = Column(String(200), nullable=False)
slug = Column(String(200), unique=True)
content = Column(Text, nullable=False) # Markdown lub HTML
excerpt = Column(String(500)) # Krótki opis do listy
image_url = Column(String(500))
external_link = Column(String(500)) # Link do zewnętrznego źródła
# Publikacja
status = Column(String(20), default='draft') # draft, published, archived
published_at = Column(DateTime)
expires_at = Column(DateTime) # Opcjonalna data wygaśnięcia
# Targetowanie
target_audience = Column(String(50), default='all') # all, category:IT, category:Services
# Powiadomienia
send_email = Column(Boolean, default=True)
send_push = Column(Boolean, default=True)
notification_sent_at = Column(DateTime)
# Meta
created_by = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, onupdate=datetime.utcnow)
view_count = Column(Integer, default=0)
# Tabela śledzenia kto widział ogłoszenie
class AnnouncementView(Base):
__tablename__ = 'announcement_views'
id = Column(Integer, primary_key=True)
announcement_id = Column(Integer, ForeignKey('announcements.id'))
user_id = Column(Integer, ForeignKey('users.id'))
viewed_at = Column(DateTime, default=datetime.utcnow)
```
### Przykłady użycia
| Scenariusz | Target | Email | Push |
|------------|--------|:-----:|:----:|
| Baza noclegowa ARP (elektrownia) | Wszystkie firmy | ✅ | ✅ |
| Szkolenie IT | category:IT | ✅ | ❌ |
| Spotkanie networkingowe | Wszystkie firmy | ✅ | ✅ |
| Aktualizacja regulaminu | Wszystkie firmy | ✅ | ❌ |
### Zależności
- **Priorytet 6 (Powiadomienia)** - do wysyłania email/push
- **Priorytet 7 (PWA)** - do push notifications
- Może działać samodzielnie (tylko strona www) bez powiadomień
### Fazy wdrożenia
1. **Faza 1:** Strona `/aktualnosci` + panel admina (bez powiadomień)
2. **Faza 2:** Integracja z email (gdy Priorytet 6 gotowy)
3. **Faza 3:** Push notifications (gdy Priorytet 7 gotowy)
--- ---
@ -414,63 +69,11 @@ Najwyższy poziom służy jako **kotwica** - sprawia że środkowy wydaje się a
| Funkcja | Basic | Premium | Enterprise | | Funkcja | Basic | Premium | Enterprise |
|---------|:-----:|:-------:|:----------:| |---------|:-----:|:-------:|:----------:|
| Katalog firm | ✅ | ✅ | ✅ | | Katalog firm | + | + | + |
| Profil firmy | ✅ | ✅ | ✅ | | Profil firmy | + | + | + |
| Forum | ❌ | ✅ | ✅ | | Forum | - | + | + |
| Kalendarz wydarzeń | ❌ | ✅ | ✅ | | Kalendarz wydarzeń | - | + | + |
| Chat AI (NordaGPT) | ❌ | ✅ | ✅ | | Chat AI (NordaGPT) | - | + | + |
| **Powiadomienia email** | ✅ | ✅ | ✅ | | Eksport danych (CSV/PDF) | - | - | + |
| **Powiadomienia SMS** | ❌ | ✅ | ✅ | | API dostęp | - | - | + |
| **Powiadomienia push (PWA)** | ❌ | ✅ | ✅ | | Priorytetowe wsparcie | - | - | + |
| **Aktualności i ogłoszenia** | ✅ | ✅ | ✅ |
| **Raporty podstawowe** | ❌ | ✅ | ✅ |
| **Raporty zaawansowane** | ❌ | ❌ | ✅ |
| Eksport danych (CSV/PDF) | ❌ | ❌ | ✅ |
| API dostęp | ❌ | ❌ | ✅ |
| Priorytetowe wsparcie | ❌ | ❌ | ✅ |
### Implementacja techniczna (przyszłość)
```python
# Model User - nowe pola
class User(Base):
# ...
subscription_tier = Column(String(20), default='basic') # basic, premium, enterprise
subscription_expires_at = Column(DateTime)
is_norda_member = Column(Boolean, default=False) # Członek Izby = specjalny status
# Dekorator kontroli dostępu
def requires_tier(min_tier):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
tiers = ['basic', 'premium', 'enterprise']
user_tier_idx = tiers.index(current_user.subscription_tier)
required_idx = tiers.index(min_tier)
if user_tier_idx < required_idx:
flash(f'Ta funkcja wymaga konta {min_tier.title()}.', 'warning')
return redirect(url_for('pricing'))
return f(*args, **kwargs)
return wrapped
return decorator
# Użycie
@app.route('/raporty/zaawansowane')
@login_required
@requires_tier('enterprise')
def advanced_reports():
...
```
### Raporty - podział według poziomu
**Raporty podstawowe (Premium+):**
- Staż członkostwa w Izbie NORDA
- Pokrycie Social Media
- Struktura branżowa
**Raporty zaawansowane (Enterprise only):**
- Ranking SEO
- Mapa lokalizacji
- Sieć rekomendacji
- Aktywność w wydarzeniach

View File

@ -809,6 +809,31 @@
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
cursor: default; cursor: default;
position: relative;
}
.reader-avatar[data-name]::after {
content: attr(data-name);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #1a1a2e);
color: var(--text-primary, #fff);
padding: 4px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.reader-avatar[data-name]:hover::after {
opacity: 1;
} }
.reader-avatar.more { .reader-avatar.more {
@ -1073,7 +1098,7 @@
<div class="seen-by-avatars"> <div class="seen-by-avatars">
{% for read in topic_readers[:20] %} {% for read in topic_readers[:20] %}
<div class="reader-avatar" <div class="reader-avatar"
title="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}" data-name="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
style="background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);"> style="background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);">
{{ (read.user.name or read.user.email)[0]|upper }} {{ (read.user.name or read.user.email)[0]|upper }}
</div> </div>
@ -1213,7 +1238,7 @@
<div class="seen-by-avatars"> <div class="seen-by-avatars">
{% for read in reply.readers[:15] %} {% for read in reply.readers[:15] %}
<div class="reader-avatar" <div class="reader-avatar"
title="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}" data-name="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
style="background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);"> style="background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);">
{{ (read.user.name or read.user.email)[0]|upper }} {{ (read.user.name or read.user.email)[0]|upper }}
</div> </div>

View File

@ -257,6 +257,98 @@ def create_forum_reply_notification(topic_id, topic_title, replier_name, reply_i
return count return count
def send_forum_reply_email(topic_id, topic_title, replier_name, reply_content, subscriber_emails):
"""
Send email notifications to forum topic subscribers about a new reply.
Args:
topic_id: ID of the forum topic
topic_title: Title of the topic
replier_name: Name of the user who replied
reply_content: First 200 chars of the reply content
subscriber_emails: List of dicts with 'email' and 'name' keys
"""
from email_service import send_email
base_url = "https://nordabiznes.pl"
topic_url = f"{base_url}/forum/{topic_id}"
unsubscribe_url = f"{base_url}/forum/{topic_id}/unsubscribe"
preview = reply_content[:200].strip()
if len(reply_content) > 200:
preview += "..."
count = 0
for subscriber in subscriber_emails:
subject = f"Nowa odpowiedz na forum: {topic_title[:60]}"
body_text = f"""{replier_name} odpowiedzial w temacie: {topic_title}
"{preview}"
Zobacz pelna odpowiedz: {topic_url}
---
Aby przestac obserwowac ten watek: {unsubscribe_url}
Norda Biznes Partner - https://nordabiznes.pl
"""
body_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: 'Inter', Arial, sans-serif; line-height: 1.6; color: #1e293b; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; background: #f8fafc; }}
.header {{ background-color: #1e3a8a; background: linear-gradient(135deg, #1e40af, #1e3a8a); color: white; padding: 24px; text-align: center; border-radius: 8px 8px 0 0; }}
.header h1 {{ margin: 0; font-size: 22px; font-weight: 700; }}
.content {{ background: white; padding: 30px; border-radius: 0 0 8px 8px; }}
.quote {{ background: #f1f5f9; border-left: 4px solid #2563eb; padding: 15px; margin: 20px 0; border-radius: 0 8px 8px 0; color: #475569; font-style: italic; }}
.button {{ display: inline-block; padding: 14px 32px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; margin: 20px 0; font-weight: 600; }}
.footer {{ text-align: center; padding: 20px; color: #94a3b8; font-size: 0.85em; }}
.footer a {{ color: #94a3b8; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Nowa odpowiedz na forum</h1>
</div>
<div class="content">
<p>Czesc {subscriber.get('name', '')}!</p>
<p><strong>{replier_name}</strong> odpowiedzial w temacie, ktory obserwujesz:</p>
<h3 style="color: #1e3a8a;">{topic_title}</h3>
<div class="quote">{preview}</div>
<p style="text-align: center;">
<a href="{topic_url}" class="button">Zobacz odpowiedz</a>
</p>
</div>
<div class="footer">
<p>Norda Biznes Partner - Platforma Networkingu</p>
<p><a href="{unsubscribe_url}">Przestam obserwowac ten watek</a></p>
</div>
</div>
</body>
</html>"""
try:
result = send_email(
to=[subscriber['email']],
subject=subject,
body_text=body_text,
body_html=body_html,
email_type='forum_notification',
recipient_name=subscriber.get('name', '')
)
if result:
count += 1
except Exception as e:
logger.error(f"Failed to send forum reply email to {subscriber['email']}: {e}")
logger.info(f"Sent {count}/{len(subscriber_emails)} forum reply emails for topic {topic_id}")
return count
def create_forum_reaction_notification(user_id, reactor_name, content_type, content_id, topic_id, emoji): def create_forum_reaction_notification(user_id, reactor_name, content_type, content_id, topic_id, emoji):
""" """
Notify user when someone reacts to their content. Notify user when someone reacts to their content.