# 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`: ```python # ============================================================ # 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'' 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'' 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'' 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'' 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'' ``` - [ ] **Step 2: Add conv_message_id FK to MessageAttachment** In `database.py`, find class `MessageAttachment` (line ~2334) and add: ```python 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: ```python from sqlalchemy import UniqueConstraint ``` - [ ] **Step 4: Write migration SQL** ```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** ```bash 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** ```python """ 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: ```python 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** ```bash 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** ```python """ 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//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`: ```python from . import sse_routes # noqa: F401 ``` - [ ] **Step 3: Commit** ```bash 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/` — details + members - `PATCH /api/conversations/` — edit name/description (owner, groups only) - `DELETE /api/conversations/` — delete (owner only) - `POST /api/conversations//members` — add member (owner, groups only) - `DELETE /api/conversations//members/` — remove member - `PATCH /api/conversations//settings` — mute/archive per user - `POST /api/conversations//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** ```python from . import conversation_routes # noqa: F401 ``` - [ ] **Step 4: Commit** ```bash 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//messages` — cursor-based pagination (before_id param), return messages with sender, reactions, reply_to, attachments - `POST /api/conversations//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/` — edit own message (max 24h old). Set edited_at. Publish SSE `message_edited`. - `DELETE /api/messages/` — soft delete own message. Set is_deleted=True. Publish SSE `message_deleted`. - `POST /api/messages//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): ```python 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** ```python from . import message_routes # noqa: F401 ``` - [ ] **Step 4: Commit** ```bash 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//reactions` — add reaction (emoji in body). Check membership. Upsert (unique constraint). Publish SSE `reaction`. - `DELETE /api/messages//reactions/` — remove own reaction. Publish SSE `reaction` with action=remove. - `POST /api/messages//pin` — pin message. Check membership. Create MessagePin. Publish SSE `message_pinned`. - `DELETE /api/messages//pin` — unpin. Delete MessagePin. Publish SSE. - `GET /api/conversations//pins` — list pinned messages with content preview. Allowed emoji set: `['👍', '❤️', '😂', '😮', '😢', '✅']` - [ ] **Step 2: Register in __init__.py** ```python from . import reaction_routes # noqa: F401 ``` - [ ] **Step 3: Commit** ```bash git add blueprints/messages/reaction_routes.py blueprints/messages/__init__.py git commit -m "feat(messages): add reactions and pins API" ``` --- ## Task 7: Link Preview **Files:** - Create: `blueprints/messages/link_preview.py` - [ ] **Step 1: Create link_preview.py** ```python """ 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: ```python 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** ```bash 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** ```bash 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 ` ``` - [ ] **Step 2: Commit** ```bash 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** ```bash 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: ```python @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** ```bash 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** ```bash # 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** ```bash 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: ```nginx 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** ```bash 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** ```bash ssh maciejpi@57.128.200.27 "sudo apt install redis-server -y && sudo systemctl enable redis-server" ``` - [ ] **Step 2: Deploy code to staging** ```bash 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** ```bash # 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)** ```bash # 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** ```bash pytest tests/unit/test_conversation_models.py tests/unit/test_conversation_api.py tests/unit/test_link_preview.py -v ``` - [ ] **Step 5: Commit** ```bash git add tests/unit/test_conversation_*.py tests/unit/test_link_preview.py git commit -m "test(messages): add unit tests for conversation system" ```