diff --git a/blueprints/__init__.py b/blueprints/__init__.py index f974edd..a331f4b 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -265,6 +265,15 @@ def register_blueprints(app): 'api_notification_mark_read': 'messages.api_notification_mark_read', 'api_notifications_mark_all_read': 'messages.api_notifications_mark_all_read', 'api_notifications_unread_count': 'messages.api_notifications_unread_count', + 'group_compose': 'messages.group_compose', + 'group_create': 'messages.group_create', + 'group_view': 'messages.group_view', + 'group_send': 'messages.group_send', + 'group_manage': 'messages.group_manage', + 'group_add_member': 'messages.group_add_member', + 'group_remove_member': 'messages.group_remove_member', + 'group_change_role': 'messages.group_change_role', + 'group_edit': 'messages.group_edit', }) logger.info("Created messages endpoint aliases") except ImportError as e: diff --git a/blueprints/messages/__init__.py b/blueprints/messages/__init__.py index 580abb2..bc6c6a0 100644 --- a/blueprints/messages/__init__.py +++ b/blueprints/messages/__init__.py @@ -2,7 +2,7 @@ Messages Blueprint ================== -Private messages and notifications routes. +Private messages, group messages, and notifications routes. """ from flask import Blueprint @@ -10,3 +10,4 @@ from flask import Blueprint bp = Blueprint('messages', __name__) from . import routes # noqa: E402, F401 +from . import group_routes # noqa: E402, F401 diff --git a/blueprints/messages/group_routes.py b/blueprints/messages/group_routes.py new file mode 100644 index 0000000..14ba8e1 --- /dev/null +++ b/blueprints/messages/group_routes.py @@ -0,0 +1,594 @@ +""" +Group Messages Routes +===================== + +Group chat creation, viewing, and messaging. +""" + +import os +from datetime import datetime + +from flask import render_template, request, redirect, url_for, flash, jsonify +from flask_login import login_required, current_user + +from . import bp +from sqlalchemy.orm import joinedload +from sqlalchemy import func +from database import (SessionLocal, User, Company, UserCompanyPermissions, + MessageGroup, MessageGroupMember, GroupMessage, + MessageAttachment, UserNotification, UserBlock) +from extensions import limiter +from utils.helpers import sanitize_html +from utils.decorators import member_required +from email_service import send_email, build_message_notification_email +from message_upload_service import MessageUploadService + + +def _get_active_norda_members(db, exclude_user_id): + """Pobierz aktywnych członków Nordy do wyboru.""" + users_with_companies = db.query( + User, + Company.name.label('company_name') + ).outerjoin( + UserCompanyPermissions, + UserCompanyPermissions.user_id == User.id + ).outerjoin( + Company, + (Company.id == UserCompanyPermissions.company_id) & (Company.status == 'active') + ).filter( + User.is_active == True, + User.is_verified == True, + User.id != exclude_user_id + ).order_by(User.name).all() + + seen_ids = set() + users = [] + for user, company_name in users_with_companies: + if user.id not in seen_ids: + seen_ids.add(user.id) + user._company_name = company_name + users.append(user) + return users + + +def _check_group_access(db, group_id, user_id): + """Sprawdź czy użytkownik jest członkiem grupy. Zwraca (group, membership) lub (None, None).""" + group = db.query(MessageGroup).filter(MessageGroup.id == group_id).first() + if not group: + return None, None + membership = db.query(MessageGroupMember).filter( + MessageGroupMember.group_id == group_id, + MessageGroupMember.user_id == user_id + ).first() + if not membership: + return None, None + return group, membership + + +def _check_block_for_group(db, group_id, target_user_id): + """Sprawdź czy dodanie użytkownika do grupy jest zablokowane przez UserBlock.""" + member_ids = [m.user_id for m in db.query(MessageGroupMember.user_id).filter( + MessageGroupMember.group_id == group_id + ).all()] + block = db.query(UserBlock).filter( + ((UserBlock.user_id == target_user_id) & (UserBlock.blocked_user_id.in_(member_ids))) | + ((UserBlock.user_id.in_(member_ids)) & (UserBlock.blocked_user_id == target_user_id)) + ).first() + return block is not None + + +@bp.route('/wiadomosci/nowa-grupa') +@login_required +@member_required +def group_compose(): + """Formularz tworzenia grupy""" + db = SessionLocal() + try: + users = _get_active_norda_members(db, current_user.id) + return render_template('messages/group_compose.html', users=users) + finally: + db.close() + + +@bp.route('/wiadomosci/grupa/utworz', methods=['POST']) +@login_required +@member_required +def group_create(): + """Utwórz grupę i wyślij pierwszą wiadomość""" + name = request.form.get('name', '').strip() + content = sanitize_html(request.form.get('content', '').strip()) + member_ids = request.form.getlist('members', type=int) + + if not content: + flash('Treść pierwszej wiadomości jest wymagana.', 'error') + return redirect(url_for('.group_compose')) + + if not member_ids: + flash('Wybierz co najmniej jedną osobę.', 'error') + return redirect(url_for('.group_compose')) + + db = SessionLocal() + try: + # Verify all members exist and are active Norda members + valid_members = db.query(User).filter( + User.id.in_(member_ids), + User.is_active == True, + User.is_verified == True + ).all() + + if not valid_members: + flash('Nie znaleziono wybranych użytkowników.', 'error') + return redirect(url_for('.group_compose')) + + # Check blocks + for member in valid_members: + block = db.query(UserBlock).filter( + ((UserBlock.user_id == current_user.id) & (UserBlock.blocked_user_id == member.id)) | + ((UserBlock.user_id == member.id) & (UserBlock.blocked_user_id == current_user.id)) + ).first() + if block: + flash(f'Nie można dodać użytkownika {member.name or member.email} do grupy.', 'error') + return redirect(url_for('.group_compose')) + + # Create group + group = MessageGroup( + name=name if name else None, + is_named=bool(name), + owner_id=current_user.id + ) + db.add(group) + db.flush() + + # Add owner as member + owner_member = MessageGroupMember( + group_id=group.id, + user_id=current_user.id, + role='owner', + last_read_at=datetime.now() + ) + db.add(owner_member) + + # Add selected members + for member in valid_members: + if member.id != current_user.id: + gm = MessageGroupMember( + group_id=group.id, + user_id=member.id, + role='member', + added_by_id=current_user.id + ) + db.add(gm) + + # Create first message + msg = GroupMessage( + group_id=group.id, + sender_id=current_user.id, + content=content + ) + db.add(msg) + db.flush() + + # Process attachments + if request.files.getlist('attachments'): + upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + files = [f for f in request.files.getlist('attachments') if f and f.filename] + if files: + valid_files, errors = upload_service.validate_files(files) + if errors: + db.rollback() + for err in errors: + flash(err, 'error') + return redirect(url_for('.group_compose')) + for f, filename, ext, size, file_content in valid_files: + stored_filename, _ = upload_service.save_file(file_content, ext) + att = MessageAttachment( + group_message_id=msg.id, + filename=filename, + stored_filename=stored_filename, + file_size=size, + mime_type=upload_service.get_mime_type(ext) + ) + db.add(att) + + group.updated_at = datetime.now() + + # Notifications for all members + sender_name = current_user.name or current_user.email.split('@')[0] + group_display = name if name else 'Nowa grupa' + for member in valid_members: + if member.id != current_user.id: + notif = UserNotification( + user_id=member.id, + title=f'Dodano do grupy: {group_display}', + message=f'{sender_name} utworzył(a) grupę i wysłał(a) pierwszą wiadomość', + notification_type='message', + related_type='group', + related_id=group.id, + action_url=url_for('.group_view', group_id=group.id) + ) + db.add(notif) + + # Email notification + if member.notify_email_messages != False and member.email: + try: + message_url = url_for('.group_view', group_id=group.id, _external=True) + settings_url = url_for('auth.konto_prywatnosc', _external=True) + preview = (content[:200] + '...') if len(content) > 200 else content + email_html, email_text = build_message_notification_email( + sender_name=sender_name, + subject=f'Grupa: {group_display}', + content_preview=preview, + message_url=message_url, + settings_url=settings_url + ) + send_email( + to=[member.email], + subject=f'Nowa grupa: {group_display} — Norda Biznes', + body_text=email_text, + body_html=email_html, + email_type='message_notification', + user_id=member.id, + recipient_name=member.name + ) + except Exception: + import logging + logging.getLogger(__name__).warning(f"Failed to send group email to {member.email}") + + db.commit() + flash('Grupa utworzona!', 'success') + return redirect(url_for('.group_view', group_id=group.id)) + finally: + db.close() + + +@bp.route('/wiadomosci/grupa/') +@login_required +@member_required +def group_view(group_id): + """Widok czatu grupowego""" + db = SessionLocal() + try: + group, membership = _check_group_access(db, group_id, current_user.id) + if not group: + flash('Grupa nie istnieje lub nie masz dostępu.', 'error') + return redirect(url_for('.messages_inbox')) + + # Load messages with senders and attachments + messages = db.query(GroupMessage).options( + joinedload(GroupMessage.sender), + joinedload(GroupMessage.attachments) + ).filter( + GroupMessage.group_id == group_id + ).order_by(GroupMessage.created_at.asc()).all() + + # Load members with user details + members = db.query(MessageGroupMember).options( + joinedload(MessageGroupMember.user) + ).filter( + MessageGroupMember.group_id == group_id + ).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all() + + # Mark as read + membership.last_read_at = datetime.now() + db.commit() + + return render_template('messages/group_view.html', + group=group, + messages=messages, + members=members, + membership=membership + ) + finally: + db.close() + + +@bp.route('/wiadomosci/grupa//wyslij', methods=['POST']) +@login_required +@member_required +def group_send(group_id): + """Wyślij wiadomość do grupy""" + content = sanitize_html(request.form.get('content', '').strip()) + + if not content: + flash('Treść jest wymagana.', 'error') + return redirect(url_for('.group_view', group_id=group_id)) + + db = SessionLocal() + try: + group, membership = _check_group_access(db, group_id, current_user.id) + if not group: + flash('Grupa nie istnieje lub nie masz dostępu.', 'error') + return redirect(url_for('.messages_inbox')) + + msg = GroupMessage( + group_id=group_id, + sender_id=current_user.id, + content=content + ) + db.add(msg) + db.flush() + + # Process attachments + if request.files.getlist('attachments'): + upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + files = [f for f in request.files.getlist('attachments') if f and f.filename] + if files: + valid_files, errors = upload_service.validate_files(files) + if errors: + db.rollback() + for err in errors: + flash(err, 'error') + return redirect(url_for('.group_view', group_id=group_id)) + for f, filename, ext, size, file_content in valid_files: + stored_filename, _ = upload_service.save_file(file_content, ext) + att = MessageAttachment( + group_message_id=msg.id, + filename=filename, + stored_filename=stored_filename, + file_size=size, + mime_type=upload_service.get_mime_type(ext) + ) + db.add(att) + + group.updated_at = datetime.now() + membership.last_read_at = datetime.now() + + # Notify all other members + sender_name = current_user.name or current_user.email.split('@')[0] + group_display = group.name or group.display_name + other_members = db.query(MessageGroupMember).options( + joinedload(MessageGroupMember.user) + ).filter( + MessageGroupMember.group_id == group_id, + MessageGroupMember.user_id != current_user.id + ).all() + + for m in other_members: + notif = UserNotification( + user_id=m.user_id, + title=f'{group_display} — nowa wiadomość', + message=f'{sender_name}: {content[:80]}...' if len(content) > 80 else f'{sender_name}: {content}', + notification_type='message', + related_type='group', + related_id=group.id, + action_url=url_for('.group_view', group_id=group_id) + ) + db.add(notif) + + if m.user and m.user.notify_email_messages != False and m.user.email: + try: + message_url = url_for('.group_view', group_id=group_id, _external=True) + settings_url = url_for('auth.konto_prywatnosc', _external=True) + preview = (content[:200] + '...') if len(content) > 200 else content + email_html, email_text = build_message_notification_email( + sender_name=sender_name, + subject=f'Grupa: {group_display}', + content_preview=preview, + message_url=message_url, + settings_url=settings_url + ) + send_email( + to=[m.user.email], + subject=f'{group_display} — nowa wiadomość od {sender_name}', + body_text=email_text, + body_html=email_html, + email_type='message_notification', + user_id=m.user_id, + recipient_name=m.user.name + ) + except Exception: + import logging + logging.getLogger(__name__).warning(f"Failed to send group email to {m.user.email}") + + db.commit() + return redirect(url_for('.group_view', group_id=group_id)) + finally: + db.close() + + +# ============================================================ +# GROUP MANAGEMENT ROUTES +# ============================================================ + +@bp.route('/wiadomosci/grupa//zarzadzaj') +@login_required +@member_required +def group_manage(group_id): + """Panel zarządzania członkami grupy""" + db = SessionLocal() + try: + group, membership = _check_group_access(db, group_id, current_user.id) + if not group or not membership.can_manage_members: + flash('Brak uprawnień do zarządzania grupą.', 'error') + return redirect(url_for('.group_view', group_id=group_id)) + + members = db.query(MessageGroupMember).options( + joinedload(MessageGroupMember.user) + ).filter( + MessageGroupMember.group_id == group_id + ).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all() + + available_users = _get_active_norda_members(db, current_user.id) + member_ids = {m.user_id for m in members} + available_users = [u for u in available_users if u.id not in member_ids] + + return render_template('messages/group_manage.html', + group=group, + members=members, + membership=membership, + available_users=available_users + ) + finally: + db.close() + + +@bp.route('/wiadomosci/grupa//dodaj-czlonka', methods=['POST']) +@login_required +@member_required +def group_add_member(group_id): + """Dodaj osobę do grupy""" + user_id = request.form.get('user_id', type=int) + + db = SessionLocal() + try: + group, membership = _check_group_access(db, group_id, current_user.id) + if not group or not membership.can_manage_members: + flash('Brak uprawnień.', 'error') + return redirect(url_for('.group_view', group_id=group_id)) + + if not user_id: + flash('Nie wybrano użytkownika.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + # Check user exists and is active + user = db.query(User).filter(User.id == user_id, User.is_active == True).first() + if not user: + flash('Użytkownik nie istnieje.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + # Check not already member + existing = db.query(MessageGroupMember).filter( + MessageGroupMember.group_id == group_id, + MessageGroupMember.user_id == user_id + ).first() + if existing: + flash('Użytkownik jest już członkiem grupy.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + # Check blocks + if _check_block_for_group(db, group_id, user_id): + flash('Nie można dodać tego użytkownika do grupy.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + new_member = MessageGroupMember( + group_id=group_id, + user_id=user_id, + role='member', + added_by_id=current_user.id + ) + db.add(new_member) + + # Notification + adder_name = current_user.name or current_user.email.split('@')[0] + group_display = group.name or group.display_name + notif = UserNotification( + user_id=user_id, + title=f'Dodano do grupy: {group_display}', + message=f'{adder_name} dodał(a) Cię do grupy', + notification_type='message', + related_type='group', + related_id=group.id, + action_url=url_for('.group_view', group_id=group_id) + ) + db.add(notif) + + db.commit() + flash(f'Dodano {user.name or user.email} do grupy.', 'success') + return redirect(url_for('.group_manage', group_id=group_id)) + finally: + db.close() + + +@bp.route('/wiadomosci/grupa//usun-czlonka', methods=['POST']) +@login_required +@member_required +def group_remove_member(group_id): + """Usuń osobę z grupy""" + user_id = request.form.get('user_id', type=int) + + db = SessionLocal() + try: + group, membership = _check_group_access(db, group_id, current_user.id) + if not group or not membership.can_manage_members: + flash('Brak uprawnień.', 'error') + return redirect(url_for('.group_view', group_id=group_id)) + + if user_id == group.owner_id: + flash('Nie można usunąć właściciela grupy.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + target = db.query(MessageGroupMember).filter( + MessageGroupMember.group_id == group_id, + MessageGroupMember.user_id == user_id + ).first() + + if not target: + flash('Użytkownik nie jest członkiem grupy.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + # Moderator cannot remove another moderator (only owner can) + if target.role == 'moderator' and not membership.is_owner: + flash('Tylko właściciel może usunąć moderatora.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + user = db.query(User).filter(User.id == user_id).first() + db.delete(target) + db.commit() + flash(f'Usunięto {user.name or user.email if user else "użytkownika"} z grupy.', 'success') + return redirect(url_for('.group_manage', group_id=group_id)) + finally: + db.close() + + +@bp.route('/wiadomosci/grupa//zmien-role', methods=['POST']) +@login_required +@member_required +def group_change_role(group_id): + """Zmień rolę członka (tylko owner)""" + user_id = request.form.get('user_id', type=int) + new_role = request.form.get('role') + + if new_role not in ('moderator', 'member'): + flash('Nieprawidłowa rola.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + db = SessionLocal() + try: + group, membership = _check_group_access(db, group_id, current_user.id) + if not group or not membership.is_owner: + flash('Tylko właściciel może zmieniać role.', 'error') + return redirect(url_for('.group_view', group_id=group_id)) + + target = db.query(MessageGroupMember).filter( + MessageGroupMember.group_id == group_id, + MessageGroupMember.user_id == user_id + ).first() + + if not target or target.is_owner: + flash('Nie można zmienić roli tego użytkownika.', 'error') + return redirect(url_for('.group_manage', group_id=group_id)) + + target.role = new_role + db.commit() + + role_label = 'moderatora' if new_role == 'moderator' else 'uczestnika' + user = db.query(User).filter(User.id == user_id).first() + flash(f'{user.name or user.email if user else "Użytkownik"} — rola zmieniona na {role_label}.', 'success') + return redirect(url_for('.group_manage', group_id=group_id)) + finally: + db.close() + + +@bp.route('/wiadomosci/grupa//edytuj', methods=['POST']) +@login_required +@member_required +def group_edit(group_id): + """Edytuj nazwę i opis grupy (tylko owner)""" + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + + db = SessionLocal() + try: + group, membership = _check_group_access(db, group_id, current_user.id) + if not group or not membership.is_owner: + flash('Tylko właściciel może edytować grupę.', 'error') + return redirect(url_for('.group_view', group_id=group_id)) + + group.name = name if name else None + group.is_named = bool(name) + group.description = description if description else None + db.commit() + flash('Grupa zaktualizowana.', 'success') + return redirect(url_for('.group_manage', group_id=group_id)) + finally: + db.close() diff --git a/blueprints/messages/routes.py b/blueprints/messages/routes.py index 030fb95..87d9798 100644 --- a/blueprints/messages/routes.py +++ b/blueprints/messages/routes.py @@ -31,6 +31,7 @@ def messages_inbox(): """Skrzynka odbiorcza""" page = request.args.get('page', 1, type=int) per_page = 20 + search_query = request.args.get('q', '').strip() db = SessionLocal() try: @@ -38,9 +39,24 @@ def messages_inbox(): joinedload(PrivateMessage.attachments) ).filter( PrivateMessage.recipient_id == current_user.id - ).order_by(PrivateMessage.created_at.desc()) + ) - total = db.query(PrivateMessage).filter(PrivateMessage.recipient_id == current_user.id).count() + # Search filtering + if search_query: + from sqlalchemy import or_, func + search_pattern = f'%{search_query}%' + query = query.join(User, PrivateMessage.sender_id == User.id).filter( + or_( + PrivateMessage.subject.ilike(search_pattern), + func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern), + User.name.ilike(search_pattern), + User.email.ilike(search_pattern) + ) + ) + + query = query.order_by(PrivateMessage.created_at.desc()) + + total = query.count() messages = query.limit(per_page).offset((page - 1) * per_page).all() unread_count = db.query(PrivateMessage).filter( @@ -48,11 +64,65 @@ def messages_inbox(): PrivateMessage.is_read == False ).count() + # Fetch group chats for current user + from database import MessageGroupMember, MessageGroup, GroupMessage + + group_items = [] + group_memberships = db.query(MessageGroupMember).filter( + MessageGroupMember.user_id == current_user.id + ).all() + + for ms in group_memberships: + group = db.query(MessageGroup).filter(MessageGroup.id == ms.group_id).first() + if not group: + continue + + # Apply search filter to groups + if search_query: + from sqlalchemy import or_, func + search_pattern = f'%{search_query}%' + # Check group name or any message content + name_match = group.name and search_query.lower() in group.name.lower() + content_match = db.query(GroupMessage).filter( + GroupMessage.group_id == group.id, + func.regexp_replace(GroupMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern) + ).first() + if not name_match and not content_match: + continue + + last_msg = db.query(GroupMessage).filter( + GroupMessage.group_id == group.id + ).order_by(GroupMessage.created_at.desc()).first() + + grp_unread = 0 + if ms.last_read_at: + grp_unread = db.query(GroupMessage).filter( + GroupMessage.group_id == group.id, + GroupMessage.sender_id != current_user.id, + GroupMessage.created_at > ms.last_read_at + ).count() + else: + grp_unread = db.query(GroupMessage).filter( + GroupMessage.group_id == group.id, + GroupMessage.sender_id != current_user.id + ).count() + + group_items.append({ + 'type': 'group', + 'group': group, + 'last_message': last_msg, + 'unread_count': grp_unread, + 'membership': ms, + 'sort_date': last_msg.created_at if last_msg else group.created_at + }) + return render_template('messages/inbox.html', messages=messages, page=page, total_pages=(total + per_page - 1) // per_page, - unread_count=unread_count + unread_count=unread_count, + search_query=search_query, + group_items=group_items ) finally: db.close() @@ -65,6 +135,7 @@ def messages_sent(): """Wysłane wiadomości""" page = request.args.get('page', 1, type=int) per_page = 20 + search_query = request.args.get('q', '').strip() db = SessionLocal() try: @@ -72,15 +143,30 @@ def messages_sent(): joinedload(PrivateMessage.attachments) ).filter( PrivateMessage.sender_id == current_user.id - ).order_by(PrivateMessage.created_at.desc()) + ) - total = db.query(PrivateMessage).filter(PrivateMessage.sender_id == current_user.id).count() + if search_query: + from sqlalchemy import or_, func + search_pattern = f'%{search_query}%' + query = query.join(User, PrivateMessage.recipient_id == User.id).filter( + or_( + PrivateMessage.subject.ilike(search_pattern), + func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern), + User.name.ilike(search_pattern), + User.email.ilike(search_pattern) + ) + ) + + query = query.order_by(PrivateMessage.created_at.desc()) + + total = query.count() messages = query.limit(per_page).offset((page - 1) * per_page).all() return render_template('messages/sent.html', messages=messages, page=page, - total_pages=(total + per_page - 1) // per_page + total_pages=(total + per_page - 1) // per_page, + search_query=search_query ) finally: db.close() @@ -505,14 +591,33 @@ def messages_reply(message_id): @login_required @member_required def api_unread_count(): - """API: Liczba nieprzeczytanych wiadomości""" + """API: Liczba nieprzeczytanych wiadomości (1:1 + grupowe)""" db = SessionLocal() try: - count = db.query(PrivateMessage).filter( + from sqlalchemy import func + from database import MessageGroupMember, GroupMessage + + # 1:1 unread + pm_count = db.query(PrivateMessage).filter( PrivateMessage.recipient_id == current_user.id, PrivateMessage.is_read == False ).count() - return jsonify({'count': count}) + + # Group unread + group_count = 0 + memberships = db.query(MessageGroupMember).filter( + MessageGroupMember.user_id == current_user.id + ).all() + for m in memberships: + q = db.query(func.count(GroupMessage.id)).filter( + GroupMessage.group_id == m.group_id, + GroupMessage.sender_id != current_user.id + ) + if m.last_read_at: + q = q.filter(GroupMessage.created_at > m.last_read_at) + group_count += q.scalar() or 0 + + return jsonify({'count': pm_count + group_count}) finally: db.close() diff --git a/database.py b/database.py index 1f61ca4..ae6e44f 100644 --- a/database.py +++ b/database.py @@ -2319,11 +2319,12 @@ class PrivateMessage(Base): class MessageAttachment(Base): - """Załączniki do wiadomości prywatnych""" + """Załączniki do wiadomości prywatnych i grupowych""" __tablename__ = 'message_attachments' id = Column(Integer, primary_key=True) - message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE'), nullable=False) + message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE')) # nullable now + group_message_id = Column(Integer, ForeignKey('group_message.id', ondelete='CASCADE')) # NEW filename = Column(String(255), nullable=False) # original filename stored_filename = Column(String(255), nullable=False) # UUID-based on disk file_size = Column(Integer, nullable=False) # bytes @@ -2333,6 +2334,91 @@ class MessageAttachment(Base): message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan')) +# ============================================================ +# GROUP MESSAGES +# ============================================================ + +class MessageGroup(Base): + """Grupa czatowa — nazwana lub ad-hoc""" + __tablename__ = 'message_group' + + id = Column(Integer, primary_key=True) + name = Column(String(255)) + description = Column(Text) + owner_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + is_named = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + owner = relationship('User', foreign_keys=[owner_id]) + members = relationship('MessageGroupMember', backref='group', cascade='all, delete-orphan') + messages = relationship('GroupMessage', backref='group', cascade='all, delete-orphan', + order_by='GroupMessage.created_at') + + @property + def display_name(self): + """Nazwa wyświetlana — nazwa grupy lub lista imion uczestników""" + if self.name: + return self.name + names = [m.user.name or m.user.email.split('@')[0] for m in self.members if m.user] + return ', '.join(sorted(names)[:4]) + (f' +{len(names)-4}' if len(names) > 4 else '') + + @property + def member_count(self): + return len(self.members) + + @property + def last_message(self): + """Ostatnia wiadomość w grupie""" + if self.messages: + return self.messages[-1] + return None + + +class MessageGroupMember(Base): + """Członkostwo w grupie czatowej""" + __tablename__ = 'message_group_member' + + group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), primary_key=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True) + role = Column(String(20), nullable=False, default='member') # owner, moderator, member + last_read_at = Column(DateTime) + joined_at = Column(DateTime, default=datetime.now) + added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL')) + + user = relationship('User', foreign_keys=[user_id]) + added_by = relationship('User', foreign_keys=[added_by_id]) + + @property + def is_owner(self): + return self.role == 'owner' + + @property + def is_moderator(self): + return self.role == 'moderator' + + @property + def can_manage_members(self): + return self.role in ('owner', 'moderator') + + +class GroupMessage(Base): + """Wiadomość w grupie czatowej""" + __tablename__ = 'group_message' + + id = Column(Integer, primary_key=True) + group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), nullable=False) + sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL')) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.now) + + sender = relationship('User', foreign_keys=[sender_id]) + attachments = relationship('MessageAttachment', + foreign_keys='MessageAttachment.group_message_id', + backref='group_message', + cascade='all, delete-orphan') + + # ============================================================ # B2B CLASSIFIEDS # ============================================================ @@ -4104,7 +4190,7 @@ class CompanyPKD(Base): class CompanyFinancialReport(Base): """ Financial reports (sprawozdania finansowe) filed with KRS. - Tracks report periods and filing dates. + Tracks report periods, filing dates, and key financial figures. """ __tablename__ = 'company_financial_reports' @@ -4116,8 +4202,21 @@ class CompanyFinancialReport(Base): report_type = Column(String(50), default='annual') # annual, quarterly source = Column(String(50), default='ekrs') + # Financial data (in PLN) + revenue = Column(Numeric(15, 2)) # Przychody netto ze sprzedaży + operating_profit = Column(Numeric(15, 2)) # Zysk/strata z działalności operacyjnej + net_profit = Column(Numeric(15, 2)) # Zysk/strata netto + total_assets = Column(Numeric(15, 2)) # Suma aktywów + equity = Column(Numeric(15, 2)) # Kapitał własny + liabilities = Column(Numeric(15, 2)) # Zobowiązania + employees_count = Column(Integer) # Średnia liczba zatrudnionych + + # Company size classification: micro, small, medium, large + size_class = Column(String(20)) + # Timestamps created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) # Relationship company = relationship('Company', backref='financial_reports') @@ -4126,6 +4225,28 @@ class CompanyFinancialReport(Base): UniqueConstraint('company_id', 'period_start', 'period_end', 'report_type', name='uq_company_financial_report'), ) + def classify_size(self): + """Classify company size based on EU criteria (revenue + employees + assets). + micro: <2M EUR revenue, <10 employees, <2M EUR assets + small: <10M EUR revenue, <50 employees, <10M EUR assets + medium: <50M EUR revenue, <250 employees, <43M EUR assets + large: above medium + EUR/PLN ~4.3 + """ + rev = float(self.revenue or 0) + emp = self.employees_count or 0 + assets = float(self.total_assets or 0) + + # PLN thresholds (approx EUR * 4.3) + if rev < 8_600_000 and emp < 10 and assets < 8_600_000: + return 'micro' + elif rev < 43_000_000 and emp < 50 and assets < 43_000_000: + return 'small' + elif rev < 215_000_000 and emp < 250 and assets < 185_000_000: + return 'medium' + else: + return 'large' + def __repr__(self): return f'' diff --git a/database/migrations/088_message_groups.sql b/database/migrations/088_message_groups.sql new file mode 100644 index 0000000..a222ac6 --- /dev/null +++ b/database/migrations/088_message_groups.sql @@ -0,0 +1,59 @@ +-- 088_message_groups.sql +-- Group messaging tables + alter message_attachments + +BEGIN; + +-- Group/channel for multi-person chats +CREATE TABLE message_group ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + description TEXT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_named BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_message_group_owner ON message_group(owner_id); + +-- Group membership with roles +CREATE TABLE message_group_member ( + group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'moderator', 'member')), + last_read_at TIMESTAMP, + joined_at TIMESTAMP NOT NULL DEFAULT NOW(), + added_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (group_id, user_id) +); + +CREATE INDEX idx_message_group_member_user ON message_group_member(user_id); + +-- Messages within a group +CREATE TABLE group_message ( + id SERIAL PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_group_message_group ON group_message(group_id); +CREATE INDEX idx_group_message_created ON group_message(group_id, created_at); + +-- Extend message_attachments to support group messages +ALTER TABLE message_attachments ALTER COLUMN message_id DROP NOT NULL; +ALTER TABLE message_attachments ADD COLUMN group_message_id INTEGER REFERENCES group_message(id) ON DELETE CASCADE; +ALTER TABLE message_attachments ADD CONSTRAINT chk_attachment_one_parent + CHECK ((message_id IS NOT NULL) != (group_message_id IS NOT NULL)); + +CREATE INDEX idx_message_attachments_group ON message_attachments(group_message_id) WHERE group_message_id IS NOT NULL; + +-- Grants +GRANT ALL ON TABLE message_group TO nordabiz_app; +GRANT ALL ON TABLE message_group_member TO nordabiz_app; +GRANT ALL ON TABLE group_message TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE message_group_id_seq TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE group_message_id_seq TO nordabiz_app; + +COMMIT; diff --git a/docs/superpowers/plans/2026-03-20-group-messages-and-search.md b/docs/superpowers/plans/2026-03-20-group-messages-and-search.md new file mode 100644 index 0000000..88c237e --- /dev/null +++ b/docs/superpowers/plans/2026-03-20-group-messages-and-search.md @@ -0,0 +1,1410 @@ +# Wiadomości grupowe i wyszukiwanie — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add message search and group messaging to NordaBiz portal's messaging module. + +**Architecture:** Two independent features built incrementally on existing messages blueprint. Search adds query filtering to existing inbox/sent routes. Group messaging adds new DB tables (message_group, message_group_member, group_message), new routes, and new templates. Both integrate into existing inbox view. + +**Tech Stack:** Flask, SQLAlchemy 2.0, PostgreSQL, Jinja2, Quill rich-text editor, vanilla JS + +**Spec:** `docs/superpowers/specs/2026-03-20-group-messages-and-search-design.md` + +--- + +## File Structure + +### New files +| File | Responsibility | +|------|---------------| +| `database/migrations/088_message_groups.sql` | Tables: message_group, message_group_member, group_message + ALTER message_attachments | +| `blueprints/messages/group_routes.py` | All group messaging routes | +| `templates/messages/group_compose.html` | Create group form | +| `templates/messages/group_view.html` | Group chat view | +| `templates/messages/group_manage.html` | Manage group members panel | + +### Modified files +| File | Changes | +|------|---------| +| `database.py` | Add MessageGroup, MessageGroupMember, GroupMessage models; alter MessageAttachment | +| `blueprints/messages/__init__.py` | Import group_routes | +| `blueprints/messages/routes.py` | Add `?q=` search to inbox/sent; extend unread-count API with group counts | +| `blueprints/__init__.py` | Register group route endpoint aliases | +| `templates/messages/inbox.html` | Add search bar; show group chats in list | +| `templates/messages/sent.html` | Add search bar | + +--- + +## Task 1: Database Migration — Group Tables + +**Files:** +- Create: `database/migrations/088_message_groups.sql` + +- [ ] **Step 1: Write migration SQL** + +```sql +-- 088_message_groups.sql +-- Group messaging tables + alter message_attachments + +BEGIN; + +-- Group/channel for multi-person chats +CREATE TABLE message_group ( + id SERIAL PRIMARY KEY, + name VARCHAR(255), + description TEXT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_named BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_message_group_owner ON message_group(owner_id); + +-- Group membership with roles +CREATE TABLE message_group_member ( + group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'moderator', 'member')), + last_read_at TIMESTAMP, + joined_at TIMESTAMP NOT NULL DEFAULT NOW(), + added_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (group_id, user_id) +); + +CREATE INDEX idx_message_group_member_user ON message_group_member(user_id); + +-- Messages within a group +CREATE TABLE group_message ( + id SERIAL PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_group_message_group ON group_message(group_id); +CREATE INDEX idx_group_message_created ON group_message(group_id, created_at); + +-- Extend message_attachments to support group messages +ALTER TABLE message_attachments ALTER COLUMN message_id DROP NOT NULL; +ALTER TABLE message_attachments ADD COLUMN group_message_id INTEGER REFERENCES group_message(id) ON DELETE CASCADE; +ALTER TABLE message_attachments ADD CONSTRAINT chk_attachment_one_parent + CHECK ((message_id IS NOT NULL) != (group_message_id IS NOT NULL)); + +CREATE INDEX idx_message_attachments_group ON message_attachments(group_message_id) WHERE group_message_id IS NOT NULL; + +-- Grants +GRANT ALL ON TABLE message_group TO nordabiz_app; +GRANT ALL ON TABLE message_group_member TO nordabiz_app; +GRANT ALL ON TABLE group_message TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE message_group_id_seq TO nordabiz_app; +GRANT USAGE, SELECT ON SEQUENCE group_message_id_seq TO nordabiz_app; + +COMMIT; +``` + +- [ ] **Step 2: Commit migration** + +```bash +git add database/migrations/088_message_groups.sql +git commit -m "feat(messages): add group messaging tables migration" +``` + +--- + +## Task 2: SQLAlchemy Models + +**Files:** +- Modify: `database.py` (after line ~2333, after MessageAttachment) + +- [ ] **Step 1: Add group models to database.py** + +Add after the `MessageAttachment` class (around line 2333): + +```python +# ============================================================ +# GROUP MESSAGES +# ============================================================ + +class MessageGroup(Base): + """Grupa czatowa — nazwana lub ad-hoc""" + __tablename__ = 'message_group' + + id = Column(Integer, primary_key=True) + name = Column(String(255)) + description = Column(Text) + owner_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False) + is_named = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + owner = relationship('User', foreign_keys=[owner_id]) + members = relationship('MessageGroupMember', backref='group', cascade='all, delete-orphan') + messages = relationship('GroupMessage', backref='group', cascade='all, delete-orphan', + order_by='GroupMessage.created_at') + + @property + def display_name(self): + """Nazwa wyświetlana — nazwa grupy lub lista imion uczestników""" + if self.name: + return self.name + names = [m.user.name or m.user.email.split('@')[0] for m in self.members if m.user] + return ', '.join(sorted(names)[:4]) + (f' +{len(names)-4}' if len(names) > 4 else '') + + @property + def member_count(self): + return len(self.members) + + @property + def last_message(self): + """Ostatnia wiadomość w grupie""" + if self.messages: + return self.messages[-1] + return None + + +class MessageGroupMember(Base): + """Członkostwo w grupie czatowej""" + __tablename__ = 'message_group_member' + + group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), primary_key=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True) + role = Column(String(20), nullable=False, default='member') # owner, moderator, member + last_read_at = Column(DateTime) + joined_at = Column(DateTime, default=datetime.now) + added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL')) + + user = relationship('User', foreign_keys=[user_id]) + added_by = relationship('User', foreign_keys=[added_by_id]) + + @property + def is_owner(self): + return self.role == 'owner' + + @property + def is_moderator(self): + return self.role == 'moderator' + + @property + def can_manage_members(self): + return self.role in ('owner', 'moderator') + + +class GroupMessage(Base): + """Wiadomość w grupie czatowej""" + __tablename__ = 'group_message' + + id = Column(Integer, primary_key=True) + group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), nullable=False) + sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL')) + content = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.now) + + sender = relationship('User', foreign_keys=[sender_id]) + attachments = relationship('MessageAttachment', + foreign_keys='MessageAttachment.group_message_id', + backref='group_message', + cascade='all, delete-orphan') +``` + +Also modify `MessageAttachment` (line ~2326) — add group_message_id: + +```python +class MessageAttachment(Base): + """Załączniki do wiadomości prywatnych i grupowych""" + __tablename__ = 'message_attachments' + + id = Column(Integer, primary_key=True) + message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE')) # nullable now + group_message_id = Column(Integer, ForeignKey('group_message.id', ondelete='CASCADE')) # NEW + filename = Column(String(255), nullable=False) + stored_filename = Column(String(255), nullable=False) + file_size = Column(Integer, nullable=False) + mime_type = Column(String(100), nullable=False) + created_at = Column(DateTime, default=datetime.now) + + message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan')) +``` + +- [ ] **Step 2: Verify compilation** + +Run: `python3 -m py_compile database.py` +Expected: no output (success) + +- [ ] **Step 3: Commit** + +```bash +git add database.py +git commit -m "feat(messages): add MessageGroup, MessageGroupMember, GroupMessage models" +``` + +--- + +## Task 3: Search in Inbox and Sent + +**Files:** +- Modify: `blueprints/messages/routes.py` (messages_inbox at line 30, messages_sent at line 64) +- Modify: `templates/messages/inbox.html` +- Modify: `templates/messages/sent.html` + +- [ ] **Step 1: Add search to messages_inbox route** + +In `blueprints/messages/routes.py`, modify `messages_inbox()` (line 30-58): + +```python +@bp.route('/wiadomosci') +@login_required +@member_required +def messages_inbox(): + """Skrzynka odbiorcza""" + page = request.args.get('page', 1, type=int) + per_page = 20 + search_query = request.args.get('q', '').strip() + + db = SessionLocal() + try: + query = db.query(PrivateMessage).options( + joinedload(PrivateMessage.attachments) + ).filter( + PrivateMessage.recipient_id == current_user.id + ) + + # Search filtering + if search_query: + from sqlalchemy import or_, func + search_pattern = f'%{search_query}%' + query = query.join(User, PrivateMessage.sender_id == User.id).filter( + or_( + PrivateMessage.subject.ilike(search_pattern), + func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern), + User.name.ilike(search_pattern), + User.email.ilike(search_pattern) + ) + ) + + query = query.order_by(PrivateMessage.created_at.desc()) + + total = query.count() + messages = query.limit(per_page).offset((page - 1) * per_page).all() + + unread_count = db.query(PrivateMessage).filter( + PrivateMessage.recipient_id == current_user.id, + PrivateMessage.is_read == False + ).count() + + return render_template('messages/inbox.html', + messages=messages, + page=page, + total_pages=(total + per_page - 1) // per_page, + unread_count=unread_count, + search_query=search_query + ) + finally: + db.close() +``` + +- [ ] **Step 2: Add search to messages_sent route** + +Same pattern for `messages_sent()` but filter on `recipient` instead of `sender`: + +```python +@bp.route('/wiadomosci/wyslane') +@login_required +@member_required +def messages_sent(): + """Wysłane wiadomości""" + page = request.args.get('page', 1, type=int) + per_page = 20 + search_query = request.args.get('q', '').strip() + + db = SessionLocal() + try: + query = db.query(PrivateMessage).options( + joinedload(PrivateMessage.attachments) + ).filter( + PrivateMessage.sender_id == current_user.id + ) + + if search_query: + from sqlalchemy import or_, func + search_pattern = f'%{search_query}%' + query = query.join(User, PrivateMessage.recipient_id == User.id).filter( + or_( + PrivateMessage.subject.ilike(search_pattern), + func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern), + User.name.ilike(search_pattern), + User.email.ilike(search_pattern) + ) + ) + + query = query.order_by(PrivateMessage.created_at.desc()) + + total = query.count() + messages = query.limit(per_page).offset((page - 1) * per_page).all() + + return render_template('messages/sent.html', + messages=messages, + page=page, + total_pages=(total + per_page - 1) // per_page, + search_query=search_query + ) + finally: + db.close() +``` + +- [ ] **Step 3: Add search bar HTML to inbox.html** + +In `templates/messages/inbox.html`, add after `.messages-topbar` div (around line 297), before message list. Add CSS in `extra_css` block and search form HTML: + +CSS to add in `