""" 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/') 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/') 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/') 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//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.24.0', 'date': '2 lutego 2026', 'badges': ['new', 'improve'], 'new': [ 'Środowisko staging - VM 248 (staging.nordabiznes.pl) do testowania zmian', 'Automatyczne testy CI/CD - GitHub Actions uruchamia testy przy każdym push', 'Testy jednostkowe - pytest z fixtures dla sesji użytkownika', 'Testy bezpieczeństwa - OWASP Top 10 (SQL injection, XSS, CSRF)', 'Testy E2E - Playwright z prawdziwą przeglądarką', 'Smoke testy produkcyjne - automatyczna weryfikacja po deploymencie', ], 'improve': [ 'Pre-commit hooks dla kontroli jakości kodu', 'Konfiguracja Ruff (linter + formatter)', 'Badge CI/CD w README', ], }, { 'version': 'v1.23.0', 'date': '1 lutego 2026', 'badges': ['security', 'new', 'improve', 'fix'], 'security': [ 'System ról: Migracja z is_admin na 6-poziomową hierarchię - UNAFFILIATED → MEMBER → EMPLOYEE → MANAGER → OFFICE_MANAGER → ADMIN', 'NordaGPT: Dostęp tylko dla członków Izby - nie-członkowie widzą stronę promocyjną', 'Wiadomości: Dostęp tylko dla członków - prywatna komunikacja wymaga członkostwa', 'Tablica B2B: Dostęp tylko dla członków - ogłoszenia biznesowe dla zrzeszonych', 'Kontakty: Dostęp tylko dla członków - dane kontaktowe chronione', ], 'new': [ # MEGA WAŻNE - System członkostwa 'System aplikacji członkowskich - kompletny workflow dołączania do Izby', 'Integracja z Białą Listą VAT - automatyczne wyszukiwanie KRS po NIP', 'Weryfikacja danych z rejestrów - porównanie danych użytkownika z KRS/CEIDG', 'Workflow akceptacji zmian - użytkownik zatwierdza lub odrzuca dane z rejestrów', 'Historia workflow - timeline wszystkich akcji w procesie aplikacji', 'Powiadomienia dla admina - alert gdy użytkownik zaakceptuje/odrzuci zmiany', # Dane rejestrowe 'Sekcja "Dane z rejestrów urzędowych" - dedykowana sekcja KRS/CEIDG w profilu firmy', 'Pełne dane KRS - wszystkie 10 pól z rejestru (kapitał, reprezentacja, wspólnicy)', 'Auto-switch KRS/CEIDG - automatyczny wybór API na podstawie typu firmy', 'Auto-pobieranie KRS przy akceptacji - dane z rejestru importowane automatycznie', # Role i uprawnienia 'NordaGPT: Dedykowana strona promocyjna dla nie-członków', 'Dekorator @office_manager_required dla tras kierownika biura', 'Dekorator @member_required dla funkcji członkowskich', 'Panel zarządzania rolami w admin panelu', # Website updater 'Automatyczna aktualizacja treści stron www - Gemini 3 Flash analizuje strony firm', ], 'improve': [ 'Profil firmy: Usunięcie duplikatów - czytelniejszy układ dla firm KRS', 'Profil firmy: Konsolidacja danych kontaktowych w sekcji KRS', 'Panel admina: Podział tras na ADMIN-only i OFFICE_MANAGER', 'Forum: Moderacja oparta na can_moderate_forum()', 'Szablony: Użycie can_access_admin_panel() w menu', 'Usunięcie sekcji AI-generated (wyróżniki, certyfikaty, metadata)', 'Ukrycie sekcji rekomendacji (oczekuje na zgodę Zarządu)', ], 'fix': [ 'Mapowanie pól Company - poprawne przypisanie adresu przy tworzeniu firmy', 'URL profilu firmy: Użycie company_detail_by_slug zamiast company', 'CSRF token w formularzach członkostwa', 'Pętla przekierowań w /membership/apply', 'Persystencja workflow_history (JSONB flag_modified)', 'Modal potwierdzenia: Zachowanie pendingAction przed zamknięciem', ], }, { 'version': 'v1.22.0', 'date': '31 stycznia 2026', 'badges': ['new', 'improve', 'fix'], 'new': [ # MEGA WAŻNE - B2B Interactions 'Tablica B2B: Przycisk "Jestem zainteresowany" - sygnał zainteresowania ogłoszeniem', 'Tablica B2B: Publiczne Q&A - pytania i odpowiedzi widoczne dla wszystkich', 'Tablica B2B: Wiadomości z kontekstem - 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 'Forum: Avatary "widziane przez" przy każdej odpowiedzi, nie tylko temacie', 'Tablica B2B: Avatary "widziane przez" - kto widział ogłoszenie', # Admin 'Admin: Moduł zarządzania firmami - lista, edycja, statystyki', 'Admin: Moduł zarządzania osobami - dane z KRS, powiązania z firmami', 'Admin: Dashboard statusu - SSL, deploy, bezpieczeństwo, API metrics', 'Audyt logowań - śledzenie zdarzeń login/logout w systemie', # Forum modernization 'Forum: Reakcje emoji - możliwość reagowania na tematy i odpowiedzi (👍 ❤️)', 'Forum: Subskrypcje tematów - powiadomienia o nowych odpowiedziach', 'Forum: Edycja postów - użytkownicy mogą edytować swoje wpisy (do 24h)', 'Forum: Zgłaszanie treści - użytkownicy mogą zgłaszać nieodpowiednie wpisy', 'Forum: Oznaczanie rozwiązań - 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 'Forum: Panel analityki - 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': [ 'Architektura: Modularyzacja kodu - 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': [ 'NordaGPT: Naprawiono wyświetlanie paska wpisywania - 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 'Moje konto: Nowa sekcja ustawień - edycja danych, prywatność, bezpieczeństwo, blokady', 'Forum: Panel moderacji dla admina - usuwanie wątków i odpowiedzi, przypinanie, blokowanie', 'Tablica B2B: Panel moderacji dla admina - 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': [ 'Reset hasła: Automatyczna weryfikacja emaila - 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 'NordaGPT: Upgrade do Gemini 3 Flash Preview - najnowszy model Google AI', 'NordaGPT: Dwa modele do wyboru - 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 'PWA: Aplikacja mobilna - 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 'Admin: Powiadomienia email o nowych rejestracjach - 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ść 'Prywatność: Ukrywanie telefonu i emaila w profilu (Ustawienia → Prywatność)', 'Blokowanie użytkowników - 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 'Kategorie: Hierarchiczna struktura - 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 'Edukacja: Nowa sekcja 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': [ 'RODO: Automatyczne maskowanie danych wrażliwych w czacie (PESEL, karty, IBAN)', 'Chat: Izolacja sesji - użytkownicy nie widzą pytań innych', 'Admin: Anonimizacja zapytań w panelu analityki', ], }, { 'version': 'v1.17.0', 'date': '26 stycznia 2026', 'badges': ['new'], 'new': [ 'Aktualności: Nowa sekcja 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 'GeoIP Blocking - blokowanie krajów wysokiego ryzyka (RU, CN, KP, IR, BY)', 'Email: Własna domena - wysyłka z noreply@nordabiznes.pl (DKIM, SPF, DMARC)', # Raporty 'Raporty: Nowa sekcja - 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 'NordaGPT: Rozszerzony kontekst AI - rekomendacje, kalendarz, B2B, forum, KRS', 'NordaGPT: Klikalne linki URL i email w odpowiedziach AI', 'NordaGPT: Banner na stronie głównej z szybkim dostępem do chatu', # Kalendarz 'Kalendarz: Widok siatki miesięcznej z Quick RSVP', 'Kalendarz: Banner wydarzenia na stronie głównej z uczestnikami', # AI i Audyty 'AI Enrichment - wzbogacanie danych firm przez AI z web search', 'KRS Audit - parsowanie dokumentów PDF, progress bar', 'Analityka: Panel /admin/analytics - ś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': [ 'Audyt GBP: Pełny audyt 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 'Mapa Powiązań - interaktywna wizualizacja firm i osób (D3.js)', 'Profile osób (/osoba) - dane z KRS/CEIDG i portalu', 'AI Learning - 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 'Forum: Załączniki obrazów - drag & drop, Ctrl+V, do 10 plików', 'Forum: Kategorie i statusy zgłoszeń (Propozycja, Błąd, Pytanie)', 'Dokumentacja architektury - 19 plików, diagramy C4, Mermaid', ], 'improve': [ 'Bezpieczny upload z walidacją magic bytes', ], 'security': [ 'Usunięcie hardcoded credentials z kodu źródłowego', 'Zmiana hasła PostgreSQL na produkcji', ], }, { 'version': 'v1.9.0', 'date': '9 stycznia 2026', 'badges': ['new', 'improve'], 'new': [ 'Panel Audyt GBP - przegląd profili Google Business', 'Panel Audyt Social - pokrycie Social Media', 'Tworzenie użytkowników z AI - wklejanie tekstu/screenshotów', ], 'improve': [ 'Nowy pasek Admin z pogrupowanymi funkcjami', ], }, { 'version': 'v1.8.0', 'date': '8 stycznia 2026', 'badges': ['new'], 'new': [ 'Panel Audyt IT - kompleksowy audyt infrastruktury IT firm', 'Eksport audytów IT do CSV', ], }, { 'version': 'v1.7.0', 'date': '6 stycznia 2026', 'badges': ['new'], 'new': [ 'Panel Audyt SEO - analiza wydajności stron www firm', 'Integracja z Google PageSpeed Insights API', ], }, { 'version': 'v1.6.0', 'date': '29 grudnia 2025', 'badges': ['new'], 'new': [ 'System newsów i wzmianek medialnych o firmach', 'Panel moderacji newsów dla adminów', 'Integracja z Brave Search API', ], }, { 'version': 'v1.5.0', 'date': '15 grudnia 2025', 'badges': ['new', 'improve'], 'new': [ 'Panel Social Media - 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': [ 'System rekomendacji między firmami', 'Panel składek członkowskich', 'Kalendarz wydarzeń Norda Biznes', ], }, { 'version': 'v1.3.0', 'date': '28 listopada 2025', 'badges': ['new', 'improve'], 'new': [ 'Chatbot AI (NordaGPT) z wiedzą o wszystkich firmach', 'Wyszukiwarka firm z synonimami i fuzzy matching', ], 'improve': [ 'Ulepszony SearchService z PostgreSQL FTS', ], }, { 'version': 'v1.2.0', 'date': '25 listopada 2025', 'badges': ['new'], 'new': [ 'System wiadomości prywatnych między użytkownikami', 'Powiadomienia o nowych wiadomościach', ], }, { 'version': 'v1.1.0', 'date': '24 listopada 2025', 'badges': ['new', 'improve'], 'new': [ 'Rejestracja i logowanie 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': [ 'Oficjalny start platformy Norda Biznes Partner', 'Katalog 111 firm członkowskich', '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)