nordabiz/blueprints/public/routes.py
Maciej Pienczyn 66070c8bf9 docs: Add release notes v1.23.0 - Role-based access control
Summary of changes:
- Migration from is_admin to 6-tier role hierarchy
- NordaGPT, Messages, B2B, Contacts restricted to MEMBER role
- New decorators: @office_manager_required, @member_required
- Promotional landing page for non-members on /chat

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:46:00 +01:00

961 lines
40 KiB
Python

"""
Public Routes
=============
Public-facing routes: index, company profiles, search, events, new members,
connections map, release notes, dashboard.
"""
import logging
from datetime import datetime, timedelta
from flask import render_template, request, redirect, url_for, flash, session
from flask_login import login_required, current_user
from sqlalchemy import or_, func
from . import bp
from database import (
SessionLocal,
Company,
Category,
User,
CompanyRecommendation,
CompanyEvent,
CompanyDigitalMaturity,
CompanyWebsiteAnalysis,
CompanyQualityTracking,
CompanyWebsiteContent,
CompanyAIInsights,
CompanySocialMedia,
CompanyContact,
Person,
CompanyPerson,
GBPAudit,
ITAudit,
CompanyPKD,
NordaEvent,
EventAttendee,
AIChatConversation,
AIChatMessage,
UserSession,
SearchQuery,
)
from utils.helpers import sanitize_input
from extensions import limiter
from search_service import search_companies
# Logger
logger = logging.getLogger(__name__)
# Global constant (same as in app.py)
COMPANY_COUNT_MARKETING = 150
@bp.route('/')
def index():
"""Homepage - landing page for guests, company directory for logged in users"""
if not current_user.is_authenticated:
# Landing page for guests
db = SessionLocal()
try:
total_companies = db.query(Company).filter_by(status='active').count()
total_categories = db.query(Category).count()
return render_template(
'landing.html',
total_companies=total_companies,
total_categories=total_categories
)
finally:
db.close()
# Company directory for logged in users
db = SessionLocal()
try:
from datetime import date
companies = db.query(Company).filter_by(status='active').order_by(Company.name).all()
# Get hierarchical categories (main categories with subcategories)
main_categories = db.query(Category).filter(
Category.parent_id.is_(None)
).order_by(Category.display_order, Category.name).all()
# All categories for backwards compatibility
categories = db.query(Category).order_by(Category.sort_order).all()
total_companies = len(companies)
total_categories = len([c for c in categories if db.query(Company).filter_by(category_id=c.id).count() > 0])
# Najbliższe wydarzenie (dla bannera "Kto weźmie udział?")
next_event = db.query(NordaEvent).filter(
NordaEvent.event_date >= date.today()
).order_by(NordaEvent.event_date.asc()).first()
# Sprawdź czy użytkownik jest zapisany na to wydarzenie
user_registered = False
if next_event:
user_registered = db.query(EventAttendee).filter(
EventAttendee.event_id == next_event.id,
EventAttendee.user_id == current_user.id
).first() is not None
return render_template(
'index.html',
companies=companies,
categories=categories,
main_categories=main_categories,
total_companies=total_companies,
total_categories=total_categories,
next_event=next_event,
user_registered=user_registered
)
finally:
db.close()
@bp.route('/company/<int:company_id>')
def company_detail(company_id):
"""Company detail page - requires login"""
db = SessionLocal()
try:
company = db.query(Company).filter_by(id=company_id).first()
if not company:
flash('Firma nie znaleziona.', 'error')
return redirect(url_for('index'))
# Load digital maturity data if available
maturity_data = db.query(CompanyDigitalMaturity).filter_by(company_id=company_id).first()
# Get latest website analysis sorted by audit date (consistent with seo_audit_dashboard)
website_analysis = db.query(CompanyWebsiteAnalysis).filter_by(
company_id=company_id
).order_by(CompanyWebsiteAnalysis.seo_audited_at.desc()).first()
# Load quality tracking data
quality_data = db.query(CompanyQualityTracking).filter_by(company_id=company_id).first()
# Load company events (latest 10)
events = db.query(CompanyEvent).filter_by(company_id=company_id).order_by(
CompanyEvent.event_date.desc(),
CompanyEvent.created_at.desc()
).limit(10).all()
# Load website scraping data (most recent)
website_content = db.query(CompanyWebsiteContent).filter_by(company_id=company_id).order_by(
CompanyWebsiteContent.scraped_at.desc()
).first()
# Load AI insights
ai_insights = db.query(CompanyAIInsights).filter_by(company_id=company_id).first()
# Load social media profiles
social_media = db.query(CompanySocialMedia).filter_by(company_id=company_id).all()
# Load company contacts (phones, emails with sources)
contacts = db.query(CompanyContact).filter_by(company_id=company_id).order_by(
CompanyContact.contact_type,
CompanyContact.is_primary.desc()
).all()
# Load recommendations (approved only, with recommender details)
recommendations = db.query(CompanyRecommendation).filter_by(
company_id=company_id,
status='approved'
).join(User, CompanyRecommendation.user_id == User.id).order_by(
CompanyRecommendation.created_at.desc()
).all()
# Load people connected to company (zarząd, wspólnicy, prokurenci)
people = db.query(CompanyPerson).filter_by(
company_id=company_id
).join(Person, CompanyPerson.person_id == Person.id).order_by(
CompanyPerson.role_category,
Person.nazwisko
).all()
# Load GBP audit (most recent)
gbp_audit = db.query(GBPAudit).filter_by(
company_id=company_id
).order_by(GBPAudit.audit_date.desc()).first()
# Load IT audit (most recent)
it_audit = db.query(ITAudit).filter_by(
company_id=company_id
).order_by(ITAudit.audit_date.desc()).first()
# Load PKD codes (all - primary first)
pkd_codes = db.query(CompanyPKD).filter_by(
company_id=company_id
).order_by(CompanyPKD.is_primary.desc(), CompanyPKD.pkd_code).all()
# Check if current user can enrich company data (user with company edit rights)
can_enrich = False
if current_user.is_authenticated:
can_enrich = current_user.can_edit_company(company.id)
return render_template('company_detail.html',
company=company,
company_id=company.id, # For analytics conversion tracking
maturity_data=maturity_data,
website_analysis=website_analysis,
quality_data=quality_data,
events=events,
website_content=website_content,
ai_insights=ai_insights,
social_media=social_media,
contacts=contacts,
recommendations=recommendations,
people=people,
gbp_audit=gbp_audit,
it_audit=it_audit,
pkd_codes=pkd_codes,
can_enrich=can_enrich
)
finally:
db.close()
@bp.route('/company/<slug>')
def company_detail_by_slug(slug):
"""Company detail page by slug - requires login"""
db = SessionLocal()
try:
company = db.query(Company).filter_by(slug=slug).first()
if not company:
flash('Firma nie znaleziona.', 'error')
return redirect(url_for('index'))
# Redirect to canonical int ID route
return redirect(url_for('company_detail', company_id=company.id))
finally:
db.close()
@bp.route('/osoba/<int:person_id>')
def person_detail(person_id):
"""Person detail page - shows registry data and portal data if available"""
db = SessionLocal()
try:
# Get person with their company relationships
person = db.query(Person).filter_by(id=person_id).first()
if not person:
flash('Osoba nie znaleziona.', 'error')
return redirect(url_for('index'))
# Get company roles with company details (only active companies)
company_roles = db.query(CompanyPerson).filter_by(
person_id=person_id
).join(Company, CompanyPerson.company_id == Company.id).filter(
Company.status == 'active'
).order_by(
CompanyPerson.role_category,
Company.name
).all()
# Try to find matching user account by name (for portal data)
# This is a simple match - in production might need more sophisticated matching
portal_user = None
name_parts = person.full_name().upper().split()
if len(name_parts) >= 2:
# Try to find user where first/last name matches
potential_users = db.query(User).filter(
User.name.isnot(None)
).all()
for u in potential_users:
if u.name:
user_name_parts = u.name.upper().split()
# Check if at least first and last name match
if len(user_name_parts) >= 2:
if (user_name_parts[-1] in name_parts and # Last name match
any(part in user_name_parts for part in name_parts[:-1])): # First name match
portal_user = u
break
return render_template('person_detail.html',
person=person,
company_roles=company_roles,
portal_user=portal_user
)
finally:
db.close()
@bp.route('/company/<slug>/recommend', methods=['GET', 'POST'])
def company_recommend(slug):
"""Create recommendation for a company - requires login"""
db = SessionLocal()
try:
# Get company
company = db.query(Company).filter_by(slug=slug).first()
if not company:
flash('Firma nie znaleziona.', 'error')
return redirect(url_for('index'))
# Handle POST (form submission)
if request.method == 'POST':
recommendation_text = request.form.get('recommendation_text', '').strip()
service_category = sanitize_input(request.form.get('service_category', ''), 200)
show_contact = request.form.get('show_contact') == '1'
# Validation
if not recommendation_text or len(recommendation_text) < 50:
flash('Rekomendacja musi mieć co najmniej 50 znaków.', 'error')
return render_template('company/recommend.html', company=company)
if len(recommendation_text) > 2000:
flash('Rekomendacja może mieć maksymalnie 2000 znaków.', 'error')
return render_template('company/recommend.html', company=company)
# Prevent self-recommendation
if current_user.company_id == company.id:
flash('Nie możesz polecać własnej firmy.', 'error')
return redirect(url_for('company_detail', company_id=company.id))
# Check for duplicate (user already recommended this company)
existing = db.query(CompanyRecommendation).filter_by(
user_id=current_user.id,
company_id=company.id
).first()
if existing:
flash('Już poleciłeś tę firmę. Możesz edytować swoją wcześniejszą rekomendację.', 'error')
return redirect(url_for('company_detail', company_id=company.id))
# Create recommendation
recommendation = CompanyRecommendation(
company_id=company.id,
user_id=current_user.id,
recommendation_text=recommendation_text,
service_category=service_category if service_category else None,
show_contact=show_contact,
status='pending'
)
db.add(recommendation)
db.commit()
flash('Dziękujemy! Twoja rekomendacja została przesłana i oczekuje na moderację.', 'success')
return redirect(url_for('company_detail', company_id=company.id))
# Handle GET (show form)
return render_template('company/recommend.html', company=company)
finally:
db.close()
@bp.route('/search')
@login_required
def search():
"""Search companies and people with advanced matching - requires login"""
query = request.args.get('q', '')
category_id = request.args.get('category', type=int)
db = SessionLocal()
try:
# Use new SearchService with synonym expansion, NIP/REGON lookup, and fuzzy matching
results = search_companies(db, query, category_id, limit=50)
# Extract companies from SearchResult objects
companies = [r.company for r in results]
# Log search to analytics (SearchQuery table)
if query:
try:
analytics_session_id = session.get('analytics_session_id')
session_db_id = None
if analytics_session_id:
user_session = db.query(UserSession).filter_by(session_id=analytics_session_id).first()
if user_session:
session_db_id = user_session.id
search_query = SearchQuery(
session_id=session_db_id,
user_id=current_user.id if current_user.is_authenticated else None,
query=query[:500],
query_normalized=query.lower().strip()[:500],
results_count=len(companies),
has_results=len(companies) > 0,
search_type='main',
filters_used={'category_id': category_id} if category_id else None
)
db.add(search_query)
db.commit()
except Exception as e:
logger.error(f"Search logging error: {e}")
db.rollback()
# For debugging/analytics - log search stats
if query:
match_types = {}
for r in results:
match_types[r.match_type] = match_types.get(r.match_type, 0) + 1
logger.info(f"Search '{query}': {len(companies)} results, types: {match_types}")
# Search people by name (partial match)
people_results = []
if query and len(query) >= 2:
q = f"%{query}%"
people_results = db.query(Person).filter(
or_(
Person.imiona.ilike(q),
Person.nazwisko.ilike(q),
func.concat(Person.imiona, ' ', Person.nazwisko).ilike(q)
)
).limit(20).all()
# For each person, get their company connections count
for person in people_results:
person.company_count = len(set(
r.company_id for r in person.company_roles
if r.company and r.company.status == 'active'
))
logger.info(f"Search '{query}': {len(people_results)} people found")
return render_template(
'search_results.html',
companies=companies,
people=people_results,
query=query,
category_id=category_id,
result_count=len(companies)
)
finally:
db.close()
@bp.route('/aktualnosci')
@login_required
def events():
"""Company events and news - latest updates from member companies"""
event_type_filter = request.args.get('type', '')
company_id = request.args.get('company', type=int)
page = request.args.get('page', 1, type=int)
per_page = 20
db = SessionLocal()
try:
# Build query
query = db.query(CompanyEvent).join(Company)
# Apply filters
if event_type_filter:
query = query.filter(CompanyEvent.event_type == event_type_filter)
if company_id:
query = query.filter(CompanyEvent.company_id == company_id)
# Order by date (newest first)
query = query.order_by(
CompanyEvent.event_date.desc(),
CompanyEvent.created_at.desc()
)
# Pagination
total_events = query.count()
events = query.limit(per_page).offset((page - 1) * per_page).all()
# Get companies with events for filter dropdown
companies_with_events = db.query(Company).join(CompanyEvent).distinct().order_by(Company.name).all()
# Event type statistics
event_types = db.query(
CompanyEvent.event_type,
func.count(CompanyEvent.id)
).group_by(CompanyEvent.event_type).all()
return render_template(
'events.html',
events=events,
companies_with_events=companies_with_events,
event_types=event_types,
event_type_filter=event_type_filter,
company_id=company_id,
page=page,
per_page=per_page,
total_events=total_events,
total_pages=(total_events + per_page - 1) // per_page
)
finally:
db.close()
@bp.route('/nowi-czlonkowie')
@login_required
def new_members():
"""Lista nowych firm członkowskich"""
days = request.args.get('days', 90, type=int)
db = SessionLocal()
try:
cutoff_date = datetime.now() - timedelta(days=days)
new_companies = db.query(Company).filter(
Company.status == 'active',
Company.created_at >= cutoff_date
).order_by(Company.created_at.desc()).all()
return render_template('new_members.html',
companies=new_companies,
days=days,
total=len(new_companies)
)
finally:
db.close()
@bp.route('/mapa-polaczen')
def connections_map():
"""Company-person connections visualization page"""
return render_template('connections_map.html')
@bp.route('/dashboard')
@login_required
def dashboard():
"""User dashboard"""
db = SessionLocal()
try:
# Get user's conversations
conversations = db.query(AIChatConversation).filter_by(
user_id=current_user.id
).order_by(AIChatConversation.updated_at.desc()).limit(10).all()
# Stats
total_conversations = db.query(AIChatConversation).filter_by(user_id=current_user.id).count()
total_messages = db.query(AIChatMessage).join(AIChatConversation).filter(
AIChatConversation.user_id == current_user.id
).count()
# Check for membership application status
has_pending_application = False
has_draft_application = False
pending_application = None
try:
from database import MembershipApplication
pending_application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['submitted', 'under_review', 'changes_requested'])
).first()
has_pending_application = pending_application is not None
if not has_pending_application:
draft = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status == 'draft'
).first()
has_draft_application = draft is not None
except Exception:
pass # MembershipApplication table may not exist yet
return render_template(
'dashboard.html',
conversations=conversations,
total_conversations=total_conversations,
total_messages=total_messages,
has_pending_application=has_pending_application,
has_draft_application=has_draft_application,
pending_application=pending_application
)
finally:
db.close()
@bp.route('/release-notes')
def release_notes():
"""Historia zmian platformy."""
releases = [
{
'version': 'v1.23.0',
'date': '1 lutego 2026',
'badges': ['security', 'new', 'improve'],
'security': [
'<strong>System ról: Migracja z is_admin na 6-poziomową hierarchię</strong> - UNAFFILIATED → MEMBER → EMPLOYEE → MANAGER → OFFICE_MANAGER → ADMIN',
'<strong>NordaGPT: Dostęp tylko dla członków Izby</strong> - nie-członkowie widzą stronę promocyjną',
'<strong>Wiadomości: Dostęp tylko dla członków</strong> - prywatna komunikacja wymaga członkostwa',
'<strong>Tablica B2B: Dostęp tylko dla członków</strong> - ogłoszenia biznesowe dla zrzeszonych',
'<strong>Kontakty: Dostęp tylko dla członków</strong> - dane kontaktowe chronione',
],
'new': [
'NordaGPT: Dedykowana strona promocyjna dla nie-członków z opisem korzyści',
'Dekorator @office_manager_required dla tras wymagających roli kierownika biura',
'Dekorator @member_required dla funkcji wyłącznie członkowskich',
],
'improve': [
'Panel admina: Trasy podzielone na ADMIN-only (użytkownicy, bezpieczeństwo) i OFFICE_MANAGER (treści)',
'Forum: Moderacja oparta na can_moderate_forum() zamiast is_admin',
'Szablony: Użycie can_access_admin_panel() zamiast is_admin w menu',
'Uproszczenie kodu: Kontrola dostępu w jednym miejscu (dekoratory tras)',
],
},
{
'version': 'v1.22.0',
'date': '31 stycznia 2026',
'badges': ['new', 'improve', 'fix'],
'new': [
# MEGA WAŻNE - B2B Interactions
'<strong>Tablica B2B: Przycisk "Jestem zainteresowany"</strong> - sygnał zainteresowania ogłoszeniem',
'<strong>Tablica B2B: Publiczne Q&A</strong> - pytania i odpowiedzi widoczne dla wszystkich',
'<strong>Tablica B2B: Wiadomości z kontekstem</strong> - powiązanie wiadomości z ogłoszeniem',
'Tablica B2B: Lista zainteresowanych widoczna dla autora ogłoszenia',
'Tablica B2B: Badge "B2B" przy wiadomościach powiązanych z ogłoszeniem',
# Read tracking
'<strong>Forum: Avatary "widziane przez"</strong> przy każdej odpowiedzi, nie tylko temacie',
'<strong>Tablica B2B: Avatary "widziane przez"</strong> - kto widział ogłoszenie',
# Admin
'<strong>Admin: Moduł zarządzania firmami</strong> - lista, edycja, statystyki',
'<strong>Admin: Moduł zarządzania osobami</strong> - dane z KRS, powiązania z firmami',
'<strong>Admin: Dashboard statusu</strong> - SSL, deploy, bezpieczeństwo, API metrics',
'<strong>Audyt logowań</strong> - śledzenie zdarzeń login/logout w systemie',
# Forum modernization
'<strong>Forum: Reakcje emoji</strong> - możliwość reagowania na tematy i odpowiedzi (👍 ❤️)',
'<strong>Forum: Subskrypcje tematów</strong> - powiadomienia o nowych odpowiedziach',
'<strong>Forum: Edycja postów</strong> - użytkownicy mogą edytować swoje wpisy (do 24h)',
'<strong>Forum: Zgłaszanie treści</strong> - użytkownicy mogą zgłaszać nieodpowiednie wpisy',
'<strong>Forum: Oznaczanie rozwiązań</strong> - admin może oznaczyć odpowiedź jako rozwiązanie',
'Forum: Statystyki użytkownika (tematy, odpowiedzi, rozwiązania)',
'Forum: Obsługa Markdown w treści postów',
'Forum: Wzmianki @użytkownik z powiadomieniami',
# Forum admin tools
'<strong>Forum: Panel analityki</strong> - statystyki, wykresy aktywności, ranking użytkowników',
'Forum: Eksport aktywności do CSV z filtrem dat',
'Forum: Akcje zbiorcze (przypinanie, blokowanie, zmiana statusu, usuwanie)',
'Forum: Przenoszenie tematów między kategoriami',
'Forum: Łączenie wielu tematów w jeden',
'Forum: Wyszukiwarka admina (także usunięte treści)',
'Forum: Log aktywności użytkownika',
'Forum: Soft-delete z możliwością przywracania treści',
# Menu
'Menu admina: Dodano linki do Forum, Ogłoszeń i Insights AI',
],
'improve': [
'<strong>Architektura: Modularyzacja kodu</strong> - migracja do blueprintów Flask',
'Forum: Etykieta "(Ty)" przy własnym awatarze w "widziane przez"',
'Dashboard statusu: Poprawiony układ kart SSL (2 kolumny + issuer)',
'Usunięto nieużywany kod z głównego pliku aplikacji',
],
'fix': [
'<strong>NordaGPT: Naprawiono wyświetlanie paska wpisywania</strong> - pole było przycięte dla adminów',
],
},
{
'version': 'v1.21.0',
'date': '30 stycznia 2026',
'badges': ['new', 'improve', 'fix'],
'new': [
# MEGA WAŻNE - Konto użytkownika
'<strong>Moje konto: Nowa sekcja ustawień</strong> - edycja danych, prywatność, bezpieczeństwo, blokady',
'<strong>Forum: Panel moderacji dla admina</strong> - usuwanie wątków i odpowiedzi, przypinanie, blokowanie',
'<strong>Tablica B2B: Panel moderacji dla admina</strong> - usuwanie i dezaktywacja ogłoszeń',
# UX
'Formularze: Ikonka oka przy polach hasła (podgląd wpisywanego hasła)',
'Forum: Ładny modal potwierdzenia zamiast systemowego okna',
'Tablica B2B: Ładny modal potwierdzenia przy moderacji',
# Feedback
'Forum: Wątek "Zgłoszenia i sugestie użytkowników" do zbierania feedbacku',
],
'improve': [
'Strona rejestracji: Poprawna nazwa "Norda Biznes Partner"',
'Strona maintenance: Przyjazna strona podczas aktualizacji (502/503/504)',
],
'fix': [
'<strong>Reset hasła: Automatyczna weryfikacja emaila</strong> - użytkownik nie musi ponownie weryfikować',
'Akademia: Usunięto placeholder video "Jak korzystać z NordaGPT"',
],
},
{
'version': 'v1.20.0',
'date': '29 stycznia 2026',
'badges': ['new', 'improve', 'fix'],
'new': [
# MEGA WAŻNE - AI
'<strong>NordaGPT: Upgrade do Gemini 3 Flash Preview</strong> - najnowszy model Google AI',
'<strong>NordaGPT: Dwa modele do wyboru</strong> - Flash (darmowy) i Pro (płatny, lepszy)',
'NordaGPT: 7x lepsze rozumowanie, thinking mode, 78% na SWE-bench',
'NordaGPT: Osobne klucze API dla Free tier i Paid tier',
'NordaGPT: Wyświetlanie szacowanego kosztu miesięcznego',
# MEGA WAŻNE - PWA
'<strong>PWA: Aplikacja mobilna</strong> - możliwość instalacji na telefonie (iOS/Android)',
'PWA: Web Manifest z ikonami 192px i 512px',
'PWA: Apple Touch Icon dla urządzeń iOS',
# Aktualności
'Aktualności: Obsługa wielu kategorii dla jednego ogłoszenia',
'Aktualności: Nowe kategorie - Wewnętrzne, Zewnętrzne, Wydarzenie, Okazja biznesowa, Partnerstwo',
# Edukacja
'Edukacja: Integracja wideo z portalem (modal player)',
'Edukacja: Wideo "Wprowadzenie do Norda Biznes Partner"',
# Admin
'<strong>Admin: Powiadomienia email o nowych rejestracjach</strong> - mail przy każdej rejestracji',
],
'improve': [
'Strona główna: Nowa ikona NordaGPT',
'Stopka: Usunięcie nieaktywnych linków',
],
'fix': [
'Tablica B2B: Naprawiono błąd 500 przy dodawaniu ogłoszeń',
'Kalendarz: Naprawiono błąd 500 przy dodawaniu wydarzeń',
'Kontakty: Naprawiono nawigację w module',
],
},
{
'version': 'v1.19.0',
'date': '28 stycznia 2026',
'badges': ['new', 'improve', 'security'],
'new': [
# MEGA WAŻNE - Prywatność
'<strong>Prywatność: Ukrywanie telefonu i emaila</strong> w profilu (Ustawienia → Prywatność)',
'<strong>Blokowanie użytkowników</strong> - możliwość blokowania kontaktów (Ustawienia → Blokady)',
'Prywatność: Preferencje kanałów kontaktu (email, telefon, portal)',
'Blokowanie: Bidirectional - zablokowany nie może wysłać wiadomości',
# MEGA WAŻNE - Kategorie
'<strong>Kategorie: Hierarchiczna struktura</strong> - 4 główne grupy branżowe',
'Katalog: Żółta kategoria "Do uzupełnienia" dla 27 firm',
'Kategorie: Nowe podkategorie (Budownictwo ogólne, Produkcja ogólna, Usługi finansowe)',
# Nowe sekcje
'<strong>Edukacja: Nowa sekcja</strong> Platforma Edukacyjna w menu',
'Insights: Panel dla adminów do zbierania feedbacku',
'Health: Monitorowanie nowych endpointów',
],
'improve': [
'Katalog: Tylko aktywna kategoria podświetlona',
'Kategorie: Sortowanie malejąco po liczbie firm',
],
'security': [
'<strong>RODO: Automatyczne maskowanie danych wrażliwych</strong> w czacie (PESEL, karty, IBAN)',
'<strong>Chat: Izolacja sesji</strong> - użytkownicy nie widzą pytań innych',
'Admin: Anonimizacja zapytań w panelu analityki',
],
},
{
'version': 'v1.17.0',
'date': '26 stycznia 2026',
'badges': ['new'],
'new': [
'<strong>Aktualności: Nowa sekcja</strong> dla członków (Społeczność → Aktualności)',
'Aktualności: Panel administracyjny do zarządzania ogłoszeniami',
'Aktualności: Kategorie, statusy publikacji, przypinanie',
'Aktualności: Linki zewnętrzne i załączniki PDF',
'Pierwsze ogłoszenia: Baza noclegowa ARP, Konkurs Tytani Przedsiębiorczości',
],
},
{
'version': 'v1.16.0',
'date': '14 stycznia 2026',
'badges': ['new', 'improve', 'fix'],
'new': [
# MEGA WAŻNE - Bezpieczeństwo
'<strong>GeoIP Blocking</strong> - blokowanie krajów wysokiego ryzyka (RU, CN, KP, IR, BY)',
'<strong>Email: Własna domena</strong> - wysyłka z noreply@nordabiznes.pl (DKIM, SPF, DMARC)',
# Raporty
'<strong>Raporty: Nowa sekcja</strong> - staż członkostwa, Social Media, struktura branżowa',
'Profil firmy: Data przystąpienia do Izby NORDA z kartą stażu',
'Integracja: API CEIDG do pobierania danych JDG',
'Bezpieczeństwo: Panel z oceną wszystkich mechanizmów ochrony',
],
'improve': [
'Dane firm: Rok założenia uzupełniony dla 71 z 111 firm (64%)',
'Import dat przystąpienia: 57 firm z historią od 1997 roku',
],
'fix': [
'Analityka: Polskie znaki i pełne nazwy użytkowników',
],
},
{
'version': 'v1.15.0',
'date': '13 stycznia 2026',
'badges': ['new', 'improve', 'fix'],
'new': [
# MEGA WAŻNE - NordaGPT
'<strong>NordaGPT: Rozszerzony kontekst AI</strong> - rekomendacje, kalendarz, B2B, forum, KRS',
'<strong>NordaGPT: Klikalne linki</strong> URL i email w odpowiedziach AI',
'<strong>NordaGPT: Banner na stronie głównej</strong> z szybkim dostępem do chatu',
# Kalendarz
'<strong>Kalendarz: Widok siatki miesięcznej</strong> z Quick RSVP',
'Kalendarz: Banner wydarzenia na stronie głównej z uczestnikami',
# AI i Audyty
'<strong>AI Enrichment</strong> - wzbogacanie danych firm przez AI z web search',
'<strong>KRS Audit</strong> - parsowanie dokumentów PDF, progress bar',
'<strong>Analityka: Panel /admin/analytics</strong> - śledzenie sesji użytkowników',
# Profile
'Profil firmy: Wszystkie kody PKD, dane właściciela CEIDG',
'Profil firmy: Zielone badge dla osób zweryfikowanych w KRS',
],
'improve': [
'Lepsze formatowanie odpowiedzi AI (Markdown)',
'Banner NordaGPT minimalizowalny',
],
'fix': [
'Rate limit logowania i audytu SEO zwiększony',
],
},
{
'version': 'v1.14.0',
'date': '12 stycznia 2026',
'badges': ['new', 'improve', 'fix'],
'new': [
'<strong>Audyt GBP: Pełny audyt</strong> z Google Places API dla wszystkich firm',
'Audyt GBP: Sekcja edukacyjna "Jak działa wizytówka Google?"',
'Audyty: Sekcje inline na profilu firmy (SEO, GBP, Social Media, IT)',
],
'improve': [
'Ujednolicona 5-poziomowa skala kolorów dla audytów',
'Social Media: Wynik jako procent zamiast liczby platform',
],
'fix': [
'Audyt GBP: Kategorie Google po polsku',
],
},
{
'version': 'v1.13.0',
'date': '11 stycznia 2026',
'badges': ['new', 'improve'],
'new': [
# MEGA WAŻNE
'<strong>Mapa Powiązań</strong> - interaktywna wizualizacja firm i osób (D3.js)',
'<strong>Profile osób</strong> (/osoba) - dane z KRS/CEIDG i portalu',
'<strong>AI Learning</strong> - uczenie chatbota z feedbacku użytkowników',
# Inne
'Wyszukiwarka osób z częściowym dopasowaniem',
'Logo firm w wynikach wyszukiwania',
'Panel AI Usage: szczegółowy widok per użytkownik',
],
'improve': [
'Mapa: fullscreen modal, etykiety przy hover',
'System toastów zamiast natywnych dialogów',
],
},
{
'version': 'v1.11.0',
'date': '10 stycznia 2026',
'badges': ['new', 'improve', 'security'],
'new': [
# MEGA WAŻNE
'<strong>Forum: Załączniki obrazów</strong> - drag & drop, Ctrl+V, do 10 plików',
'<strong>Forum: Kategorie i statusy</strong> zgłoszeń (Propozycja, Błąd, Pytanie)',
'<strong>Dokumentacja architektury</strong> - 19 plików, diagramy C4, Mermaid',
],
'improve': [
'Bezpieczny upload z walidacją magic bytes',
],
'security': [
'<strong>Usunięcie hardcoded credentials</strong> z kodu źródłowego',
'Zmiana hasła PostgreSQL na produkcji',
],
},
{
'version': 'v1.9.0',
'date': '9 stycznia 2026',
'badges': ['new', 'improve'],
'new': [
'<strong>Panel Audyt GBP</strong> - przegląd profili Google Business',
'<strong>Panel Audyt Social</strong> - pokrycie Social Media',
'<strong>Tworzenie użytkowników z AI</strong> - wklejanie tekstu/screenshotów',
],
'improve': [
'Nowy pasek Admin z pogrupowanymi funkcjami',
],
},
{
'version': 'v1.8.0',
'date': '8 stycznia 2026',
'badges': ['new'],
'new': [
'<strong>Panel Audyt IT</strong> - kompleksowy audyt infrastruktury IT firm',
'Eksport audytów IT do CSV',
],
},
{
'version': 'v1.7.0',
'date': '6 stycznia 2026',
'badges': ['new'],
'new': [
'<strong>Panel Audyt SEO</strong> - analiza wydajności stron www firm',
'<strong>Integracja z Google PageSpeed Insights API</strong>',
],
},
{
'version': 'v1.6.0',
'date': '29 grudnia 2025',
'badges': ['new'],
'new': [
'<strong>System newsów</strong> i wzmianek medialnych o firmach',
'Panel moderacji newsów dla adminów',
'<strong>Integracja z Brave Search API</strong>',
],
},
{
'version': 'v1.5.0',
'date': '15 grudnia 2025',
'badges': ['new', 'improve'],
'new': [
'<strong>Panel Social Media</strong> - zarządzanie profilami społecznościowymi',
'Weryfikacja aktywności profili Social Media',
],
'improve': [
'Ulepszony profil firmy z sekcją Social Media',
],
},
{
'version': 'v1.4.0',
'date': '1 grudnia 2025',
'badges': ['new'],
'new': [
'<strong>System rekomendacji</strong> między firmami',
'<strong>Panel składek członkowskich</strong>',
'<strong>Kalendarz wydarzeń</strong> Norda Biznes',
],
},
{
'version': 'v1.3.0',
'date': '28 listopada 2025',
'badges': ['new', 'improve'],
'new': [
'<strong>Chatbot AI (NordaGPT)</strong> z wiedzą o wszystkich firmach',
'<strong>Wyszukiwarka firm</strong> z synonimami i fuzzy matching',
],
'improve': [
'Ulepszony SearchService z PostgreSQL FTS',
],
},
{
'version': 'v1.2.0',
'date': '25 listopada 2025',
'badges': ['new'],
'new': [
'<strong>System wiadomości prywatnych</strong> między użytkownikami',
'Powiadomienia o nowych wiadomościach',
],
},
{
'version': 'v1.1.0',
'date': '24 listopada 2025',
'badges': ['new', 'improve'],
'new': [
'<strong>Rejestracja i logowanie</strong> użytkowników',
'Profile użytkowników powiązane z firmami',
],
'improve': [
'Responsywny design na urządzenia mobilne',
],
},
{
'version': 'v1.0.0',
'date': '23 listopada 2025',
'badges': ['new'],
'new': [
'<strong>Oficjalny start platformy Norda Biznes Partner</strong>',
'<strong>Katalog 111 firm członkowskich</strong>',
'Wyszukiwarka firm po nazwie, kategorii, usługach',
'Profile firm z pełnymi danymi kontaktowymi',
],
},
]
# Statystyki (używa globalnej stałej COMPANY_COUNT_MARKETING)
db = SessionLocal()
try:
stats = {
'companies': COMPANY_COUNT_MARKETING,
'categories': db.query(Category).filter(Category.parent_id.isnot(None)).count(),
}
finally:
db.close()
return render_template('release_notes.html', releases=releases, stats=stats)