nordabiz/docs/superpowers/plans/2026-03-20-group-messages-and-search.md
Maciej Pienczyn 3a18ebcb28
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat(messages): group messaging and search
- Search bar in inbox/sent: filters by subject, content, sender/recipient
- Group chats: create named or ad-hoc groups with Norda members
- Group roles: owner, moderator, member with permission hierarchy
- Group management: add/remove members, change roles
- Photo avatars in message list (fallback to initials)
- Unread count API extended to include group messages
- Migration 088: message_group, message_group_member, group_message tables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 11:11:57 +01:00

48 KiB

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

-- 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
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):

# ============================================================
# 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:

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
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):

@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:

@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 <style> block:

.messages-search {
    position: relative;
    margin-bottom: var(--spacing-md);
}

.messages-search input {
    width: 100%;
    padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) 36px;
    border: 1.5px solid var(--border-color, #e5e7eb);
    border-radius: var(--radius);
    font-size: var(--font-size-sm);
    background: white;
    transition: var(--transition);
}

.messages-search input:focus {
    border-color: var(--primary);
    outline: none;
    box-shadow: 0 0 0 3px rgba(46, 72, 114, 0.1);
}

.messages-search-icon {
    position: absolute;
    left: 12px;
    top: 50%;
    transform: translateY(-50%);
    color: var(--text-muted);
    font-size: 14px;
    pointer-events: none;
}

.messages-search-clear {
    position: absolute;
    right: 8px;
    top: 50%;
    transform: translateY(-50%);
    background: none;
    border: none;
    color: var(--text-muted);
    cursor: pointer;
    font-size: 16px;
    padding: 4px;
    display: none;
}

.messages-search-clear.visible {
    display: block;
}

HTML to add after topbar, before message list:

<form class="messages-search" method="GET" action="{{ url_for('messages.messages_inbox') }}">
    <span class="messages-search-icon">🔍</span>
    <input type="text" name="q" value="{{ search_query or '' }}" placeholder="Szukaj w wiadomościach..." autocomplete="off">
    <button type="button" class="messages-search-clear {% if search_query %}visible{% endif %}" onclick="this.previousElementSibling.value=''; this.form.submit();"></button>
</form>
{% if search_query %}
<div style="margin-bottom: var(--spacing-md); color: var(--text-muted); font-size: var(--font-size-sm);">
    Wyniki wyszukiwania dla: <strong>{{ search_query }}</strong>
    <a href="{{ url_for('messages.messages_inbox') }}" style="margin-left: var(--spacing-sm); color: var(--primary);">Wyczyść</a>
</div>
{% endif %}
  • Step 4: Add same search bar to sent.html

Same CSS and HTML pattern but with action="{{ url_for('messages.messages_sent') }}".

  • Step 5: Verify compilation and test locally

Run: python3 -m py_compile blueprints/messages/routes.py

  • Step 6: Commit
git add blueprints/messages/routes.py templates/messages/inbox.html templates/messages/sent.html
git commit -m "feat(messages): add search bar to inbox and sent views"

Task 4: Group Routes — Create and View

Files:

  • Create: blueprints/messages/group_routes.py

  • Modify: blueprints/messages/__init__.py

  • Step 1: Create group_routes.py with create and view routes

"""
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/<int:group_id>')
@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/<int:group_id>/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()
  • Step 2: Register group_routes in blueprint init

Modify blueprints/messages/__init__.py:

"""
Messages Blueprint
==================

Private messages, group messages, and notifications routes.
"""

from flask import Blueprint

bp = Blueprint('messages', __name__)

from . import routes  # noqa: E402, F401
from . import group_routes  # noqa: E402, F401
  • Step 3: Verify compilation

Run: python3 -m py_compile blueprints/messages/group_routes.py

  • Step 4: Commit
git add blueprints/messages/group_routes.py blueprints/messages/__init__.py
git commit -m "feat(messages): add group create, view, and send routes"

Task 5: Group Management Routes

Files:

  • Modify: blueprints/messages/group_routes.py (append to end)

  • Step 1: Add member management routes

Append to group_routes.py:

@bp.route('/wiadomosci/grupa/<int:group_id>/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/<int:group_id>/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/<int:group_id>/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/<int:group_id>/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/<int:group_id>/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()
  • Step 2: Verify compilation

Run: python3 -m py_compile blueprints/messages/group_routes.py

  • Step 3: Commit
git add blueprints/messages/group_routes.py
git commit -m "feat(messages): add group management routes (add/remove members, roles)"

Task 6: Extend Unread Count API + Inbox Integration

Files:

  • Modify: blueprints/messages/routes.py (api_unread_count at line 507, messages_inbox at line 30)

  • Step 1: Extend unread count to include group messages

Modify api_unread_count() in routes.py:

@bp.route('/api/messages/unread-count')
@limiter.exempt
@login_required
@member_required
def api_unread_count():
    """API: Liczba nieprzeczytanych wiadomości (1:1 + grupowe)"""
    db = SessionLocal()
    try:
        # 1:1 unread
        pm_count = db.query(PrivateMessage).filter(
            PrivateMessage.recipient_id == current_user.id,
            PrivateMessage.is_read == False
        ).count()

        # Group unread
        from database import MessageGroupMember, GroupMessage
        from sqlalchemy import func, and_
        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()
  • Step 2: Add group chats to inbox view

Modify messages_inbox() — after fetching private messages, also fetch groups and merge:

Add at the end of the try block, before return render_template:

        # Fetch group chats for current user
        from database import MessageGroupMember, MessageGroup, GroupMessage
        from sqlalchemy import and_

        group_memberships = db.query(MessageGroupMember).filter(
            MessageGroupMember.user_id == current_user.id
        ).all()

        group_items = []
        for membership in group_memberships:
            group = db.query(MessageGroup).filter(MessageGroup.id == membership.group_id).first()
            if not group:
                continue
            last_msg = db.query(GroupMessage).filter(
                GroupMessage.group_id == group.id
            ).order_by(GroupMessage.created_at.desc()).first()

            unread = 0
            if membership.last_read_at:
                unread = db.query(GroupMessage).filter(
                    GroupMessage.group_id == group.id,
                    GroupMessage.sender_id != current_user.id,
                    GroupMessage.created_at > membership.last_read_at
                ).count()
            else:
                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': unread,
                'membership': membership,
                'sort_date': last_msg.created_at if last_msg else group.created_at
            })

Pass group_items=group_items to render_template.

  • Step 3: Commit
git add blueprints/messages/routes.py
git commit -m "feat(messages): extend unread count API and inbox with group chats"

Task 7: Templates — Group Compose

Files:

  • Create: templates/messages/group_compose.html

  • Step 1: Create group compose template

Template with:

  • Optional group name field
  • Multi-select user picker (checkboxes with search/filter)
  • Quill rich-text editor for first message
  • File attachments (drag & drop)
  • Same styling as existing compose.html

The template extends base.html, uses {% block extra_css %} for styles and {% block extra_js %} for JS. Multi-select implemented as a searchable list of checkboxes with user avatars (photo or initial) and company name. Selected users shown as pill badges above the list.

Key elements:

  • Input name="name" for group name (optional)

  • Checkboxes name="members" value=user.id for each user

  • Quill editor bound to hidden name="content" textarea

  • File input name="attachments" with drag & drop zone

  • Form POSTs to url_for('messages.group_create')

  • Step 2: Commit

git add templates/messages/group_compose.html
git commit -m "feat(messages): add group compose template with multi-select"

Task 8: Templates — Group View (Chat)

Files:

  • Create: templates/messages/group_view.html

  • Step 1: Create group chat view template

Template with:

  • Header: group name (or participant list), member count badge, "Zarządzaj" button for owner/moderator
  • Message list: chronological, each message shows avatar (photo from avatar_path or initial fallback), sender name, timestamp, content, attachments
  • Members sidebar (collapsible on mobile): list of members with role badges (Właściciel/Moderator)
  • Reply form at bottom: Quill editor + attachments, POSTs to url_for('messages.group_send', group_id=group.id)
  • Auto-scroll to newest message on load

Avatar rendering pattern (reuse across all templates):

{% if user.avatar_path %}
<img src="{{ url_for('static', filename='uploads/' + user.avatar_path) }}" class="avatar-img" alt="">
{% else %}
<span class="avatar-initial">{{ (user.name or user.email)[0].upper() }}</span>
{% endif %}
  • Step 2: Commit
git add templates/messages/group_view.html
git commit -m "feat(messages): add group chat view template"

Task 9: Templates — Group Manage

Files:

  • Create: templates/messages/group_manage.html

  • Step 1: Create group management template

Template with:

  • Group info edit form (name, description) — only for owner

  • Current members list with role badges and action buttons:

    • Owner sees: "Nadaj moderatora" / "Odbierz moderatora" toggle, "Usuń" button
    • Moderator sees: "Usuń" button (for members only, not other moderators)
  • Add member section: autocomplete/search field with available users list

  • Each action is a separate POST form to the appropriate route

  • Step 2: Commit

git add templates/messages/group_manage.html
git commit -m "feat(messages): add group management template"

Task 10: Inbox Template — Show Groups + Avatars

Files:

  • Modify: templates/messages/inbox.html

  • Step 1: Update inbox to show group chats

Add group items rendering in the message list. Groups shown with:

  • "👥 N" badge instead of single avatar
  • Group name or participant names
  • Last message preview with sender name
  • Unread count badge
  • Link to url_for('messages.group_view', group_id=item.group.id)

Group items and private messages are rendered together, sorted by date. Use Jinja to merge and sort:

{% set all_items = [] %}
{% for msg in messages %}
    {% do all_items.append({'type': 'pm', 'msg': msg, 'sort_date': msg.created_at}) %}
{% endfor %}
{% for gi in group_items %}
    {% do all_items.append(gi) %}
{% endfor %}
{% for item in all_items|sort(attribute='sort_date', reverse=True) %}
    ...render based on item.type...
{% endfor %}
  • Step 2: Add "Nowa grupa" button

Add button next to existing "Nowa wiadomość" in topbar:

<a href="{{ url_for('messages.group_compose') }}" class="btn btn-outline" style="...">👥 Nowa grupa</a>
  • Step 3: Update avatars to use photos

In all avatar spots (inbox, sent, view, compose), replace initial-only rendering with photo+fallback pattern from Task 8.

  • Step 4: Commit
git add templates/messages/inbox.html templates/messages/sent.html templates/messages/view.html templates/messages/compose.html
git commit -m "feat(messages): show groups in inbox, add photo avatars, new group button"

Task 11: Register Endpoint Aliases

Files:

  • Modify: blueprints/__init__.py (line ~256-268)

  • Step 1: Add group route aliases

Add group endpoint aliases after existing messages aliases:

'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',
  • Step 2: Commit
git add blueprints/__init__.py
git commit -m "feat(messages): register group route endpoint aliases"

Task 12: Deploy to Staging

Files: None (deployment steps)

  • Step 1: Push to both remotes
git push origin master && git push inpi master
  • Step 2: Deploy to staging
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
  • Step 3: Run migration on staging
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && \
  DATABASE_URL=\$(grep DATABASE_URL .env | cut -d'=' -f2) \
  /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/088_message_groups.sql"
  • Step 4: Restart and verify
ssh maciejpi@10.22.68.248 "sudo systemctl restart nordabiznes"
curl -sI https://staging.nordabiznes.pl/health | head -3
  • Step 5: Test on staging

Manual verification:

  1. Go to /wiadomosci — search bar visible, search works
  2. Create new group — /wiadomosci/nowa-grupa
  3. Send message in group
  4. Check unread badge updates
  5. Manage members (add/remove/change role)