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

1120 lines
37 KiB
Markdown

# 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'<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:
```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/<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`:
```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/<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**
```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/<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):
```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/<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**
```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 `<script>` tag for instant render without API call:
```html
<script>
window.__CONVERSATIONS__ = {{ conversations_json|safe }};
window.__CURRENT_USER__ = {{ current_user_json|safe }};
</script>
```
- [ ] **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/<id>/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"
```