nordabiz/docs/superpowers/plans/2026-03-27-messaging-redesign.md
Maciej Pienczyn 110d971dca
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: migrate prod docs to OVH VPS + UTC→Warsaw timezone in all templates
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS
(57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash
commands, memory files, architecture docs, and deploy procedures.

Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted
155 .strftime() calls across 71 templates so timestamps display
in Polish timezone regardless of server timezone.

Also includes: created_by_id tracking, abort import fix, ICS
calendar fix for missing end times, Pros Poland data cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:41:53 +02:00

37 KiB

Messaging Redesign — 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: Przebudowa systemu wiadomości z email-like (Odebrane/Wysłane) na konwersacyjny (Messenger/WhatsApp) z ujednoliconym modelem danych, SSE real-time, i pełnym zestawem funkcji komunikacyjnych.

Architecture: Unified Conversation + Message model replacing separate PrivateMessage and MessageGroup systems. SSE via Redis pub/sub for real-time events. REST API for all operations. Conversation-based frontend with split-pane layout.

Tech Stack: Flask, SQLAlchemy, PostgreSQL, Redis (pub/sub + presence), SSE, Quill.js, vanilla JS

Spec: docs/superpowers/specs/2026-03-27-messaging-redesign-design.md


File Structure

New files

File Responsibility
database/migrations/091_messaging_redesign.sql New tables DDL
blueprints/messages/conversation_routes.py Conversation CRUD API
blueprints/messages/message_routes.py Message send/edit/delete/forward API
blueprints/messages/reaction_routes.py Reactions + pins API
blueprints/messages/sse_routes.py SSE stream + typing + presence
blueprints/messages/link_preview.py Link preview fetcher
redis_service.py Redis client singleton + pub/sub helpers
templates/messages/conversations.html Main conversation view (list + chat)
static/js/conversations.js Frontend: conversation list, chat, SSE client
static/css/conversations.css Conversation-specific styles
scripts/migrate_messages.py Data migration from old to new tables
tests/unit/test_conversation_models.py Model unit tests
tests/unit/test_conversation_api.py API endpoint tests

Modified files

File Changes
database.py Add 5 new model classes (after line 5821)
blueprints/messages/__init__.py Import new route modules
email_service.py Add build_conversation_notification_email
app.py Redis connection setup (extend existing, line ~245)

Preserved (read-only after migration)

File Status
blueprints/messages/routes.py Keep for /wiadomosci/archiwum redirect
blueprints/messages/group_routes.py Keep for backward compat, redirect to new
templates/messages/inbox.html Unused after switch, keep for rollback

Task 1: Database Models

Files:

  • Modify: database.py:5822 (insert before # DATABASE INITIALIZATION)

  • Create: database/migrations/091_messaging_redesign.sql

  • Step 1: Add SQLAlchemy models to database.py

Insert after line 5821 (after InternalHealthLog), before # DATABASE INITIALIZATION:

# ============================================================
# CONVERSATIONS (Unified Messaging System)
# ============================================================

class Conversation(Base):
    """Unified conversation model — 1:1 and groups."""
    __tablename__ = 'conversations'

    id = Column(Integer, primary_key=True)
    name = Column(String(255), nullable=True)  # Null for 1:1
    is_group = Column(Boolean, default=False, nullable=False)
    owner_id = Column(Integer, ForeignKey('users.id'), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
    last_message_id = Column(Integer, ForeignKey('conv_messages.id', use_alter=True), nullable=True)

    owner = relationship('User', foreign_keys=[owner_id])
    members = relationship('ConversationMember', back_populates='conversation', cascade='all, delete-orphan')
    messages = relationship('ConvMessage', back_populates='conversation', foreign_keys='ConvMessage.conversation_id')
    last_message = relationship('ConvMessage', foreign_keys=[last_message_id], post_update=True)
    pins = relationship('MessagePin', back_populates='conversation', cascade='all, delete-orphan')

    @property
    def display_name(self):
        if self.name:
            return self.name
        member_names = [m.user.name for m in self.members if m.user]
        return ', '.join(member_names[:4]) + (f' +{len(member_names)-4}' if len(member_names) > 4 else '')

    @property
    def member_count(self):
        return len(self.members)

    def __repr__(self):
        return f'<Conversation {self.id} group={self.is_group}>'


class ConversationMember(Base):
    """Membership in a conversation with per-user settings."""
    __tablename__ = 'conversation_members'

    conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), primary_key=True)
    user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
    role = Column(String(20), default='member', nullable=False)
    last_read_at = Column(DateTime, nullable=True)
    is_muted = Column(Boolean, default=False, nullable=False)
    is_archived = Column(Boolean, default=False, nullable=False)
    joined_at = Column(DateTime, default=datetime.utcnow, nullable=False)
    added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)

    conversation = relationship('Conversation', back_populates='members')
    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'

    def __repr__(self):
        return f'<ConversationMember conv={self.conversation_id} user={self.user_id}>'


class ConvMessage(Base):
    """Message within a conversation."""
    __tablename__ = 'conv_messages'

    id = Column(Integer, primary_key=True)
    conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), nullable=False, index=True)
    sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
    content = Column(Text, nullable=False)
    reply_to_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='SET NULL'), nullable=True)
    edited_at = Column(DateTime, nullable=True)
    is_deleted = Column(Boolean, default=False, nullable=False)
    link_preview = Column(JSON, nullable=True)  # {url, title, description, image}
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)

    conversation = relationship('Conversation', back_populates='messages', foreign_keys=[conversation_id])
    sender = relationship('User', foreign_keys=[sender_id])
    reply_to = relationship('ConvMessage', remote_side=[id], foreign_keys=[reply_to_id])
    reactions = relationship('MessageReaction', back_populates='message', cascade='all, delete-orphan')
    attachments = relationship('MessageAttachment', back_populates='conv_message', foreign_keys='MessageAttachment.conv_message_id')

    def __repr__(self):
        return f'<ConvMessage {self.id} conv={self.conversation_id}>'


class MessageReaction(Base):
    """Emoji reaction on a message."""
    __tablename__ = 'message_reactions'

    id = Column(Integer, primary_key=True)
    message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=False, index=True)
    user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
    emoji = Column(String(10), nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

    message = relationship('ConvMessage', back_populates='reactions')
    user = relationship('User')

    __table_args__ = (
        UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_reaction_per_user'),
    )

    def __repr__(self):
        return f'<MessageReaction {self.emoji} msg={self.message_id}>'


class MessagePin(Base):
    """Pinned message in a conversation."""
    __tablename__ = 'message_pins'

    id = Column(Integer, primary_key=True)
    conversation_id = Column(Integer, ForeignKey('conversations.id', ondelete='CASCADE'), nullable=False, index=True)
    message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=False)
    pinned_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

    conversation = relationship('Conversation', back_populates='pins')
    message = relationship('ConvMessage')
    pinned_by = relationship('User')

    def __repr__(self):
        return f'<MessagePin msg={self.message_id} conv={self.conversation_id}>'
  • Step 2: Add conv_message_id FK to MessageAttachment

In database.py, find class MessageAttachment (line ~2334) and add:

conv_message_id = Column(Integer, ForeignKey('conv_messages.id', ondelete='CASCADE'), nullable=True)
conv_message = relationship('ConvMessage', back_populates='attachments', foreign_keys=[conv_message_id])
  • Step 3: Add missing import if needed

Check that UniqueConstraint is imported at the top of database.py. If not, add to the from sqlalchemy import ... line:

from sqlalchemy import UniqueConstraint
  • Step 4: Write migration SQL
-- Migration 091: Messaging Redesign — Unified Conversation Model
-- Replaces separate private_messages + message_group systems

BEGIN;

CREATE TABLE conversations (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    is_group BOOLEAN NOT NULL DEFAULT FALSE,
    owner_id INTEGER NOT NULL REFERENCES users(id),
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
    last_message_id INTEGER  -- FK added after conv_messages exists
);

CREATE TABLE conversation_members (
    conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role VARCHAR(20) NOT NULL DEFAULT 'member',
    last_read_at TIMESTAMP,
    is_muted BOOLEAN NOT NULL DEFAULT FALSE,
    is_archived BOOLEAN NOT NULL DEFAULT FALSE,
    joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
    added_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
    PRIMARY KEY (conversation_id, user_id)
);

CREATE TABLE conv_messages (
    id SERIAL PRIMARY KEY,
    conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
    sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
    content TEXT NOT NULL,
    reply_to_id INTEGER REFERENCES conv_messages(id) ON DELETE SET NULL,
    edited_at TIMESTAMP,
    is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
    link_preview JSONB,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_conv_messages_conversation ON conv_messages(conversation_id, created_at DESC);
CREATE INDEX idx_conv_messages_sender ON conv_messages(sender_id);

-- Add FK from conversations to conv_messages (deferred)
ALTER TABLE conversations ADD CONSTRAINT fk_conversations_last_message
    FOREIGN KEY (last_message_id) REFERENCES conv_messages(id) ON DELETE SET NULL;

CREATE TABLE message_reactions (
    id SERIAL PRIMARY KEY,
    message_id INTEGER NOT NULL REFERENCES conv_messages(id) ON DELETE CASCADE,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    emoji VARCHAR(10) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_reaction_per_user UNIQUE (message_id, user_id, emoji)
);

CREATE INDEX idx_message_reactions_message ON message_reactions(message_id);

CREATE TABLE message_pins (
    id SERIAL PRIMARY KEY,
    conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
    message_id INTEGER NOT NULL REFERENCES conv_messages(id) ON DELETE CASCADE,
    pinned_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_message_pins_conversation ON message_pins(conversation_id);

-- Add conv_message_id to existing message_attachments
ALTER TABLE message_attachments ADD COLUMN conv_message_id INTEGER
    REFERENCES conv_messages(id) ON DELETE CASCADE;

-- Grants
GRANT ALL ON TABLE conversations TO nordabiz_app;
GRANT ALL ON TABLE conversation_members TO nordabiz_app;
GRANT ALL ON TABLE conv_messages TO nordabiz_app;
GRANT ALL ON TABLE message_reactions TO nordabiz_app;
GRANT ALL ON TABLE message_pins TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE conversations_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE conv_messages_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE message_reactions_id_seq TO nordabiz_app;
GRANT USAGE, SELECT ON SEQUENCE message_pins_id_seq TO nordabiz_app;

COMMIT;
  • Step 5: Verify models compile

Run: python3 -c "from database import Conversation, ConversationMember, ConvMessage, MessageReaction, MessagePin; print('OK')"

  • Step 6: Commit
git add database.py database/migrations/091_messaging_redesign.sql
git commit -m "feat(messages): add unified conversation models and migration SQL"

Task 2: Redis Service

Files:

  • Create: redis_service.py

  • Modify: app.py:~245 (extend existing Redis setup)

  • Step 1: Create redis_service.py

"""
Redis Service
=============

Singleton Redis client for pub/sub (SSE events) and presence tracking.
Uses the same Redis instance as Flask-Limiter (localhost:6379/0).
"""

import json
import logging
import redis
import threading

logger = logging.getLogger(__name__)

_redis_client = None
_redis_available = False


def init_redis(app=None):
    """Initialize Redis connection. Called from app.py after existing Redis check."""
    global _redis_client, _redis_available
    try:
        _redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
        _redis_client.ping()
        _redis_available = True
        logger.info("Redis connected for messaging pub/sub")
    except (redis.ConnectionError, redis.TimeoutError):
        _redis_available = False
        logger.warning("Redis unavailable — SSE real-time disabled, falling back to polling")


def get_redis():
    """Get Redis client. Returns None if unavailable."""
    return _redis_client if _redis_available else None


def is_available():
    return _redis_available


def publish_event(user_id, event_type, data):
    """Publish SSE event to a user's channel."""
    r = get_redis()
    if not r:
        return
    payload = json.dumps({'event': event_type, 'data': data})
    r.publish(f'user:{user_id}:events', payload)


def publish_to_conversation(db, conversation_id, event_type, data, exclude_user_id=None):
    """Publish event to all members of a conversation."""
    from database import ConversationMember
    members = db.query(ConversationMember.user_id).filter_by(conversation_id=conversation_id).all()
    for (uid,) in members:
        if uid != exclude_user_id:
            publish_event(uid, event_type, data)


def set_user_online(user_id):
    """Mark user as online with 60s TTL."""
    r = get_redis()
    if not r:
        return
    r.setex(f'user:{user_id}:online', 60, '1')


def is_user_online(user_id):
    """Check if user is currently online."""
    r = get_redis()
    if not r:
        return False
    return r.exists(f'user:{user_id}:online') > 0


def get_user_last_seen(user_id):
    """Get last seen timestamp from Redis."""
    r = get_redis()
    if not r:
        return None
    ts = r.get(f'user:{user_id}:last_seen')
    return ts


def update_last_seen(user_id):
    """Update last seen timestamp."""
    r = get_redis()
    if not r:
        return
    from datetime import datetime
    r.set(f'user:{user_id}:last_seen', datetime.utcnow().isoformat())
  • Step 2: Wire Redis init into app.py

In app.py, after the existing Redis ping block (~line 260), add:

from redis_service import init_redis
init_redis(app)
  • Step 3: Verify

Run: python3 -c "from redis_service import init_redis, is_available; init_redis(); print('Redis available:', is_available())"

  • Step 4: Commit
git add redis_service.py app.py
git commit -m "feat(messages): add Redis service for pub/sub and presence"

Task 3: SSE Stream + Presence + Typing

Files:

  • Create: blueprints/messages/sse_routes.py

  • Modify: blueprints/messages/__init__.py

  • Step 1: Create sse_routes.py

"""
SSE Routes
==========

Server-Sent Events stream, typing indicators, and presence.
"""

import json
from datetime import datetime
from flask import Response, request, jsonify, stream_with_context
from flask_login import login_required, current_user
from . import bp
from database import SessionLocal, ConversationMember
from utils.decorators import member_required
from redis_service import get_redis, is_available, publish_to_conversation, set_user_online, update_last_seen, publish_event


@bp.route('/api/messages/stream')
@login_required
def message_stream():
    """SSE stream — one connection per logged-in user."""
    r = get_redis()
    if not r:
        return jsonify({'error': 'Real-time unavailable'}), 503

    user_id = current_user.id

    def generate():
        pubsub = r.pubsub()
        pubsub.subscribe(f'user:{user_id}:events')
        # Initial heartbeat
        yield f"retry: 30000\ndata: {json.dumps({'event': 'connected'})}\n\n"
        try:
            for msg in pubsub.listen():
                if msg['type'] == 'message':
                    payload = json.loads(msg['data'])
                    yield f"event: {payload['event']}\ndata: {json.dumps(payload['data'])}\n\n"
        except GeneratorExit:
            pubsub.unsubscribe()
            pubsub.close()

    # Update presence on SSE connect
    set_user_online(user_id)
    update_last_seen(user_id)

    response = Response(
        stream_with_context(generate()),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no',  # nginx: disable buffering
            'Connection': 'keep-alive',
        }
    )
    return response


@bp.route('/api/conversations/<int:conversation_id>/typing', methods=['POST'])
@login_required
@member_required
def typing_indicator(conversation_id):
    """Signal that current user is typing."""
    if not is_available():
        return jsonify({'ok': True})

    db = SessionLocal()
    try:
        member = db.query(ConversationMember).filter_by(
            conversation_id=conversation_id, user_id=current_user.id
        ).first()
        if not member:
            return jsonify({'error': 'Not a member'}), 403

        publish_to_conversation(db, conversation_id, 'typing', {
            'conversation_id': conversation_id,
            'user_id': current_user.id,
            'user_name': current_user.name or current_user.email.split('@')[0],
        }, exclude_user_id=current_user.id)

        return jsonify({'ok': True})
    finally:
        db.close()


@bp.route('/api/messages/heartbeat', methods=['POST'])
@login_required
def heartbeat():
    """Update presence — called every 30s from SSE client."""
    set_user_online(current_user.id)
    update_last_seen(current_user.id)
    return jsonify({'ok': True})


@bp.route('/api/users/presence', methods=['GET'])
@login_required
@member_required
def users_presence():
    """Batch check online status for multiple users."""
    from redis_service import is_user_online, get_user_last_seen
    user_ids = request.args.getlist('ids', type=int)
    if not user_ids or len(user_ids) > 50:
        return jsonify({'error': 'Provide 1-50 user IDs'}), 400

    result = {}
    for uid in user_ids:
        result[str(uid)] = {
            'online': is_user_online(uid),
            'last_seen': get_user_last_seen(uid),
        }
    return jsonify(result)
  • Step 2: Register in init.py

Add to blueprints/messages/__init__.py:

from . import sse_routes  # noqa: F401
  • Step 3: Commit
git add blueprints/messages/sse_routes.py blueprints/messages/__init__.py
git commit -m "feat(messages): add SSE stream, typing indicator, and presence endpoints"

Task 4: Conversation API

Files:

  • Create: blueprints/messages/conversation_routes.py

  • Modify: blueprints/messages/__init__.py

  • Step 1: Create conversation_routes.py

Full CRUD for conversations: list, create (with 1:1 dedup), get details, update, delete, manage members, settings (mute/archive), mark read.

Key endpoints:

  • GET /api/conversations — list user's conversations with last_message, unread count, sorted by updated_at
  • POST /api/conversations — create new (1:1 dedup: if conversation with exact same 2 members exists, return it)
  • GET /api/conversations/<id> — details + members
  • PATCH /api/conversations/<id> — edit name/description (owner, groups only)
  • DELETE /api/conversations/<id> — delete (owner only)
  • POST /api/conversations/<id>/members — add member (owner, groups only)
  • DELETE /api/conversations/<id>/members/<uid> — remove member
  • PATCH /api/conversations/<id>/settings — mute/archive per user
  • POST /api/conversations/<id>/read — mark all messages as read (update last_read_at)

Each endpoint: check membership, return JSON, publish SSE events where relevant.

  • Step 2: Create GET /wiadomosci HTML route

Add route that renders the new conversations.html template. This replaces the old inbox. Pass conversations list as initial data to avoid extra API call on page load.

  • Step 3: Register in init.py
from . import conversation_routes  # noqa: F401
  • Step 4: Commit
git add blueprints/messages/conversation_routes.py blueprints/messages/__init__.py
git commit -m "feat(messages): add conversation CRUD API endpoints"

Task 5: Message API

Files:

  • Create: blueprints/messages/message_routes.py

  • Modify: blueprints/messages/__init__.py

  • Step 1: Create message_routes.py

Endpoints:

  • GET /api/conversations/<id>/messages — cursor-based pagination (before_id param), return messages with sender, reactions, reply_to, attachments

  • POST /api/conversations/<id>/messages — send message. Check membership. Update conversation.updated_at and last_message_id. Publish SSE new_message. Send email notification (respecting mute). Handle attachments. Trigger link preview (async).

  • PATCH /api/messages/<id> — edit own message (max 24h old). Set edited_at. Publish SSE message_edited.

  • DELETE /api/messages/<id> — soft delete own message. Set is_deleted=True. Publish SSE message_deleted.

  • POST /api/messages/<id>/forward — copy message content to another conversation. Create new message in target.

  • Step 2: Email notification logic

In the send message handler, for each conversation member (except sender):

member = db.query(ConversationMember).filter_by(conversation_id=cid, user_id=uid).first()
if member.is_muted:
    continue
user = member.user
if user.notify_email_messages == False or not user.email:
    continue
# Send email using build_message_notification_email
  • Step 3: Register in init.py
from . import message_routes  # noqa: F401
  • Step 4: Commit
git add blueprints/messages/message_routes.py blueprints/messages/__init__.py
git commit -m "feat(messages): add message send/edit/delete/forward API"

Task 6: Reactions + Pins API

Files:

  • Create: blueprints/messages/reaction_routes.py

  • Modify: blueprints/messages/__init__.py

  • Step 1: Create reaction_routes.py

Endpoints:

  • POST /api/messages/<id>/reactions — add reaction (emoji in body). Check membership. Upsert (unique constraint). Publish SSE reaction.
  • DELETE /api/messages/<id>/reactions/<emoji> — remove own reaction. Publish SSE reaction with action=remove.
  • POST /api/messages/<id>/pin — pin message. Check membership. Create MessagePin. Publish SSE message_pinned.
  • DELETE /api/messages/<id>/pin — unpin. Delete MessagePin. Publish SSE.
  • GET /api/conversations/<id>/pins — list pinned messages with content preview.

Allowed emoji set: ['👍', '❤️', '😂', '😮', '😢', '✅']

  • Step 2: Register in init.py
from . import reaction_routes  # noqa: F401
  • Step 3: Commit
git add blueprints/messages/reaction_routes.py blueprints/messages/__init__.py
git commit -m "feat(messages): add reactions and pins API"

Files:

  • Create: blueprints/messages/link_preview.py

  • Step 1: Create link_preview.py

"""
Link Preview
============

Fetch Open Graph metadata for URLs in messages.
"""

import re
import logging
import requests
from html.parser import HTMLParser

logger = logging.getLogger(__name__)

URL_REGEX = re.compile(r'https?://[^\s<>"\']+')
INTERNAL_DOMAINS = ['nordabiznes.pl', 'staging.nordabiznes.pl', 'localhost']


class OGParser(HTMLParser):
    """Parse og: meta tags and title from HTML."""

    def __init__(self):
        super().__init__()
        self.og = {}
        self.title = None
        self._in_title = False
        self._title_data = []

    def handle_starttag(self, tag, attrs):
        if tag == 'meta':
            d = dict(attrs)
            prop = d.get('property', '') or d.get('name', '')
            content = d.get('content', '')
            if prop in ('og:title', 'og:description', 'og:image'):
                self.og[prop.replace('og:', '')] = content
            elif prop == 'description' and 'description' not in self.og:
                self.og['description'] = content
        elif tag == 'title':
            self._in_title = True

    def handle_data(self, data):
        if self._in_title:
            self._title_data.append(data)

    def handle_endtag(self, tag):
        if tag == 'title':
            self._in_title = False
            self.title = ''.join(self._title_data).strip()


def fetch_link_preview(text):
    """Extract first URL from text and fetch OG metadata. Returns dict or None."""
    # Strip HTML tags for URL detection
    clean = re.sub(r'<[^>]+>', '', text)
    urls = URL_REGEX.findall(clean)
    if not urls:
        return None

    url = urls[0]

    # Skip internal links
    from urllib.parse import urlparse
    parsed = urlparse(url)
    if any(parsed.hostname and parsed.hostname.endswith(d) for d in INTERNAL_DOMAINS):
        return None

    try:
        resp = requests.get(url, timeout=3, headers={
            'User-Agent': 'NordaBiznes/1.0 (Link Preview)'
        }, allow_redirects=True)
        resp.raise_for_status()

        if 'text/html' not in resp.headers.get('content-type', ''):
            return None

        # Parse only first 100KB
        html = resp.text[:100_000]
        parser = OGParser()
        parser.feed(html)

        title = parser.og.get('title') or parser.title
        if not title:
            return None

        return {
            'url': url,
            'title': title[:200],
            'description': (parser.og.get('description') or '')[:300],
            'image': parser.og.get('image', ''),
        }
    except Exception as e:
        logger.debug(f"Link preview failed for {url}: {e}")
        return None
  • Step 2: Wire into message sending

In message_routes.py, after saving the message:

from blueprints.messages.link_preview import fetch_link_preview
preview = fetch_link_preview(content)
if preview:
    message.link_preview = preview
    db.commit()
  • Step 3: Commit
git add blueprints/messages/link_preview.py
git commit -m "feat(messages): add link preview for URLs in messages"

Task 8: Frontend — CSS

Files:

  • Create: static/css/conversations.css

  • Step 1: Create conversations.css

Port and refine styles from the mockup (mockups/messages_chat_view.html). Use the existing CSS variable system (var(--primary), var(--surface), var(--text-primary), etc.) from the portal's base styles rather than hardcoded colors.

Key sections:

  • .conversations-container — flex layout, full viewport height

  • .conversations-panel — left panel (380px), conversation list items, hover/active states, unread badges, avatars

  • .chat-panel — right panel, header, messages area, input area

  • .message-bubble — mine (accent color, right) / theirs (surface, left), consecutive grouping

  • .message-reactions — pill badges under bubbles

  • .message-reply-quote — cited message preview

  • .link-preview-card — title + description + image card

  • .date-separator — centered date between message groups

  • .typing-indicator — animated dots

  • .pinned-bar — pinned messages strip under header

  • .context-menu — hover menu for message actions

  • Mobile breakpoint (max-width: 768px) — single-panel mode, bottom sheet menus

  • Step 2: Commit

git add static/css/conversations.css
git commit -m "feat(messages): add conversation view CSS"

Task 9: Frontend — HTML Template

Files:

  • Create: templates/messages/conversations.html

  • Step 1: Create conversations.html

Extends base.html. Single template for the entire messaging experience.

Structure:

{% block extra_css %} — link to conversations.css
{% block content %}
  .conversations-container
    .conversations-panel
      .conversations-header (title + search + new message btn)
      .conversation-list (rendered server-side for initial load, updated by JS)
    .chat-panel
      .chat-empty (shown when no conversation selected)
      .chat-header (hidden until conversation selected)
      .chat-messages (scrollable, filled by JS)
      .chat-input-area (Quill editor + attachments + send)
      .pinned-bar (hidden until pins exist)
{% block extra_js %} — link to conversations.js

Initial data: embed conversations list as JSON in a <script> tag for instant render without API call:

<script>
  window.__CONVERSATIONS__ = {{ conversations_json|safe }};
  window.__CURRENT_USER__ = {{ current_user_json|safe }};
</script>
  • Step 2: Commit
git add templates/messages/conversations.html
git commit -m "feat(messages): add conversation view template"

Task 10: Frontend — JavaScript

Files:

  • Create: static/js/conversations.js

  • Step 1: Create conversations.js

Main JS file handling all client-side logic. Modules (as functions/objects, no build step):

ConversationList — render conversation items, handle selection, search filtering, update on new messages, unread badges, muted/archived indicators

ChatView — render messages for selected conversation, cursor-based scroll-up loading, scroll to bottom on new message, date separators, read receipt indicators (ptaszki for 1:1, avatars for groups)

MessageActions — context menu on hover/long-press: reply, react, forward, pin, edit, delete. Reply-to quote UI. Edit mode. Delete confirmation.

Reactions — emoji picker (6 emoji), toggle reaction via API, render pill badges, update via SSE

Composer — Quill editor init, send on Enter (Shift+Enter = newline), attachment drag&drop (reuse existing pattern from compose.html), typing indicator (debounce 2s, POST to /api/conversations//typing)

SSEClient — connect to /api/messages/stream, handle events (new_message, message_read, typing, reaction, message_edited, message_deleted, message_pinned, presence), heartbeat every 30s, auto-reconnect on disconnect

Presence — batch fetch /api/users/presence for visible conversation members, update online dots and "last seen" text, refresh every 60s

Search — filter conversation list client-side by name, search within conversation via API

Pins — fetch/render pinned messages bar, pin/unpin actions

LinkPreview — render link_preview JSON as card under message content

  • Step 2: Commit
git add static/js/conversations.js
git commit -m "feat(messages): add conversation view JavaScript"

Task 11: Old Routes — Redirect + Backward Compat

Files:

  • Modify: blueprints/messages/routes.py

  • Step 1: Redirect old inbox/sent to new view

At the top of messages_inbox and messages_sent functions, add redirect:

@bp.route('/wiadomosci/stare')
@login_required
@member_required
def messages_inbox():
    # Old inbox — keep for direct links but redirect
    ...existing code...

# New primary route
@bp.route('/wiadomosci')
@login_required
@member_required
def conversations_view():
    return redirect(url_for('messages.conversations'))

Change old /wiadomosci route to /wiadomosci/stare so existing bookmarks still work but new links go to conversations view.

  • Step 2: Update nav links

In templates/base.html, update the messages nav link to point to new route.

  • Step 3: Commit
git add blueprints/messages/routes.py templates/base.html
git commit -m "feat(messages): redirect old inbox to new conversation view"

Task 12: Data Migration Script

Files:

  • Create: scripts/migrate_messages.py

  • Step 1: Create migration script

Script that:

  1. Groups private_messages by unique (sender_id, recipient_id) pairs → creates Conversation per pair (is_group=False)
  2. Maps message_group → Conversation (is_group=True) with name, owner
  3. Maps message_group_member → ConversationMember with role, last_read_at
  4. Maps private_messages → ConvMessage preserving content, sender_id, created_at
  5. Maps parent_id → reply_to_id using old→new ID mapping
  6. Maps group_message → ConvMessage
  7. Updates message_attachments.conv_message_id using old→new mapping
  8. Sets conversation.last_message_id and updated_at
  9. Computes last_read_at for 1:1 members from PrivateMessage.read_at
  10. Validation: count old messages == count new messages, print summary

Usage: DATABASE_URL=... python3 scripts/migrate_messages.py [--dry-run]

  • Step 2: Test on dev database
# Start Docker DB
docker compose up -d

# Run migration (dry run)
DATABASE_URL=postgresql://nordabiz:nordabiz@localhost:5433/nordabiz python3 scripts/migrate_messages.py --dry-run

# Run migration (real)
DATABASE_URL=postgresql://nordabiz:nordabiz@localhost:5433/nordabiz python3 scripts/migrate_messages.py
  • Step 3: Commit
git add scripts/migrate_messages.py
git commit -m "feat(messages): add data migration script (old → unified model)"

Task 13: Nginx SSE Configuration

Files:

  • No code files — server configuration via SSH

  • Step 1: Add SSE proxy config on staging

SSH to R11-REVPROXY-01 and add custom nginx config for the SSE endpoint:

location /api/messages/stream {
    proxy_pass http://10.22.68.248:5000;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 86400s;
}

This can be added via NPM's "Advanced" tab for the staging proxy host (ID 44).

  • Step 2: Test SSE on staging
curl -N -H "Cookie: session=..." https://staging.nordabiznes.pl/api/messages/stream

Should receive data: {"event": "connected"} immediately.

  • Step 3: Document

Add SSE config note to docs/architecture/08-critical-configurations.md.


Task 14: Deploy + Production Migration

  • Step 1: Install Redis on production VM
ssh maciejpi@57.128.200.27 "sudo apt install redis-server -y && sudo systemctl enable redis-server"
  • Step 2: Deploy code to staging
git push origin master && git push inpi master
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
# Schema
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/091_messaging_redesign.sql"

# Data migration
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/migrate_messages.py"
  • Step 4: Test on staging

Verify at https://staging.nordabiznes.pl/wiadomosci:

  • Conversation list renders with migrated data

  • Opening a conversation shows message history

  • Sending a message works

  • SSE events arrive in real-time

  • Reactions, pins, typing indicator work

  • Mobile view works

  • Step 5: Deploy to production (after staging verification)

# Same steps as staging but on 57.128.200.27
# Plus: add SSE nginx config to production proxy host (ID 27)
  • Step 6: Update release notes

Add to _get_releases() in blueprints/public/routes.py.


Task 15: Unit Tests

Files:

  • Create: tests/unit/test_conversation_models.py

  • Create: tests/unit/test_conversation_api.py

  • Create: tests/unit/test_link_preview.py

  • Step 1: Model tests

Test Conversation, ConversationMember, ConvMessage creation, relationships, display_name property, member_count.

  • Step 2: Link preview tests

Test URL extraction, OG parsing, internal domain skipping, timeout handling.

  • Step 3: API tests

Test conversation creation (1:1 dedup), message sending, reaction toggle, pin/unpin, mute/archive settings, membership checks.

  • Step 4: Run all tests
pytest tests/unit/test_conversation_models.py tests/unit/test_conversation_api.py tests/unit/test_link_preview.py -v
  • Step 5: Commit
git add tests/unit/test_conversation_*.py tests/unit/test_link_preview.py
git commit -m "test(messages): add unit tests for conversation system"